diff --git a/cdk/package.json b/cdk/package.json index 98d67261..a116a7f1 100644 --- a/cdk/package.json +++ b/cdk/package.json @@ -19,6 +19,7 @@ "@aws-sdk/client-bedrock-agentcore": "^3.1078.0", "@aws-sdk/client-bedrock-agentcore-control": "^3.1078.0", "@aws-sdk/client-bedrock-runtime": "^3.1078.0", + "@aws-sdk/client-cognito-identity-provider": "^3.1078.0", "@aws-sdk/client-dynamodb": "^3.1078.0", "@aws-sdk/client-ecs": "^3.1078.0", "@aws-sdk/client-lambda": "^3.1078.0", diff --git a/cdk/src/constructs/budget-alerts.ts b/cdk/src/constructs/budget-alerts.ts new file mode 100644 index 00000000..68b866c9 --- /dev/null +++ b/cdk/src/constructs/budget-alerts.ts @@ -0,0 +1,67 @@ +/** + * 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 { Duration } from 'aws-cdk-lib'; +import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch'; +import { Construct } from 'constructs'; +import { + BUDGET_EXCEEDED_PERCENT, + BUDGET_WARNING_PERCENT, +} from '../handlers/shared/budgets'; + +const ALARM_PERIOD_MINUTES = 1; +const METRIC_NAMESPACE = 'ABCA/Budgets'; + +/** CloudWatch alarms for one-shot monthly budget threshold metrics. */ +export class BudgetAlerts extends Construct { + public readonly warningAlarm: cloudwatch.Alarm; + public readonly exceededAlarm: cloudwatch.Alarm; + + constructor(scope: Construct, id: string) { + super(scope, id); + + const thresholdMetric = (threshold: number): cloudwatch.Metric => new cloudwatch.Metric({ + namespace: METRIC_NAMESPACE, + metricName: 'BudgetThresholdCrossed', + dimensionsMap: { Threshold: String(threshold) }, + statistic: 'Sum', + period: Duration.minutes(ALARM_PERIOD_MINUTES), + }); + this.warningAlarm = new cloudwatch.Alarm(this, 'WarningAlarm', { + metric: thresholdMetric(BUDGET_WARNING_PERCENT), + threshold: 1, + evaluationPeriods: 1, + comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING, + alarmDescription: + 'A user or Cognito-team monthly ABCA budget crossed 80%. ' + + 'Inspect OrchestrationReconciler logs for the scope and spend details (#471).', + }); + this.exceededAlarm = new cloudwatch.Alarm(this, 'ExceededAlarm', { + metric: thresholdMetric(BUDGET_EXCEEDED_PERCENT), + threshold: 1, + evaluationPeriods: 1, + comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING, + alarmDescription: + 'A user or Cognito-team monthly ABCA budget crossed 100%. ' + + 'Hard-stop scopes reject new tasks until the next UTC month (#471).', + }); + } +} diff --git a/cdk/src/constructs/budget-table.ts b/cdk/src/constructs/budget-table.ts new file mode 100644 index 00000000..739eaccf --- /dev/null +++ b/cdk/src/constructs/budget-table.ts @@ -0,0 +1,80 @@ +/** + * 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 { Construct } from 'constructs'; + +export const BUDGET_CONFIG_INDEX_NAME = 'record_type-scope_key-index'; + +export interface BudgetTableProps { + /** Optional physical table name. */ + readonly tableName?: string; + /** Resource lifecycle on stack deletion. @default RemovalPolicy.DESTROY */ + readonly removalPolicy?: RemovalPolicy; + /** Enable point-in-time recovery. @default true */ + readonly pointInTimeRecovery?: boolean; +} + +/** + * Monthly user/team budget configuration and spend rollups. + * + * Key layout: + * - ``scope_key = USER#`` or ``TEAM#`` + * - ``period = CONFIG`` for the recurring limit + * - ``period = YYYY-MM`` for one month's spend + * - ``scope_key = TASK#, period = ROLLUP`` for stream deduplication + */ +export class BudgetTable extends Construct { + public readonly table: dynamodb.Table; + + constructor(scope: Construct, id: string, props: BudgetTableProps = {}) { + super(scope, id); + + this.table = new dynamodb.Table(this, 'Table', { + tableName: props.tableName, + partitionKey: { + name: 'scope_key', + type: dynamodb.AttributeType.STRING, + }, + sortKey: { + name: 'period', + type: dynamodb.AttributeType.STRING, + }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + timeToLiveAttribute: 'ttl', + pointInTimeRecoverySpecification: { + pointInTimeRecoveryEnabled: props.pointInTimeRecovery ?? true, + }, + removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY, + }); + this.table.addGlobalSecondaryIndex({ + indexName: BUDGET_CONFIG_INDEX_NAME, + partitionKey: { + name: 'record_type', + type: dynamodb.AttributeType.STRING, + }, + sortKey: { + name: 'scope_key', + type: dynamodb.AttributeType.STRING, + }, + projectionType: dynamodb.ProjectionType.ALL, + }); + } +} diff --git a/cdk/src/constructs/jira-integration.ts b/cdk/src/constructs/jira-integration.ts index b5bd5668..c76b4cd5 100644 --- a/cdk/src/constructs/jira-integration.ts +++ b/cdk/src/constructs/jira-integration.ts @@ -80,6 +80,9 @@ export interface JiraIntegrationProps { /** The DynamoDB task events table. */ readonly taskEventsTable: dynamodb.ITable; + /** Monthly user/team budget configuration and spend table. */ + readonly budgetTable?: dynamodb.ITable; + /** Shared orchestration DAG table. Omit to retain one-issue/one-task mode. */ readonly orchestrationTable?: dynamodb.ITable; @@ -253,6 +256,10 @@ export class JiraIntegration extends Construct { props.maxConcurrentTasksPerUser ?? 10, ); } + if (props.budgetTable) { + createTaskEnv.BUDGET_TABLE_NAME = props.budgetTable.tableName; + createTaskEnv.USER_POOL_ID = props.userPool.userPoolId; + } // --- Cognito Authorizer (for /jira/link) --- const cognitoAuthorizer = new apigw.CognitoUserPoolsAuthorizer(this, 'JiraCognitoAuthorizer', { @@ -316,6 +323,13 @@ export class JiraIntegration extends Construct { if (props.repoTable) { props.repoTable.grantReadData(webhookProcessorFn); } + if (props.budgetTable) { + props.budgetTable.grantReadData(webhookProcessorFn); + webhookProcessorFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['cognito-idp:AdminListGroupsForUser'], + resources: [props.userPool.userPoolArn], + })); + } if (props.orchestratorFunctionArn) { webhookProcessorFn.addToRolePolicy(new iam.PolicyStatement({ actions: ['lambda:InvokeFunction'], diff --git a/cdk/src/constructs/linear-integration.ts b/cdk/src/constructs/linear-integration.ts index ff8627ae..bd4e7b0c 100644 --- a/cdk/src/constructs/linear-integration.ts +++ b/cdk/src/constructs/linear-integration.ts @@ -70,6 +70,9 @@ export interface LinearIntegrationProps { /** The DynamoDB task events table. */ readonly taskEventsTable: dynamodb.ITable; + /** Monthly user/team budget configuration and spend table. */ + readonly budgetTable?: dynamodb.ITable; + /** The DynamoDB repo config table (optional — for repo onboarding checks). */ readonly repoTable?: dynamodb.ITable; @@ -233,6 +236,10 @@ export class LinearIntegration extends Construct { if (props.attachmentsBucket) { createTaskEnv.ATTACHMENTS_BUCKET_NAME = props.attachmentsBucket.bucketName; } + if (props.budgetTable) { + createTaskEnv.BUDGET_TABLE_NAME = props.budgetTable.tableName; + createTaskEnv.USER_POOL_ID = props.userPool.userPoolId; + } // --- Cognito Authorizer (for /linear/link) --- const cognitoAuthorizer = new apigw.CognitoUserPoolsAuthorizer(this, 'LinearCognitoAuthorizer', { @@ -315,6 +322,13 @@ export class LinearIntegration extends Construct { if (props.repoTable) { props.repoTable.grantReadData(webhookProcessorFn); } + if (props.budgetTable) { + props.budgetTable.grantReadData(webhookProcessorFn); + webhookProcessorFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['cognito-idp:AdminListGroupsForUser'], + resources: [props.userPool.userPoolArn], + })); + } if (props.orchestratorFunctionArn) { webhookProcessorFn.addToRolePolicy(new iam.PolicyStatement({ actions: ['lambda:InvokeFunction'], diff --git a/cdk/src/constructs/orchestration-reconciler.ts b/cdk/src/constructs/orchestration-reconciler.ts index 1b0b85a2..761dd33d 100644 --- a/cdk/src/constructs/orchestration-reconciler.ts +++ b/cdk/src/constructs/orchestration-reconciler.ts @@ -47,18 +47,20 @@ export interface OrchestrationReconcilerProps { /** Forwarded so released child tasks land in the right tables. */ readonly taskEventsTable: dynamodb.ITable; + + /** Monthly budget config/rollup table. When set, terminal task costs roll up here. */ + readonly budgetTable?: dynamodb.ITable; } /** - * TaskTable-stream consumer that drives Linear parent/sub-issue - * orchestration. On each child task reaching a - * terminal status it releases newly-unblocked children in dependency - * order (see `handlers/orchestration-reconciler.ts`). + * TaskTable terminal-record consumer. It rolls up every positive task cost into + * monthly user/team budgets, then drives parent/sub-issue orchestration for + * records that belong to a graph. * * Stream-source rationale: TaskEventsTable's stream is at its 2-consumer * limit (FanOutConsumer + ApprovalMetricsPublisher); TaskTable had no - * stream, so the reconciler is its first and only consumer — zero - * contention with the fan-out plane. + * stream, so this combined consumer has zero contention with the fan-out plane + * and leaves one DynamoDB Streams consumer slot available. */ /** DLQ message retention (days) — long enough for an operator to inspect a @@ -90,6 +92,9 @@ export class OrchestrationReconciler extends Construct { ORCHESTRATION_TABLE_NAME: props.orchestrationTable.tableName, TASK_TABLE_NAME: props.taskTable.tableName, TASK_EVENTS_TABLE_NAME: props.taskEventsTable.tableName, + ...(props.budgetTable && { + BUDGET_TABLE_NAME: props.budgetTable.tableName, + }), ...(props.orchestratorFunctionArn && { ORCHESTRATOR_FUNCTION_ARN: props.orchestratorFunctionArn, }), @@ -121,6 +126,7 @@ export class OrchestrationReconciler extends Construct { props.orchestrationTable.grantReadWriteData(this.fn); props.taskTable.grantReadWriteData(this.fn); props.taskEventsTable.grantReadWriteData(this.fn); + props.budgetTable?.grantReadWriteData(this.fn); // Subscribe to the TaskTable stream. LATEST: we only care about // tasks transitioning to terminal from here on. bisectBatchOnError + diff --git a/cdk/src/constructs/slack-integration.ts b/cdk/src/constructs/slack-integration.ts index a7224b92..df853f29 100644 --- a/cdk/src/constructs/slack-integration.ts +++ b/cdk/src/constructs/slack-integration.ts @@ -64,6 +64,9 @@ export interface SlackIntegrationProps { /** The DynamoDB task events table (must have DynamoDB Streams enabled). */ readonly taskEventsTable: dynamodb.ITable; + /** Monthly user/team budget configuration and spend table. */ + readonly budgetTable?: dynamodb.ITable; + /** The DynamoDB repo config table (optional — for repo onboarding checks). */ readonly repoTable?: dynamodb.ITable; @@ -210,6 +213,10 @@ export class SlackIntegration extends Construct { createTaskEnv.GUARDRAIL_ID = props.guardrailId; createTaskEnv.GUARDRAIL_VERSION = props.guardrailVersion; } + if (props.budgetTable) { + createTaskEnv.BUDGET_TABLE_NAME = props.budgetTable.tableName; + createTaskEnv.USER_POOL_ID = props.userPool.userPoolId; + } // ═══════════════════════════════════════════════════════════════════════════ // Lambda Handlers @@ -304,6 +311,13 @@ export class SlackIntegration extends Construct { if (props.repoTable) { props.repoTable.grantReadData(commandProcessorFn); } + if (props.budgetTable) { + props.budgetTable.grantReadData(commandProcessorFn); + commandProcessorFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['cognito-idp:AdminListGroupsForUser'], + resources: [props.userPool.userPoolArn], + })); + } if (props.orchestratorFunctionArn) { commandProcessorFn.addToRolePolicy(new iam.PolicyStatement({ actions: ['lambda:InvokeFunction'], diff --git a/cdk/src/constructs/task-api.ts b/cdk/src/constructs/task-api.ts index 788b8f69..8769f440 100644 --- a/cdk/src/constructs/task-api.ts +++ b/cdk/src/constructs/task-api.ts @@ -233,6 +233,12 @@ export interface TaskApiProps { */ readonly userConcurrencyTable?: dynamodb.ITable; + /** + * Monthly user/team budget configuration and spend table. When provided, + * task creation resolves Cognito groups and enforces hard-stop budgets. + */ + readonly budgetTable?: dynamodb.ITable; + } /** @@ -242,6 +248,7 @@ export interface TaskApiProps { * Exposes endpoints: * - POST /tasks → createTask (Cognito) * - GET /tasks → listTasks (Cognito) + * - GET /tasks?view=budget → personal monthly budget status (Cognito) * - GET /tasks/{task_id} → getTask (Cognito) * - DELETE /tasks/{task_id} → cancelTask (Cognito) * - GET /tasks/{task_id}/events → getTaskEvents (Cognito) @@ -603,6 +610,10 @@ export class TaskApi extends Construct { if (props.attachmentsBucket) { createTaskEnv.ATTACHMENTS_BUCKET_NAME = props.attachmentsBucket.bucketName; } + if (props.budgetTable) { + createTaskEnv.BUDGET_TABLE_NAME = props.budgetTable.tableName; + createTaskEnv.USER_POOL_ID = this.userPool.userPoolId; + } const createTaskFn = new lambda.NodejsFunction(this, 'CreateTaskFn', { entry: path.join(handlersDir, 'create-task.ts'), @@ -629,12 +640,16 @@ export class TaskApi extends Construct { bundling: commonBundling, }); + const listTasksEnv: Record = { ...commonEnv }; + if (props.budgetTable) { + listTasksEnv.BUDGET_TABLE_NAME = props.budgetTable.tableName; + } const listTasksFn = new lambda.NodejsFunction(this, 'ListTasksFn', { entry: path.join(handlersDir, 'list-tasks.ts'), handler: 'handler', runtime: Runtime.NODEJS_24_X, architecture: Architecture.ARM_64, - environment: commonEnv, + environment: listTasksEnv, bundling: commonBundling, }); @@ -749,6 +764,10 @@ export class TaskApi extends Construct { if (props.repoTable) { props.repoTable.grantReadData(createTaskFn); } + if (props.budgetTable) { + props.budgetTable.grantReadData(createTaskFn); + props.budgetTable.grantReadData(listTasksFn); + } // Read-only for get, list, and events props.taskTable.grantReadData(getTaskFn); @@ -1276,6 +1295,13 @@ export class TaskApi extends Construct { if (props.repoTable) { props.repoTable.grantReadData(webhookCreateTaskFn); } + if (props.budgetTable) { + props.budgetTable.grantReadData(webhookCreateTaskFn); + webhookCreateTaskFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['cognito-idp:AdminListGroupsForUser'], + resources: [this.userPool.userPoolArn], + })); + } if (props.orchestratorFunctionArn) { webhookCreateTaskFn.addToRolePolicy(new iam.PolicyStatement({ diff --git a/cdk/src/constructs/task-table.ts b/cdk/src/constructs/task-table.ts index 7702faa9..4ac74d13 100644 --- a/cdk/src/constructs/task-table.ts +++ b/cdk/src/constructs/task-table.ts @@ -117,14 +117,13 @@ export class TaskTable extends Construct { pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: props.pointInTimeRecovery ?? true, }, - // NEW_IMAGE stream feeds the orchestration reconciler - // (`OrchestrationReconciler`), which reacts to child tasks reaching - // terminal status to release dependency-unblocked children. This is - // the table's FIRST and only stream consumer — deliberately on + // NEW_IMAGE stream feeds the combined terminal-task reconciler + // (`OrchestrationReconciler`), which rolls up monthly budget spend and + // releases dependency-unblocked children. This is deliberately on // TaskTable rather than TaskEventsTable, whose stream is already at // its 2-consumer limit (FanOutConsumer + ApprovalMetricsPublisher; - // see TaskEventsTable). NEW_IMAGE suffices — the reconciler reads - // status/build_passed/orchestration_id off the new record image. + // see TaskEventsTable). It uses one of TaskTable's two DynamoDB Streams + // consumer slots. NEW_IMAGE contains every field both paths need. // Enabling a stream on an existing table is an in-place CFN update // (no table replacement). stream: dynamodb.StreamViewType.NEW_IMAGE, diff --git a/cdk/src/handlers/budget-rollup.ts b/cdk/src/handlers/budget-rollup.ts new file mode 100644 index 00000000..fd47c5a6 --- /dev/null +++ b/cdk/src/handlers/budget-rollup.ts @@ -0,0 +1,269 @@ +/** + * 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 { + GetCommand, + TransactWriteCommand, + UpdateCommand, +} from '@aws-sdk/lib-dynamodb'; +import type { DynamoDBRecord } from 'aws-lambda'; +import { + BUDGET_EXCEEDED_PERCENT, + BUDGET_ROLLUP_PERIOD, + BUDGET_WARNING_PERCENT, + budgetPeriod, + loadBudgetStates, + MAX_BUDGET_SCOPES_PER_TASK, + taskBudgetMarkerKey, + teamBudgetScopeKey, + userBudgetScopeKey, +} from './shared/budgets'; +import { logger } from './shared/logger'; +import { makeDocClient } from './shared/ua'; +import { TERMINAL_STATUSES, type TaskStatusType } from '../constructs/task-status'; + +const BUDGET_TABLE_NAME = process.env.BUDGET_TABLE_NAME; +const BUDGET_METRIC_NAMESPACE = 'ABCA/Budgets'; +const ROLLUP_RETENTION_DAYS = 400; +const SECONDS_PER_DAY = 24 * 60 * 60; +const TERMINAL = new Set(TERMINAL_STATUSES); +const ddb = makeDocClient(); + +interface TaskCostEvent { + readonly taskId: string; + readonly userId: string; + readonly teamIds: readonly string[]; + readonly period: string; + readonly costUsd: number; +} + +function numberAttribute(record: DynamoDBRecord, field: string): number | null { + const attr = record.dynamodb?.NewImage?.[field]; + const value = attr?.N ?? attr?.S; + if (value === undefined) return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +export function parseTaskCostEvent(record: DynamoDBRecord): TaskCostEvent | null { + if (record.eventName !== 'MODIFY' && record.eventName !== 'INSERT') return null; + const image = record.dynamodb?.NewImage; + if (!image) return null; + + const status = image.status?.S as TaskStatusType | undefined; + const taskId = image.task_id?.S; + const userId = image.user_id?.S; + const costUsd = numberAttribute(record, 'cost_usd'); + if (!status || !TERMINAL.has(status) || !taskId || !userId || costUsd === null || costUsd <= 0) { + return null; + } + + const teamIds = (image.team_ids?.L ?? []) + .map(value => value.S) + .filter((value): value is string => Boolean(value)); + const completedAt = image.completed_at?.S ?? image.updated_at?.S; + const completedDate = completedAt ? new Date(completedAt) : new Date(); + const period = Number.isNaN(completedDate.getTime()) + ? budgetPeriod() + : budgetPeriod(completedDate); + + return { + taskId, + userId, + teamIds: [...new Set(teamIds)].sort(), + period, + costUsd, + }; +} + +function ttlEpoch(now: Date = new Date()): number { + return Math.floor(now.getTime() / 1000) + (ROLLUP_RETENTION_DAYS * SECONDS_PER_DAY); +} + +function isTransactionCanceled(err: unknown): boolean { + return typeof err === 'object' + && err !== null + && 'name' in err + && (err as { name?: string }).name === 'TransactionCanceledException'; +} + +function isConditionalCheckFailed(err: unknown): boolean { + return typeof err === 'object' + && err !== null + && 'name' in err + && (err as { name?: string }).name === 'ConditionalCheckFailedException'; +} + +async function markerExists(taskId: string): Promise { + if (!BUDGET_TABLE_NAME) return false; + const result = await ddb.send(new GetCommand({ + TableName: BUDGET_TABLE_NAME, + Key: { + scope_key: taskBudgetMarkerKey(taskId), + period: BUDGET_ROLLUP_PERIOD, + }, + ConsistentRead: true, + })); + return result.Item !== undefined; +} + +async function writeRollup(evt: TaskCostEvent): Promise { + if (!BUDGET_TABLE_NAME) return false; + const scopeKeys = [ + userBudgetScopeKey(evt.userId), + ...evt.teamIds.map(teamBudgetScopeKey), + ]; + if (scopeKeys.length > MAX_BUDGET_SCOPES_PER_TASK) { + throw new Error( + `Task ${evt.taskId} has ${scopeKeys.length} budget scopes; maximum is ` + + `${MAX_BUDGET_SCOPES_PER_TASK}.`, + ); + } + + const now = new Date().toISOString(); + const ttl = ttlEpoch(); + try { + await ddb.send(new TransactWriteCommand({ + TransactItems: [ + { + Put: { + TableName: BUDGET_TABLE_NAME, + Item: { + scope_key: taskBudgetMarkerKey(evt.taskId), + period: BUDGET_ROLLUP_PERIOD, + task_id: evt.taskId, + rolled_up_period: evt.period, + cost_usd: evt.costUsd, + created_at: now, + ttl, + }, + ConditionExpression: 'attribute_not_exists(scope_key)', + }, + }, + ...scopeKeys.map(scopeKey => ({ + Update: { + TableName: BUDGET_TABLE_NAME, + Key: { scope_key: scopeKey, period: evt.period }, + UpdateExpression: + 'SET updated_at = :now, #ttl = :ttl ' + + 'ADD spend_usd :cost, task_count :one', + ExpressionAttributeNames: { + '#ttl': 'ttl', + }, + ExpressionAttributeValues: { + ':now': now, + ':ttl': ttl, + ':cost': evt.costUsd, + ':one': 1, + }, + }, + })), + ], + })); + return true; + } catch (err) { + if (isTransactionCanceled(err) && await markerExists(evt.taskId)) { + logger.info('Budget rollup already applied', { task_id: evt.taskId }); + return false; + } + throw err; + } +} + +function emitThresholdMetric( + threshold: number, + state: Awaited>[number], +): void { + process.stdout.write(JSON.stringify({ + _aws: { + Timestamp: Date.now(), + CloudWatchMetrics: [{ + Namespace: BUDGET_METRIC_NAMESPACE, + Dimensions: [['Threshold']], + Metrics: [{ Name: 'BudgetThresholdCrossed', Unit: 'Count' }], + }], + }, + Threshold: String(threshold), + BudgetThresholdCrossed: 1, + scope_type: state.scopeType, + scope_id: state.scopeId, + period: state.period, + spend_usd: state.spendUsd, + monthly_limit_usd: state.monthlyLimitUsd, + utilization_percent: state.utilizationPercent, + hard_stop: state.hardStop, + }) + '\n'); +} + +async function claimAndEmitAlert( + threshold: typeof BUDGET_WARNING_PERCENT | typeof BUDGET_EXCEEDED_PERCENT, + state: Awaited>[number], +): Promise { + if (!BUDGET_TABLE_NAME || state.utilizationPercent < threshold) return; + const alreadyAlerted = threshold === BUDGET_WARNING_PERCENT + ? state.warningAlerted + : state.exceededAlerted; + if (alreadyAlerted) return; + + // Emit before claiming. If the process fails between these operations, the + // stream retry can emit again instead of permanently suppressing the alert. + // Concurrent retries may duplicate the metric, which the threshold alarm + // tolerates; the conditional claim stops later deliveries. + emitThresholdMetric(threshold, state); + const suffix = String(threshold); + try { + await ddb.send(new UpdateCommand({ + TableName: BUDGET_TABLE_NAME, + Key: { scope_key: state.scopeKey, period: state.period }, + UpdateExpression: + `SET alerted_${suffix}_at = :now, ` + + `alerted_${suffix}_spend_usd = :spend, ` + + `alerted_${suffix}_limit_usd = :limit`, + ConditionExpression: `attribute_not_exists(alerted_${suffix}_at)`, + ExpressionAttributeValues: { + ':now': new Date().toISOString(), + ':spend': state.spendUsd, + ':limit': state.monthlyLimitUsd, + }, + })); + } catch (err) { + if (!isConditionalCheckFailed(err)) throw err; + } +} + +/** Apply one terminal TaskTable stream record to monthly budget rollups. */ +export async function rollupTaskCost(record: DynamoDBRecord): Promise { + if (!BUDGET_TABLE_NAME) return false; + const evt = parseTaskCostEvent(record); + if (!evt) return false; + const wrote = await writeRollup(evt); + + // Do not return early when the task marker already exists. A prior delivery + // may have committed spend and then failed before completing alert delivery. + const scopeKeys = [ + userBudgetScopeKey(evt.userId), + ...evt.teamIds.map(teamBudgetScopeKey), + ]; + const states = await loadBudgetStates(scopeKeys, evt.period); + for (const state of states) { + await claimAndEmitAlert(BUDGET_WARNING_PERCENT, state); + await claimAndEmitAlert(BUDGET_EXCEEDED_PERCENT, state); + } + return wrote; +} diff --git a/cdk/src/handlers/create-task.ts b/cdk/src/handlers/create-task.ts index ac909f23..268fd68a 100644 --- a/cdk/src/handlers/create-task.ts +++ b/cdk/src/handlers/create-task.ts @@ -20,7 +20,7 @@ import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { ulid } from 'ulid'; import { createTaskCore } from './shared/create-task-core'; -import { buildChannelMetadata, extractUserId } from './shared/gateway'; +import { buildChannelMetadata, extractUserGroups, extractUserId } from './shared/gateway'; import { logger } from './shared/logger'; import { ErrorCode, errorResponse } from './shared/response'; import type { CreateTaskRequest } from './shared/types'; @@ -51,6 +51,7 @@ export async function handler(event: APIGatewayProxyEvent): Promise { /** * Lambda entry point — TaskTable stream handler. * - * Processes records sequentially; a failure on one record throws so the - * stream retries the batch (idempotent replay is safe). Non-terminal / - * non-orchestration records are skipped cheaply. + * Processes records sequentially. Orchestration reconciliation and the + * idempotent budget rollup are attempted independently, so an outage in either + * subsystem cannot prevent the other from progressing. Either failure still + * reports the record for retry. */ export async function handler(event: DynamoDBStreamEvent): Promise { let processed = 0; + let budgetRolledUp = 0; // Per-record isolation. A thrown record is reported as a // batch item failure (by its stream sequence number) so ONLY it retries, // instead of failing the whole batch and re-driving its healthy siblings. const batchItemFailures: { itemIdentifier: string }[] = []; for (const record of event.Records) { const seq = record.dynamodb?.SequenceNumber; + let orchestrationError: unknown; + let budgetError: unknown; + try { const evt = parseTerminalTaskRecord(record); - if (!evt) continue; - // Restack cascade: an iteration/restack task on a node X (NOT a child-row task) - // re-stacks X's direct dependents. Routed here, not through child gating. - if (evt.cascadeSubIssueId) { - await cascadeRestack(evt); - } else { - await reconcileTerminalChild(evt); + if (evt) { + // Restack cascade: an iteration/restack task on a node X (NOT a child-row task) + // re-stacks X's direct dependents. Routed here, not through child gating. + if (evt.cascadeSubIssueId) { + await cascadeRestack(evt); + } else { + await reconcileTerminalChild(evt); + } + processed += 1; } - processed += 1; } catch (err) { - logger.error('Orchestration reconciler record failed — reporting for isolated retry', { + orchestrationError = err; + } + + try { + if (await rollupTaskCost(record)) budgetRolledUp += 1; + } catch (err) { + budgetError = err; + } + + const error = orchestrationError ?? budgetError; + if (error) { + logger.error('TaskTable reconciler record failed — reporting for isolated retry', { sequence_number: seq, event_name: record.eventName, - error: err instanceof Error ? err.message : String(err), + orchestration_error: orchestrationError instanceof Error + ? orchestrationError.message + : orchestrationError === undefined ? undefined : String(orchestrationError), + budget_error: budgetError instanceof Error + ? budgetError.message + : budgetError === undefined ? undefined : String(budgetError), }); // Without a sequence number we can't report the item individually; rethrow // so the batch fails rather than silently dropping a real error. - if (!seq) throw err; + if (!seq) throw error; batchItemFailures.push({ itemIdentifier: seq }); } } logger.info('Orchestration reconciler batch processed', { records: event.Records.length, reconciled: processed, + budget_rolled_up: budgetRolledUp, failed: batchItemFailures.length, }); return { batchItemFailures }; diff --git a/cdk/src/handlers/shared/budgets.ts b/cdk/src/handlers/shared/budgets.ts new file mode 100644 index 00000000..c5186742 --- /dev/null +++ b/cdk/src/handlers/shared/budgets.ts @@ -0,0 +1,325 @@ +/** + * 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 { + AdminListGroupsForUserCommand, + CognitoIdentityProviderClient, +} from '@aws-sdk/client-cognito-identity-provider'; +import { BatchGetCommand } from '@aws-sdk/lib-dynamodb'; +import { logger } from './logger'; +import type { PersonalBudgetStatus } from './types'; +import { makeClient, makeDocClient } from './ua'; + +export const BUDGET_CONFIG_PERIOD = 'CONFIG'; +export const BUDGET_ROLLUP_PERIOD = 'ROLLUP'; +export const BUDGET_USER_PREFIX = 'USER#'; +export const BUDGET_TEAM_PREFIX = 'TEAM#'; +export const BUDGET_TASK_PREFIX = 'TASK#'; +export const BUDGET_WARNING_PERCENT = 80; +export const BUDGET_EXCEEDED_PERCENT = 100; + +/** DynamoDB transactions allow 100 actions; reserve one for the task marker. */ +export const MAX_BUDGET_SCOPES_PER_TASK = 99; + +const BATCH_GET_LIMIT = 100; +const budgetTableName = process.env.BUDGET_TABLE_NAME; +const userPoolId = process.env.USER_POOL_ID; +const ddb = makeDocClient(); +const cognito = budgetTableName && userPoolId + ? makeClient(CognitoIdentityProviderClient) + : undefined; + +export type BudgetScopeType = 'user' | 'team'; + +export interface BudgetConfig { + readonly scopeKey: string; + readonly scopeType: BudgetScopeType; + readonly scopeId: string; + readonly monthlyLimitUsd: number; + readonly hardStop: boolean; + readonly updatedAt?: string; +} + +export interface BudgetState extends BudgetConfig { + readonly period: string; + readonly spendUsd: number; + readonly utilizationPercent: number; + readonly warningAlerted: boolean; + readonly exceededAlerted: boolean; +} + +export interface BudgetBlock { + readonly scopeType: BudgetScopeType; + readonly scopeId: string; + readonly spendUsd: number; + readonly monthlyLimitUsd: number; +} + +export interface BudgetAdmissionResult { + readonly teamIds: readonly string[]; + readonly period: string; + readonly blocked: BudgetBlock | null; +} + +export function budgetPeriod(date: Date = new Date()): string { + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + return `${year}-${month}`; +} + +export function budgetResetAt(date: Date = new Date()): string { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1)).toISOString(); +} + +export function userBudgetScopeKey(userId: string): string { + return `${BUDGET_USER_PREFIX}${userId}`; +} + +export function teamBudgetScopeKey(teamId: string): string { + return `${BUDGET_TEAM_PREFIX}${teamId}`; +} + +export function taskBudgetMarkerKey(taskId: string): string { + return `${BUDGET_TASK_PREFIX}${taskId}`; +} + +export function parseBudgetScopeKey(scopeKey: string): { + scopeType: BudgetScopeType; + scopeId: string; +} | null { + if (scopeKey.startsWith(BUDGET_USER_PREFIX)) { + return { scopeType: 'user', scopeId: scopeKey.slice(BUDGET_USER_PREFIX.length) }; + } + if (scopeKey.startsWith(BUDGET_TEAM_PREFIX)) { + return { scopeType: 'team', scopeId: scopeKey.slice(BUDGET_TEAM_PREFIX.length) }; + } + return null; +} + +function numeric(value: unknown): number { + if (typeof value === 'number') return Number.isFinite(value) ? value : 0; + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } + return 0; +} + +async function resolveTeamIds(userId: string): Promise { + if (!userPoolId || !cognito) { + throw new Error('Budget admission requires USER_POOL_ID when team IDs are not supplied by the caller.'); + } + + const names: string[] = []; + let nextToken: string | undefined; + do { + const result = await cognito.send(new AdminListGroupsForUserCommand({ + UserPoolId: userPoolId, + Username: userId, + NextToken: nextToken, + })); + for (const group of result.Groups ?? []) { + if (group.GroupName) names.push(group.GroupName); + } + nextToken = result.NextToken; + } while (nextToken); + + return [...new Set(names)].sort(); +} + +async function batchGetItems(keys: readonly Record[]): Promise[]> { + if (!budgetTableName || keys.length === 0) return []; + + const items: Record[] = []; + for (let offset = 0; offset < keys.length; offset += BATCH_GET_LIMIT) { + let pendingKeys: Record[] = keys.slice(offset, offset + BATCH_GET_LIMIT); + do { + const result = await ddb.send(new BatchGetCommand({ + RequestItems: { + [budgetTableName]: { + Keys: pendingKeys, + ConsistentRead: true, + }, + }, + })); + items.push(...(result.Responses?.[budgetTableName] ?? [])); + pendingKeys = (result.UnprocessedKeys?.[budgetTableName]?.Keys ?? []) + .map(key => ({ + scope_key: String(key.scope_key), + period: String(key.period), + })); + } while (pendingKeys.length > 0); + } + return items; +} + +/** + * Load recurring configs and the named month's spend for each scope. + * Missing config rows are omitted; spend defaults to zero. + */ +export async function loadBudgetStates( + scopeKeys: readonly string[], + period: string, +): Promise { + if (!budgetTableName || scopeKeys.length === 0) return []; + + const keys = scopeKeys.flatMap(scopeKey => [ + { scope_key: scopeKey, period: BUDGET_CONFIG_PERIOD }, + { scope_key: scopeKey, period }, + ]); + const items = await batchGetItems(keys); + const byKey = new Map(items.map(item => [ + `${String(item.scope_key)}\0${String(item.period)}`, + item, + ])); + + const states: BudgetState[] = []; + for (const scopeKey of scopeKeys) { + const parsedScope = parseBudgetScopeKey(scopeKey); + if (!parsedScope) continue; + const config = byKey.get(`${scopeKey}\0${BUDGET_CONFIG_PERIOD}`); + if (!config) continue; + + const monthlyLimitUsd = numeric(config.monthly_limit_usd); + if (monthlyLimitUsd <= 0) { + throw new Error(`Budget config ${scopeKey} has invalid monthly_limit_usd.`); + } + const spend = byKey.get(`${scopeKey}\0${period}`); + const spendUsd = Math.max(0, numeric(spend?.spend_usd)); + states.push({ + scopeKey, + ...parsedScope, + monthlyLimitUsd, + hardStop: config.hard_stop === true, + updatedAt: typeof config.updated_at === 'string' ? config.updated_at : undefined, + period, + spendUsd, + utilizationPercent: (spendUsd / monthlyLimitUsd) * 100, + warningAlerted: spend !== undefined && Object.hasOwn(spend, 'alerted_80_at'), + exceededAlerted: spend !== undefined && Object.hasOwn(spend, 'alerted_100_at'), + }); + } + return states; +} + +/** Read the authenticated user's own monthly estimated-spend status. */ +export async function loadPersonalBudgetStatus( + userId: string, + now: Date = new Date(), +): Promise { + const period = budgetPeriod(now); + const scopeKey = userBudgetScopeKey(userId); + const items = await batchGetItems([ + { scope_key: scopeKey, period: BUDGET_CONFIG_PERIOD }, + { scope_key: scopeKey, period }, + ]); + const config = items.find(item => item.period === BUDGET_CONFIG_PERIOD); + const spend = items.find(item => item.period === period); + const spendUsd = Math.max(0, numeric(spend?.spend_usd)); + + if (!config) { + return { + period, + resets_at: budgetResetAt(now), + configured: false, + spend_usd: spendUsd, + monthly_limit_usd: null, + remaining_usd: null, + utilization_percent: null, + hard_stop: false, + hard_stop_active: false, + }; + } + + const monthlyLimitUsd = numeric(config.monthly_limit_usd); + if (monthlyLimitUsd <= 0) { + throw new Error(`Budget config ${scopeKey} has invalid monthly_limit_usd.`); + } + const utilizationPercent = (spendUsd / monthlyLimitUsd) * 100; + const hardStop = config.hard_stop === true; + return { + period, + resets_at: budgetResetAt(now), + configured: true, + spend_usd: spendUsd, + monthly_limit_usd: monthlyLimitUsd, + remaining_usd: Math.max(0, monthlyLimitUsd - spendUsd), + utilization_percent: utilizationPercent, + hard_stop: hardStop, + hard_stop_active: hardStop && utilizationPercent >= BUDGET_EXCEEDED_PERCENT, + }; +} + +/** + * Resolve all team memberships and enforce configured hard-stop budgets. + * + * When the budget table is not wired (unit tests or an older deployment), + * admission is unchanged and only caller-supplied team IDs are returned. + */ +export async function checkBudgetAdmission( + userId: string, + suppliedTeamIds?: readonly string[], + now: Date = new Date(), +): Promise { + const teamIds = suppliedTeamIds === undefined + ? (budgetTableName ? await resolveTeamIds(userId) : []) + : [...new Set(suppliedTeamIds)].sort(); + const scopeKeys = [ + userBudgetScopeKey(userId), + ...teamIds.map(teamBudgetScopeKey), + ]; + if (scopeKeys.length > MAX_BUDGET_SCOPES_PER_TASK) { + throw new Error( + `User ${userId} belongs to ${teamIds.length} teams; budget rollup supports at most ` + + `${MAX_BUDGET_SCOPES_PER_TASK - 1}.`, + ); + } + + const period = budgetPeriod(now); + const states = await loadBudgetStates(scopeKeys, period); + for (const state of states) { + if (state.utilizationPercent >= BUDGET_WARNING_PERCENT) { + logger.warn('Monthly budget is at or above the warning threshold', { + scope_type: state.scopeType, + scope_id: state.scopeId, + period, + spend_usd: state.spendUsd, + monthly_limit_usd: state.monthlyLimitUsd, + utilization_percent: state.utilizationPercent, + hard_stop: state.hardStop, + }); + } + } + + const blocked = states.find(state => + state.hardStop && state.utilizationPercent >= BUDGET_EXCEEDED_PERCENT); + + return { + teamIds, + period, + blocked: blocked + ? { + scopeType: blocked.scopeType, + scopeId: blocked.scopeId, + spendUsd: blocked.spendUsd, + monthlyLimitUsd: blocked.monthlyLimitUsd, + } + : null, + }; +} diff --git a/cdk/src/handlers/shared/create-task-core.ts b/cdk/src/handlers/shared/create-task-core.ts index 8a4b3f94..618ec299 100644 --- a/cdk/src/handlers/shared/create-task-core.ts +++ b/cdk/src/handlers/shared/create-task-core.ts @@ -30,6 +30,7 @@ import type { APIGatewayProxyResult } from 'aws-lambda'; import { ulid } from 'ulid'; import { isDegeneratePattern, parseApprovalScope } from './approval-scope'; import { screenImage, screenTextFile, AttachmentScreeningError, type ScreeningConfig } from './attachment-screening'; +import { checkBudgetAdmission } from './budgets'; import { generateBranchName } from './gateway'; import { estimateImageTokensFromBuffer } from './image-tokens'; import { logger } from './logger'; @@ -63,6 +64,12 @@ import { TaskStatus } from '../../constructs/task-status'; */ export interface TaskCreationContext { readonly userId: string; + /** + * Cognito group names used as team IDs for fleet budgets. The direct API + * supplies these from the authenticated JWT; headless channel adapters omit + * them and the budget helper resolves current membership from Cognito. + */ + readonly teamIds?: readonly string[]; readonly channelSource: ChannelSource; readonly channelMetadata: Record; readonly idempotencyKey?: string; @@ -680,6 +687,44 @@ export async function createTaskCore( } } + // 3b. Fleet budget admission. This intentionally runs AFTER idempotency + // replay so retrying an already-created task remains a 200 even if the + // user's/team's budget was exhausted after the original submission. + // Headless adapters resolve Cognito groups here; direct API calls pass the + // token's group claim through TaskCreationContext. + let budgetAdmission; + try { + budgetAdmission = await checkBudgetAdmission(context.userId, context.teamIds); + } catch (budgetErr) { + if (s3Client) await cleanupOrphanedAttachments(s3Client, uploadedS3Keys); + logger.error('Budget admission check failed closed', { + user_id: context.userId, + request_id: requestId, + error: budgetErr instanceof Error ? budgetErr.message : String(budgetErr), + metric_type: 'budget_admission_failure', + }); + return errorResponse( + 503, + ErrorCode.SERVICE_UNAVAILABLE, + 'Budget admission is temporarily unavailable. Please try again later.', + requestId, + ); + } + if (budgetAdmission.blocked) { + if (s3Client) await cleanupOrphanedAttachments(s3Client, uploadedS3Keys); + const blocked = budgetAdmission.blocked; + const owner = blocked.scopeType === 'user' + ? 'Your monthly budget' + : `The monthly budget for team '${blocked.scopeId}'`; + return errorResponse( + 429, + ErrorCode.BUDGET_EXCEEDED, + `${owner} is exhausted ($${blocked.spendUsd.toFixed(2)} of ` + + `$${blocked.monthlyLimitUsd.toFixed(2)}). New tasks are disabled until the next UTC month.`, + requestId, + ); + } + // 4. Generate identifiers and timestamps const now = new Date().toISOString(); // A task with no repo never clones, branches, or opens a PR (the agent prompt @@ -703,6 +748,7 @@ export async function createTaskCore( const taskRecord: TaskRecord = { task_id: taskId, user_id: context.userId, + ...(budgetAdmission.teamIds.length > 0 && { team_ids: budgetAdmission.teamIds }), status: initialStatus, ...(body.repo ? { repo: body.repo } : {}), ...(body.issue_number !== undefined && { issue_number: body.issue_number }), diff --git a/cdk/src/handlers/shared/gateway.ts b/cdk/src/handlers/shared/gateway.ts index aca8abf7..12aae1ed 100644 --- a/cdk/src/handlers/shared/gateway.ts +++ b/cdk/src/handlers/shared/gateway.ts @@ -40,6 +40,18 @@ export function extractUserId(event: APIGatewayProxyEvent): string | null { return null; } +/** + * Extract Cognito group membership from the authenticated JWT. Group names are + * the platform's team identifiers for monthly budgets. + */ +export function extractUserGroups(event: APIGatewayProxyEvent): string[] { + const raw = event.requestContext.authorizer?.claims?.['cognito:groups']; + if (!raw) return []; + const groups = Array.isArray(raw) ? raw : String(raw).split(/[,\s]+/); + return [...new Set(groups.filter((group): group is string => + typeof group === 'string' && group.length > 0))].sort(); +} + /** * Check whether the authenticated caller is in a Cognito group. Cognito places * group membership in the `cognito:groups` claim, which the authorizer surfaces @@ -50,10 +62,7 @@ export function extractUserId(event: APIGatewayProxyEvent): string | null { * @returns true if the caller is a member of `group`. */ export function userInGroup(event: APIGatewayProxyEvent, group: string): boolean { - const raw = event.requestContext.authorizer?.claims?.['cognito:groups']; - if (!raw) return false; - const groups = Array.isArray(raw) ? raw : String(raw).split(/[,\s]+/); - return groups.includes(group); + return extractUserGroups(event).includes(group); } /** diff --git a/cdk/src/handlers/shared/orchestration-release.ts b/cdk/src/handlers/shared/orchestration-release.ts index a318248e..a1d42e7f 100644 --- a/cdk/src/handlers/shared/orchestration-release.ts +++ b/cdk/src/handlers/shared/orchestration-release.ts @@ -194,8 +194,8 @@ export type ReleaseChildReadyResult = ReleaseChildResult & { readonly subIssueId // The status codes createTaskCore ACTUALLY returns on a non-success (verified // against create-task-core.ts, not assumed from HTTP-code lore): 400 // VALIDATION_ERROR (incl. the guardrail block), 409 DUPLICATE_TASK (idempotent -// replay), 422 REPO_NOT_ONBOARDED, 500/503 server/service errors. There is no -// 403/404/408/429 path here. +// replay), 422 REPO_NOT_ONBOARDED, 429 BUDGET_EXCEEDED, 500/503 server/service +// errors. There is no 403/404/408 path here. const HTTP_CONFLICT = 409; // idempotent replay — a task already exists for this key const HTTP_CLIENT_ERROR_MIN = 400; const HTTP_SERVER_ERROR_MIN = 500; @@ -213,6 +213,9 @@ const HTTP_SERVER_ERROR_MIN = 500; * DETERMINISTIC. Neither self-heals; the user must edit/reword the sub-issue * or onboard the repo, THEN re-run via ``@bgagent retry``. Rolling back would * loop the sweep forever. + * - 429 (monthly budget exhausted) → DETERMINISTIC for this release attempt. + * An operator must adjust/disable the budget or wait for the next UTC month, + * then re-run via ``@bgagent retry``. * - 409 (duplicate/idempotent replay) → NOT a real failure: a task already * exists for this key, so treat like a transient (roll back; a re-release * idempotent-replays to 200 and finalizes). Never terminal. @@ -224,6 +227,7 @@ function isDeterministicCreateFailure(statusCode: number): boolean { } const HTTP_UNPROCESSABLE = 422; // REPO_NOT_ONBOARDED +const HTTP_TOO_MANY_REQUESTS = 429; // BUDGET_EXCEEDED /** * A short, user-facing reason for a deterministic create failure, shown as @@ -240,6 +244,9 @@ function deterministicFailureReason(statusCode: number, body: string): string { if (statusCode === HTTP_UNPROCESSABLE) { return `Couldn't start — this repo isn't onboarded to ABCA. Onboard it, ${retry}`; } + if (statusCode === HTTP_TOO_MANY_REQUESTS) { + return `Couldn't start — a monthly budget is exhausted. Adjust the budget or wait for the next UTC month, ${retry}`; + } // 400: distinguish a guardrail/content-policy block (rewordable) from other validation. if (/content policy|guardrail/i.test(body || '')) { return `Blocked by content policy — reword this sub-issue, ${retry}`; @@ -561,13 +568,13 @@ export async function releaseChild(params: ReleaseChildParams): Promise { + test('creates 80 and 100 percent threshold alarms', () => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + new BudgetAlerts(stack, 'BudgetAlerts'); + const template = Template.fromStack(stack); + + template.resourceCountIs('AWS::CloudWatch::Alarm', 2); + template.hasResourceProperties('AWS::CloudWatch::Alarm', { + Namespace: 'ABCA/Budgets', + MetricName: 'BudgetThresholdCrossed', + Dimensions: [{ Name: 'Threshold', Value: '80' }], + Threshold: 1, + }); + template.hasResourceProperties('AWS::CloudWatch::Alarm', { + Namespace: 'ABCA/Budgets', + MetricName: 'BudgetThresholdCrossed', + Dimensions: [{ Name: 'Threshold', Value: '100' }], + Threshold: 1, + }); + }); +}); diff --git a/cdk/test/constructs/budget-table.test.ts b/cdk/test/constructs/budget-table.test.ts new file mode 100644 index 00000000..5deaf6c7 --- /dev/null +++ b/cdk/test/constructs/budget-table.test.ts @@ -0,0 +1,81 @@ +/** + * 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 { App, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import { Template } from 'aws-cdk-lib/assertions'; +import { BudgetTable } from '../../src/constructs/budget-table'; + +describe('BudgetTable', () => { + test('uses the scope/month composite key with TTL, PITR, and a config index', () => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + new BudgetTable(stack, 'BudgetTable'); + const template = Template.fromStack(stack); + + template.hasResourceProperties('AWS::DynamoDB::Table', { + KeySchema: [ + { AttributeName: 'scope_key', KeyType: 'HASH' }, + { AttributeName: 'period', KeyType: 'RANGE' }, + ], + AttributeDefinitions: [ + { AttributeName: 'scope_key', AttributeType: 'S' }, + { AttributeName: 'period', AttributeType: 'S' }, + { AttributeName: 'record_type', AttributeType: 'S' }, + ], + BillingMode: 'PAY_PER_REQUEST', + TimeToLiveSpecification: { + AttributeName: 'ttl', + Enabled: true, + }, + PointInTimeRecoverySpecification: { + PointInTimeRecoveryEnabled: true, + }, + GlobalSecondaryIndexes: [{ + IndexName: 'record_type-scope_key-index', + KeySchema: [ + { AttributeName: 'record_type', KeyType: 'HASH' }, + { AttributeName: 'scope_key', KeyType: 'RANGE' }, + ], + Projection: { ProjectionType: 'ALL' }, + }], + }); + }); + + test('supports custom lifecycle settings', () => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + new BudgetTable(stack, 'BudgetTable', { + tableName: 'budgets', + removalPolicy: RemovalPolicy.RETAIN, + pointInTimeRecovery: false, + }); + const template = Template.fromStack(stack); + + template.hasResourceProperties('AWS::DynamoDB::Table', { + TableName: 'budgets', + PointInTimeRecoverySpecification: { + PointInTimeRecoveryEnabled: false, + }, + }); + template.hasResource('AWS::DynamoDB::Table', { + DeletionPolicy: 'Retain', + UpdateReplacePolicy: 'Retain', + }); + }); +}); diff --git a/cdk/test/constructs/orchestration-reconciler.test.ts b/cdk/test/constructs/orchestration-reconciler.test.ts index 49bc2967..12d6106d 100644 --- a/cdk/test/constructs/orchestration-reconciler.test.ts +++ b/cdk/test/constructs/orchestration-reconciler.test.ts @@ -20,6 +20,7 @@ import { App, Stack } from 'aws-cdk-lib'; import { Match, Template } from 'aws-cdk-lib/assertions'; import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import { BudgetTable } from '../../src/constructs/budget-table'; import { OrchestrationReconciler } from '../../src/constructs/orchestration-reconciler'; import { OrchestrationTable } from '../../src/constructs/orchestration-table'; import { TaskEventsTable } from '../../src/constructs/task-events-table'; @@ -31,10 +32,12 @@ function synth(): Template { const taskTable = new TaskTable(stack, 'TaskTable'); const orchestrationTable = new OrchestrationTable(stack, 'OrchestrationTable'); const taskEventsTable = new TaskEventsTable(stack, 'TaskEventsTable'); + const budgetTable = new BudgetTable(stack, 'BudgetTable'); new OrchestrationReconciler(stack, 'OrchestrationReconciler', { taskTable: taskTable.table, orchestrationTable: orchestrationTable.table, taskEventsTable: taskEventsTable.table, + budgetTable: budgetTable.table, orchestratorFunctionArn: 'arn:aws:lambda:us-east-1:123456789012:function:orch', }); return Template.fromStack(stack); @@ -52,6 +55,7 @@ describe('OrchestrationReconciler', () => { Variables: Match.objectLike({ ORCHESTRATION_TABLE_NAME: Match.anyValue(), TASK_TABLE_NAME: Match.anyValue(), + BUDGET_TABLE_NAME: Match.anyValue(), }), }, }); diff --git a/cdk/test/constructs/task-api.test.ts b/cdk/test/constructs/task-api.test.ts index 12c742fe..4cb03b1c 100644 --- a/cdk/test/constructs/task-api.test.ts +++ b/cdk/test/constructs/task-api.test.ts @@ -74,6 +74,28 @@ function createStackWithWebhooks(overrides?: Partial): { stack: St return { stack, template }; } +function createStackWithBudget(): { stack: Stack; template: Template } { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + 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 budgetTable = new dynamodb.Table(stack, 'BudgetTable', { + partitionKey: { name: 'scope_key', type: dynamodb.AttributeType.STRING }, + sortKey: { name: 'period', type: dynamodb.AttributeType.STRING }, + }); + new TaskApi(stack, 'TaskApi', { + taskTable, + taskEventsTable, + budgetTable, + }); + return { stack, template: Template.fromStack(stack) }; +} + // Full surface: webhookTable AND apiKeyTable both provided (all tables in the // same app/stack — CDK forbids cross-app resource references). function createStackWithWebhooksAndApiKeys(): { stack: Stack; template: Template } { @@ -107,10 +129,12 @@ function createStackWithWebhooksAndApiKeys(): { stack: Stack; template: Template describe('TaskApi construct', () => { let baseTemplate: Template; + let budgetTemplate: Template; let webhookTemplate: Template; beforeAll(() => { baseTemplate = createStack().template; + budgetTemplate = createStackWithBudget().template; webhookTemplate = createStackWithWebhooks().template; }); @@ -155,6 +179,33 @@ describe('TaskApi construct', () => { baseTemplate.resourceCountIs('AWS::Lambda::Function', 6); }); + test('personal budget view reuses ListTasksFn with budget-table read access', () => { + budgetTemplate.resourceCountIs('AWS::Lambda::Function', 6); + const functions = budgetTemplate.findResources('AWS::Lambda::Function'); + const listTasksFn = Object.entries(functions) + .find(([id]) => id.startsWith('TaskApiListTasksFn')); + expect(listTasksFn).toBeDefined(); + expect(listTasksFn![1].Properties.Environment.Variables.BUDGET_TABLE_NAME).toEqual({ + Ref: expect.stringMatching(/^BudgetTable/), + }); + + const policies = budgetTemplate.findResources('AWS::IAM::Policy'); + const listTasksPolicy = Object.entries(policies) + .find(([id]) => id.startsWith('TaskApiListTasksFnServiceRoleDefaultPolicy')); + expect(listTasksPolicy).toBeDefined(); + const statements = listTasksPolicy![1].Properties.PolicyDocument.Statement as Array<{ + Action: string | string[]; + Resource: unknown; + }>; + const budgetRead = statements.find(statement => + JSON.stringify(statement.Resource).includes('BudgetTable')); + expect(budgetRead).toBeDefined(); + expect(budgetRead!.Action).toEqual(expect.arrayContaining([ + 'dynamodb:BatchGetItem', + 'dynamodb:GetItem', + ])); + }); + test('creates 11 Lambda functions with webhookTable', () => { webhookTemplate.resourceCountIs('AWS::Lambda::Function', 11); }); diff --git a/cdk/test/handlers/budget-rollup.test.ts b/cdk/test/handlers/budget-rollup.test.ts new file mode 100644 index 00000000..580b0bb5 --- /dev/null +++ b/cdk/test/handlers/budget-rollup.test.ts @@ -0,0 +1,298 @@ +/** + * 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 { DynamoDBRecord } from 'aws-lambda'; + +const sendMock = jest.fn(); + +async function loadRollup(options: { enabled?: boolean } = {}) { + jest.resetModules(); + sendMock.mockReset(); + if (options.enabled === false) { + delete process.env.BUDGET_TABLE_NAME; + } else { + process.env.BUDGET_TABLE_NAME = 'Budgets'; + } + jest.doMock('../../src/handlers/shared/ua', () => ({ + makeDocClient: () => ({ send: sendMock }), + makeClient: () => ({ send: jest.fn() }), + })); + return import('../../src/handlers/budget-rollup'); +} + +function record(): DynamoDBRecord { + return { + eventID: 'event-1', + eventName: 'MODIFY', + eventSource: 'aws:dynamodb', + eventVersion: '1.1', + awsRegion: 'us-east-1', + eventSourceARN: 'arn:stream', + dynamodb: { + SequenceNumber: 'seq-1', + NewImage: { + task_id: { S: 'task-1' }, + user_id: { S: 'user-1' }, + team_ids: { L: [{ S: 'Platform' }] }, + status: { S: 'COMPLETED' }, + cost_usd: { S: '8.5' }, + completed_at: { S: '2026-08-18T12:00:00Z' }, + }, + }, + }; +} + +afterEach(() => { + jest.dontMock('../../src/handlers/shared/ua'); + delete process.env.BUDGET_TABLE_NAME; + jest.restoreAllMocks(); +}); + +describe('budget rollup handler', () => { + test('is a no-op when the optional budget table is not wired', async () => { + const rollup = await loadRollup({ enabled: false }); + + await expect(rollup.rollupTaskCost(record())).resolves.toBe(false); + expect(sendMock).not.toHaveBeenCalled(); + }); + + test('parses terminal task cost and team scopes', async () => { + const rollup = await loadRollup(); + expect(rollup.parseTaskCostEvent(record())).toEqual({ + taskId: 'task-1', + userId: 'user-1', + teamIds: ['Platform'], + period: '2026-08', + costUsd: 8.5, + }); + }); + + test('rejects a task with more scopes than one transaction supports', async () => { + const rollup = await loadRollup(); + const overflow = record(); + overflow.dynamodb!.NewImage!.team_ids = { + L: Array.from({ length: 99 }, (_, index) => ({ S: `Team-${index}` })), + }; + + await expect(rollup.rollupTaskCost(overflow)) + .rejects.toThrow('has 100 budget scopes; maximum is 99'); + expect(sendMock).not.toHaveBeenCalled(); + }); + + test('writes one transaction and emits the 80 percent threshold once', async () => { + const rollup = await loadRollup(); + const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + sendMock + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ + Responses: { + Budgets: [ + { + scope_key: 'USER#user-1', + period: 'CONFIG', + monthly_limit_usd: 10, + hard_stop: true, + }, + { + scope_key: 'USER#user-1', + period: '2026-08', + spend_usd: 8.5, + }, + ], + }, + }) + .mockResolvedValueOnce({}); + + const result = await rollup.rollupTaskCost(record()); + + expect(result).toBe(true); + const transaction = sendMock.mock.calls[0][0]; + expect(transaction.input.TransactItems).toHaveLength(3); + expect(transaction.input.TransactItems[0].Put.Item.scope_key).toBe('TASK#task-1'); + for (const item of transaction.input.TransactItems.slice(1)) { + expect(item.Update.UpdateExpression).toContain('#ttl = :ttl'); + expect(item.Update.ExpressionAttributeNames).toEqual({ '#ttl': 'ttl' }); + } + expect(stdout.mock.calls.map(call => String(call[0])).join('')).toContain('"Threshold":"80"'); + }); + + test('emits both 80 and 100 percent thresholds for one large rollup', async () => { + const rollup = await loadRollup(); + const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + sendMock + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ + Responses: { + Budgets: [ + { + scope_key: 'USER#user-1', + period: 'CONFIG', + monthly_limit_usd: 10, + hard_stop: true, + }, + { + scope_key: 'USER#user-1', + period: '2026-08', + spend_usd: 12, + }, + ], + }, + }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}); + + await expect(rollup.rollupTaskCost(record())).resolves.toBe(true); + + const output = stdout.mock.calls.map(call => String(call[0])).join(''); + expect(output.match(/"Threshold":"80"/g)).toHaveLength(1); + expect(output.match(/"Threshold":"100"/g)).toHaveLength(1); + }); + + test('does not re-emit a threshold that was already claimed', async () => { + const rollup = await loadRollup(); + const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + sendMock + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ + Responses: { + Budgets: [ + { + scope_key: 'USER#user-1', + period: 'CONFIG', + monthly_limit_usd: 10, + hard_stop: true, + }, + { + scope_key: 'USER#user-1', + period: '2026-08', + spend_usd: 8.5, + alerted_80_at: '2026-08-18T12:00:00.000Z', + }, + ], + }, + }); + + await expect(rollup.rollupTaskCost(record())).resolves.toBe(true); + + expect(stdout).not.toHaveBeenCalled(); + expect(sendMock).toHaveBeenCalledTimes(2); + }); + + test('treats an existing task marker as an idempotent replay', async () => { + const rollup = await loadRollup(); + const canceled = Object.assign(new Error('cancelled'), { + name: 'TransactionCanceledException', + }); + sendMock + .mockRejectedValueOnce(canceled) + .mockResolvedValueOnce({ Item: { scope_key: 'TASK#task-1' } }) + .mockResolvedValueOnce({ Responses: { Budgets: [] } }); + + const result = await rollup.rollupTaskCost(record()); + + expect(result).toBe(false); + expect(sendMock).toHaveBeenCalledTimes(3); + }); + + test('retries threshold claims after the spend transaction already committed', async () => { + const rollup = await loadRollup(); + const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + const canceled = Object.assign(new Error('cancelled'), { + name: 'TransactionCanceledException', + }); + sendMock + // First delivery: spend commits, then the read needed for alerts fails. + .mockResolvedValueOnce({}) + .mockRejectedValueOnce(new Error('read throttled')) + // Retry: marker proves the spend is already applied. + .mockRejectedValueOnce(canceled) + .mockResolvedValueOnce({ Item: { scope_key: 'TASK#task-1' } }) + .mockResolvedValueOnce({ + Responses: { + Budgets: [ + { + scope_key: 'USER#user-1', + period: 'CONFIG', + monthly_limit_usd: 10, + hard_stop: true, + }, + { + scope_key: 'USER#user-1', + period: '2026-08', + spend_usd: 8.5, + }, + ], + }, + }) + .mockResolvedValueOnce({}); + + await expect(rollup.rollupTaskCost(record())).rejects.toThrow('read throttled'); + await expect(rollup.rollupTaskCost(record())).resolves.toBe(false); + + expect(stdout.mock.calls.map(call => String(call[0])).join('')).toContain('"Threshold":"80"'); + }); + + test('re-emits a threshold when its first claim write fails', async () => { + const rollup = await loadRollup(); + const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + const canceled = Object.assign(new Error('cancelled'), { + name: 'TransactionCanceledException', + }); + const budgetState = { + Responses: { + Budgets: [ + { + scope_key: 'USER#user-1', + period: 'CONFIG', + monthly_limit_usd: 10, + hard_stop: true, + }, + { + scope_key: 'USER#user-1', + period: '2026-08', + spend_usd: 8.5, + }, + ], + }, + }; + sendMock + // First delivery commits spend and emits, but cannot persist the claim. + .mockResolvedValueOnce({}) + .mockResolvedValueOnce(budgetState) + .mockRejectedValueOnce(new Error('claim throttled')) + // Retry proves spend was already applied, then re-emits and claims. + .mockRejectedValueOnce(canceled) + .mockResolvedValueOnce({ Item: { scope_key: 'TASK#task-1' } }) + .mockResolvedValueOnce(budgetState) + .mockResolvedValueOnce({}); + + await expect(rollup.rollupTaskCost(record())).rejects.toThrow('claim throttled'); + await expect(rollup.rollupTaskCost(record())).resolves.toBe(false); + + const output = stdout.mock.calls.map(call => String(call[0])).join(''); + expect(output.match(/"Threshold":"80"/g)).toHaveLength(2); + }); + + test('throws so the shared stream consumer retries the record', async () => { + const rollup = await loadRollup(); + sendMock.mockRejectedValue(new Error('ddb unavailable')); + + await expect(rollup.rollupTaskCost(record())).rejects.toThrow('ddb unavailable'); + }); +}); diff --git a/cdk/test/handlers/list-tasks.test.ts b/cdk/test/handlers/list-tasks.test.ts index 014de4b2..159ec02d 100644 --- a/cdk/test/handlers/list-tasks.test.ts +++ b/cdk/test/handlers/list-tasks.test.ts @@ -17,7 +17,7 @@ * SOFTWARE. */ -import { QueryCommand } from '@aws-sdk/lib-dynamodb'; +import { BatchGetCommand, QueryCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEvent } from 'aws-lambda'; // --- Mocks --- @@ -25,16 +25,19 @@ const mockSend = jest.fn(); jest.mock('@aws-sdk/client-dynamodb', () => ({ DynamoDBClient: jest.fn(() => ({})) })); jest.mock('@aws-sdk/lib-dynamodb', () => ({ DynamoDBDocumentClient: { from: jest.fn(() => ({ send: mockSend })) }, + BatchGetCommand: jest.fn((input: unknown) => ({ _type: 'BatchGet', input })), QueryCommand: jest.fn((input: unknown) => ({ _type: 'Query', input })), })); jest.mock('ulid', () => ({ ulid: jest.fn(() => 'REQ-ULID') })); process.env.TASK_TABLE_NAME = 'Tasks'; +process.env.BUDGET_TABLE_NAME = 'Budgets'; import { handler } from '../../src/handlers/list-tasks'; const MockQueryCommand = QueryCommand as unknown as jest.Mock; +const MockBatchGetCommand = BatchGetCommand as unknown as jest.Mock; const TASK_ITEMS = [ { @@ -129,6 +132,59 @@ describe('list-tasks handler', () => { expect(body.pagination.next_token).toBeNull(); }); + test('returns only the authenticated user personal budget for view=budget', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-08-21T12:00:00Z')); + mockSend.mockResolvedValueOnce({ + Responses: { + Budgets: [ + { + scope_key: 'USER#user-123', + period: 'CONFIG', + monthly_limit_usd: 50, + hard_stop: true, + }, + { + scope_key: 'USER#user-123', + period: '2026-08', + spend_usd: 25, + }, + ], + }, + }); + + try { + const result = await handler(makeEvent({ + queryStringParameters: { view: 'budget' }, + })); + expect(result.statusCode).toBe(200); + expect(JSON.parse(result.body).data).toEqual({ + period: '2026-08', + resets_at: '2026-09-01T00:00:00.000Z', + configured: true, + spend_usd: 25, + monthly_limit_usd: 50, + remaining_usd: 25, + utilization_percent: 50, + hard_stop: true, + hard_stop_active: false, + }); + expect(MockBatchGetCommand).toHaveBeenCalledWith(expect.objectContaining({ + RequestItems: { + Budgets: expect.objectContaining({ + Keys: [ + { scope_key: 'USER#user-123', period: 'CONFIG' }, + { scope_key: 'USER#user-123', period: '2026-08' }, + ], + ConsistentRead: true, + }), + }, + })); + expect(MockQueryCommand).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } + }); + test('returns pagination token when more results exist', async () => { mockSend.mockResolvedValueOnce({ Items: [TASK_ITEMS[0]], diff --git a/cdk/test/handlers/orchestration-reconciler.test.ts b/cdk/test/handlers/orchestration-reconciler.test.ts index c0586c71..0e395c0d 100644 --- a/cdk/test/handlers/orchestration-reconciler.test.ts +++ b/cdk/test/handlers/orchestration-reconciler.test.ts @@ -46,6 +46,11 @@ jest.mock('../../src/handlers/shared/create-task-core', () => ({ createTaskCore: (...args: unknown[]) => createTaskCoreMock(...args), })); +const rollupTaskCostMock = jest.fn(); +jest.mock('../../src/handlers/budget-rollup', () => ({ + rollupTaskCost: (...args: unknown[]) => rollupTaskCostMock(...args), +})); + const postIssueCommentMock = jest.fn(); const upsertStatusCommentMock = jest.fn(); const swapIssueReactionMock = jest.fn(); @@ -100,6 +105,10 @@ process.env.ARTIFACTS_BUCKET_NAME = 'ArtifactsBucket'; import { TERMINAL_STATUSES } from '../../src/constructs/task-status'; import { handler, parseTerminalTaskRecord } from '../../src/handlers/orchestration-reconciler'; +beforeEach(() => { + rollupTaskCostMock.mockReset().mockResolvedValue(false); +}); + /** Build a TaskTable stream MODIFY record. */ function taskRecord(fields: { task_id?: string; @@ -317,6 +326,56 @@ describe('orchestration-reconciler handler', () => { expect(ctx.idempotencyKey).toBe('orch_1_B'); }); + test('rolls up a terminal task even when it does not belong to an orchestration', async () => { + rollupTaskCostMock.mockResolvedValueOnce(true); + const record = taskRecord({ task_id: 'standalone', status: 'COMPLETED' }); + + const result = await handler({ Records: [record] } as never); + + expect(rollupTaskCostMock).toHaveBeenCalledWith(record); + expect(result.batchItemFailures).toEqual([]); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + }); + + test('releases orchestration dependents before reporting a budget-rollup retry', async () => { + mockOrchestration({ + subIssueId: 'A', + children: [ + { sub_issue_id: 'A', child_status: 'released' }, + { sub_issue_id: 'B', depends_on: ['A'], child_status: 'blocked' }, + ], + }); + rollupTaskCostMock.mockRejectedValueOnce(new Error('budget table unavailable')); + const record = taskRecord({ + task_id: 'TA', + status: 'COMPLETED', + orchestration_id: 'orch_1', + sequenceNumber: 'seq-budget', + }); + + const result = await handler({ Records: [record] } as never); + + expect(createTaskCoreMock).toHaveBeenCalledTimes(1); + expect(createTaskCoreMock.mock.calls[0][1].idempotencyKey).toBe('orch_1_B'); + expect(result.batchItemFailures).toEqual([{ itemIdentifier: 'seq-budget' }]); + }); + + test('rolls up spend even when orchestration reconciliation needs a retry', async () => { + ddbSend.mockRejectedValueOnce(new Error('orchestration table unavailable')); + rollupTaskCostMock.mockResolvedValueOnce(true); + const record = taskRecord({ + task_id: 'TA', + status: 'COMPLETED', + orchestration_id: 'orch_1', + sequenceNumber: 'seq-orchestration', + }); + + const result = await handler({ Records: [record] } as never); + + expect(rollupTaskCostMock).toHaveBeenCalledWith(record); + expect(result.batchItemFailures).toEqual([{ itemIdentifier: 'seq-orchestration' }]); + }); + test('A fails → no release, B skipped (createTaskCore not called)', async () => { mockOrchestration({ subIssueId: 'A', diff --git a/cdk/test/handlers/shared/budgets.test.ts b/cdk/test/handlers/shared/budgets.test.ts new file mode 100644 index 00000000..f376ddbd --- /dev/null +++ b/cdk/test/handlers/shared/budgets.test.ts @@ -0,0 +1,208 @@ +/** + * 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 ddbSend = jest.fn(); +const cognitoSend = jest.fn(); + +async function loadBudgets(options: { enabled?: boolean } = {}) { + jest.resetModules(); + ddbSend.mockReset(); + cognitoSend.mockReset(); + if (options.enabled === false) { + delete process.env.BUDGET_TABLE_NAME; + delete process.env.USER_POOL_ID; + } else { + process.env.BUDGET_TABLE_NAME = 'Budgets'; + process.env.USER_POOL_ID = 'us-east-1_pool'; + } + jest.doMock('../../../src/handlers/shared/ua', () => ({ + makeDocClient: () => ({ send: ddbSend }), + makeClient: () => ({ send: cognitoSend }), + })); + return import('../../../src/handlers/shared/budgets'); +} + +afterEach(() => { + jest.dontMock('../../../src/handlers/shared/ua'); + delete process.env.BUDGET_TABLE_NAME; + delete process.env.USER_POOL_ID; +}); + +describe('budget admission', () => { + test('resolves Cognito groups and blocks an exhausted hard-stop team', async () => { + const budgets = await loadBudgets(); + cognitoSend.mockResolvedValue({ + Groups: [{ GroupName: 'Developers' }, { GroupName: 'Platform' }], + }); + ddbSend.mockResolvedValue({ + Responses: { + Budgets: [ + { + scope_key: 'TEAM#Platform', + period: 'CONFIG', + monthly_limit_usd: 100, + hard_stop: true, + }, + { + scope_key: 'TEAM#Platform', + period: '2026-08', + spend_usd: 101.25, + }, + ], + }, + }); + + const result = await budgets.checkBudgetAdmission( + 'user-1', + undefined, + new Date('2026-08-18T12:00:00Z'), + ); + + expect(result.teamIds).toEqual(['Developers', 'Platform']); + expect(result.blocked).toEqual({ + scopeType: 'team', + scopeId: 'Platform', + spendUsd: 101.25, + monthlyLimitUsd: 100, + }); + expect(ddbSend.mock.calls[0][0].input.RequestItems.Budgets.ConsistentRead).toBe(true); + }); + + test('allows a soft budget above 100 percent', async () => { + const budgets = await loadBudgets(); + ddbSend.mockResolvedValue({ + Responses: { + Budgets: [ + { + scope_key: 'USER#user-1', + period: 'CONFIG', + monthly_limit_usd: 10, + hard_stop: false, + }, + { + scope_key: 'USER#user-1', + period: '2026-08', + spend_usd: 12, + }, + ], + }, + }); + + const result = await budgets.checkBudgetAdmission( + 'user-1', + [], + new Date('2026-08-18T12:00:00Z'), + ); + + expect(cognitoSend).not.toHaveBeenCalled(); + expect(result.blocked).toBeNull(); + }); + + test('preserves caller-supplied groups when the feature is not wired', async () => { + const budgets = await loadBudgets({ enabled: false }); + const result = await budgets.checkBudgetAdmission('user-1', ['TeamB', 'TeamA', 'TeamA']); + expect(result.teamIds).toEqual(['TeamA', 'TeamB']); + expect(result.blocked).toBeNull(); + expect(ddbSend).not.toHaveBeenCalled(); + }); + + test('rejects more team scopes than one DynamoDB rollup transaction supports', async () => { + const budgets = await loadBudgets(); + const teamIds = Array.from({ length: 99 }, (_, index) => `Team-${index}`); + + await expect(budgets.checkBudgetAdmission('user-1', teamIds)) + .rejects.toThrow('belongs to 99 teams; budget rollup supports at most 98'); + expect(ddbSend).not.toHaveBeenCalled(); + expect(cognitoSend).not.toHaveBeenCalled(); + }); + + test('uses UTC calendar months', async () => { + const budgets = await loadBudgets({ enabled: false }); + expect(budgets.budgetPeriod(new Date('2026-08-31T23:59:59Z'))).toBe('2026-08'); + expect(budgets.budgetPeriod(new Date('2026-09-01T00:00:00Z'))).toBe('2026-09'); + expect(budgets.budgetResetAt(new Date('2026-12-31T23:59:59Z'))) + .toBe('2027-01-01T00:00:00.000Z'); + }); + + test('returns the authenticated user personal budget status', async () => { + const budgets = await loadBudgets(); + ddbSend.mockResolvedValue({ + Responses: { + Budgets: [ + { + scope_key: 'USER#user-1', + period: 'CONFIG', + monthly_limit_usd: 100, + hard_stop: true, + }, + { + scope_key: 'USER#user-1', + period: '2026-08', + spend_usd: 82.5, + }, + ], + }, + }); + + await expect(budgets.loadPersonalBudgetStatus( + 'user-1', + new Date('2026-08-21T12:00:00Z'), + )).resolves.toEqual({ + period: '2026-08', + resets_at: '2026-09-01T00:00:00.000Z', + configured: true, + spend_usd: 82.5, + monthly_limit_usd: 100, + remaining_usd: 17.5, + utilization_percent: 82.5, + hard_stop: true, + hard_stop_active: false, + }); + }); + + test('shows spend without inventing a personal limit when none is configured', async () => { + const budgets = await loadBudgets(); + ddbSend.mockResolvedValue({ + Responses: { + Budgets: [ + { + scope_key: 'USER#user-1', + period: '2026-08', + spend_usd: 12.25, + }, + ], + }, + }); + + await expect(budgets.loadPersonalBudgetStatus( + 'user-1', + new Date('2026-08-21T12:00:00Z'), + )).resolves.toEqual({ + period: '2026-08', + resets_at: '2026-09-01T00:00:00.000Z', + configured: false, + spend_usd: 12.25, + monthly_limit_usd: null, + remaining_usd: null, + utilization_percent: null, + hard_stop: false, + hard_stop_active: false, + }); + }); +}); diff --git a/cdk/test/handlers/shared/create-task-core.test.ts b/cdk/test/handlers/shared/create-task-core.test.ts index 30947ab2..b927543f 100644 --- a/cdk/test/handlers/shared/create-task-core.test.ts +++ b/cdk/test/handlers/shared/create-task-core.test.ts @@ -52,6 +52,11 @@ jest.mock('../../../src/handlers/shared/repo-config', () => ({ lookupRepo: mockLookupRepo, })); +const mockCheckBudgetAdmission = jest.fn(); +jest.mock('../../../src/handlers/shared/budgets', () => ({ + checkBudgetAdmission: (...args: unknown[]) => mockCheckBudgetAdmission(...args), +})); + // Partial-mock the workflows module: keep every real resolver/descriptor, but // make ``disallowedWorkflowModel`` controllable so the rule-13 admission path // can be exercised without shipping a workflow that pins a bad model. Defaults @@ -96,6 +101,13 @@ beforeEach(() => { // Default: the resolved workflow's model is permitted (matches the real // implementation for every shipped workflow). Rule-13 tests override this. mockDisallowedWorkflowModel.mockReturnValue(null); + mockCheckBudgetAdmission.mockImplementation( + (_userId: string, teamIds?: readonly string[]) => Promise.resolve({ + teamIds: teamIds ?? [], + period: '2026-08', + blocked: null, + }), + ); }); describe('createTaskCore', () => { @@ -114,6 +126,75 @@ describe('createTaskCore', () => { expect(mockLambdaSend).toHaveBeenCalledTimes(1); }); + test('persists the Cognito teams captured by budget admission', async () => { + mockCheckBudgetAdmission.mockResolvedValue({ + teamIds: ['Developers', 'Platform'], + period: '2026-08', + blocked: null, + }); + + const result = await createTaskCore( + { repo: 'org/repo', task_description: 'Fix the bug' }, + makeContext({ teamIds: ['Platform', 'Developers'] }), + 'req-budget-teams', + ); + + expect(result.statusCode).toBe(201); + expect(mockCheckBudgetAdmission).toHaveBeenCalledWith( + 'user-123', + ['Platform', 'Developers'], + ); + const taskPut = mockSend.mock.calls.find( + ([command]) => command._type === 'Put' && command.input.TableName === 'Tasks', + ); + expect(taskPut![0].input.Item.team_ids).toEqual(['Developers', 'Platform']); + }); + + test('returns 429 without creating a task when a hard-stop budget is exhausted', async () => { + mockCheckBudgetAdmission.mockResolvedValue({ + teamIds: ['Platform'], + period: '2026-08', + blocked: { + scopeType: 'team', + scopeId: 'Platform', + spendUsd: 101.25, + monthlyLimitUsd: 100, + }, + }); + + const result = await createTaskCore( + { repo: 'org/repo', task_description: 'Fix the bug' }, + makeContext(), + 'req-budget-blocked', + ); + + expect(result.statusCode).toBe(429); + expect(JSON.parse(result.body).error).toMatchObject({ + code: 'BUDGET_EXCEEDED', + message: expect.stringContaining("team 'Platform'"), + }); + expect(mockSend).not.toHaveBeenCalled(); + expect(mockLambdaSend).not.toHaveBeenCalled(); + }); + + test('fails closed when monthly budget admission is unavailable', async () => { + mockCheckBudgetAdmission.mockRejectedValue(new Error('budget table unavailable')); + + const result = await createTaskCore( + { repo: 'org/repo', task_description: 'Fix the bug' }, + makeContext(), + 'req-budget-unavailable', + ); + + expect(result.statusCode).toBe(503); + expect(JSON.parse(result.body).error).toMatchObject({ + code: 'SERVICE_UNAVAILABLE', + message: expect.stringContaining('Budget admission'), + }); + expect(mockSend).not.toHaveBeenCalled(); + expect(mockLambdaSend).not.toHaveBeenCalled(); + }); + test('hoists tenant-scoped Jira issue identity for the sparse lookup index', async () => { const result = await createTaskCore( { repo: 'org/repo', task_description: 'Fix the Jira issue' }, @@ -350,6 +431,7 @@ describe('createTaskCore', () => { expect(body.data.task_description).toBe('Original work'); expect(mockSend).toHaveBeenCalledTimes(2); expect(mockLambdaSend).not.toHaveBeenCalled(); + expect(mockCheckBudgetAdmission).not.toHaveBeenCalled(); }); test('returns 200 for a repo-less idempotency replay despite empty branch_name', async () => { diff --git a/cdk/test/handlers/shared/gateway.test.ts b/cdk/test/handlers/shared/gateway.test.ts index 17f4bf1b..040877af 100644 --- a/cdk/test/handlers/shared/gateway.test.ts +++ b/cdk/test/handlers/shared/gateway.test.ts @@ -21,6 +21,7 @@ import type { APIGatewayProxyEvent } from 'aws-lambda'; import { buildChannelMetadata, buildWebhookChannelMetadata, + extractUserGroups, extractUserId, extractWebhookContext, generateBranchName, @@ -110,6 +111,28 @@ describe('extractUserId', () => { }); }); +describe('extractUserGroups', () => { + test('normalizes, deduplicates, and sorts Cognito group claims', () => { + const event = makeEvent(); + event.requestContext.authorizer = { + claims: { 'cognito:groups': 'Platform,Developers Platform' }, + }; + expect(extractUserGroups(event)).toEqual(['Developers', 'Platform']); + }); + + test('preserves exact group names when Cognito provides the native array claim', () => { + const event = makeEvent(); + event.requestContext.authorizer = { + claims: { 'cognito:groups': ['Platform Team', 'FinOps,Core', 'Platform Team'] }, + }; + expect(extractUserGroups(event)).toEqual(['FinOps,Core', 'Platform Team']); + }); + + test('returns an empty list when the claim is absent', () => { + expect(extractUserGroups(makeEvent())).toEqual([]); + }); +}); + describe('generateBranchName', () => { test('generates correct pattern with description', () => { const result = generateBranchName('01ABC', 'Fix authentication bug'); diff --git a/cdk/test/handlers/shared/orchestration-release.test.ts b/cdk/test/handlers/shared/orchestration-release.test.ts index 97302886..91f2aeaf 100644 --- a/cdk/test/handlers/shared/orchestration-release.test.ts +++ b/cdk/test/handlers/shared/orchestration-release.test.ts @@ -671,8 +671,9 @@ describe('releaseChild — idempotency + failure', () => { expect(fail.input.ExpressionAttributeValues![':reason']).toContain('content policy'); }); - // Against the codes createTaskCore ACTUALLY returns: 400 (validation/guardrail) - // + 422 (repo-not-onboarded) are terminal; 409 (dup) + 5xx are transient. + // Against the codes createTaskCore ACTUALLY returns: 400 (validation/guardrail), + // 422 (repo-not-onboarded), and 429 (budget exhausted) are terminal; + // 409 (dup) + 5xx are transient. test('422 REPO_NOT_ONBOARDED is TERMINAL with an "onboard it" reason (sweep can\'t bound it)', async () => { const ddb = { send: jest.fn().mockResolvedValue({}) }; const createTaskCore = jest.fn().mockResolvedValue({ @@ -693,6 +694,30 @@ describe('releaseChild — idempotency + failure', () => { expect(fail.input.ExpressionAttributeValues![':failed']).toBe('failed'); }); + test('429 BUDGET_EXCEEDED is TERMINAL with an actionable budget reason', async () => { + const ddb = { send: jest.fn().mockResolvedValue({}) }; + const createTaskCore = jest.fn().mockResolvedValue({ + statusCode: 429, + body: '{"error":{"code":"BUDGET_EXCEEDED","message":"monthly budget exhausted"}}', + }); + const result = await releaseChild({ + ddb: ddb as never, + tableName: 'OrchestrationTable', + row: makeRow(), + platformUserId: 'user-1', + createTaskCore: createTaskCore as never, + now: NOW, + }); + expect(result.kind).toBe('create_failed_terminal'); + if (result.kind === 'create_failed_terminal') { + expect(result.failureReason).toMatch(/monthly budget/i); + expect(result.failureReason).toMatch(/next UTC month/i); + } + const fail = ddb.send.mock.calls[1][0] as UpdateCommand; + expect(fail.input.ExpressionAttributeValues![':failed']).toBe('failed'); + expect(fail.input.ExpressionAttributeValues![':reason']).toContain('monthly budget'); + }); + test('5xx (server) + 409 (duplicate replay) are TRANSIENT — roll back to ready', async () => { for (const statusCode of [503, 500, 409]) { const ddb = { send: jest.fn().mockResolvedValue({}) }; diff --git a/cdk/test/stacks/agent.test.ts b/cdk/test/stacks/agent.test.ts index f7acc8a3..503622a6 100644 --- a/cdk/test/stacks/agent.test.ts +++ b/cdk/test/stacks/agent.test.ts @@ -40,8 +40,8 @@ describe('AgentStack', () => { expect(template).toBeDefined(); }); - test('creates exactly 21 DynamoDB tables', () => { - // task, task-events, repo, user-concurrency, webhook, task-nudges, + test('creates exactly 22 DynamoDB tables', () => { + // task, task-events, repo, user-concurrency, budget, webhook, task-nudges, // task-approvals (Cedar HITL V2), // api-key (platform API keys for headless webhook management), // slack-installation, slack-user-mapping, @@ -52,8 +52,8 @@ describe('AgentStack', () => { // jira-project-mapping, jira-user-mapping, jira-workspace-registry, // jira-webhook-dedup (added for the Jira Cloud integration on main), // orchestration (parent/sub-issue DAG state). - // = 16 shared/base + 4 Jira + 1 orchestration = 21. - template.resourceCountIs('AWS::DynamoDB::Table', 21); + // = 17 shared/base + 4 Jira + 1 orchestration = 22. + template.resourceCountIs('AWS::DynamoDB::Table', 22); }); test('creates TaskApprovalsTable with user_id-status-index GSI', () => { @@ -77,6 +77,12 @@ describe('AgentStack', () => { }); }); + test('outputs BudgetTableName', () => { + template.hasOutput('BudgetTableName', { + Description: 'Name of the monthly user/team budget configuration and spend table', + }); + }); + test('outputs ComputeSubstrate=agentcore on the default (no-gate) deploy', () => { // The CLI reads this to refuse onboarding a repo as compute_type=ecs on a // stack that never provisioned the ECS substrate. @@ -721,8 +727,8 @@ describe('AgentStack', () => { }); test('wires all three DLQ-depth alarms to the alerts topic (#629)', () => { - // FanOut, ApprovalMetricsPublisher, and the screenshot processor - // DLQ alarms must each carry an AlarmActions entry — otherwise a + // FanOut, ApprovalMetricsPublisher, and screenshot processor DLQ alarms + // must each carry an AlarmActions entry — otherwise a // poison-pill pile-up stays silent (the whole point of #629). const alarms = template.findResources('AWS::CloudWatch::Alarm'); const dlqAlarmsWithActions = Object.values(alarms).filter((r: any) => { @@ -742,6 +748,17 @@ describe('AgentStack', () => { } }); + test('wires the 80/100 budget alarms to the alerts topic (#471)', () => { + const alarms = template.findResources('AWS::CloudWatch::Alarm'); + const budgetAlarms = Object.values(alarms).filter((r: any) => + r.Properties?.Namespace === 'ABCA/Budgets' + && r.Properties?.MetricName === 'BudgetThresholdCrossed'); + expect(budgetAlarms).toHaveLength(2); + for (const alarm of budgetAlarms) { + expect(JSON.stringify((alarm as any).Properties.AlarmActions)).toContain('OperationalAlerts'); + } + }); + test('does NOT subscribe an email when no alertEmail context is set (#629)', () => { // The default deploy ships the topic with no confirmed target; // operators subscribe Slack / PagerDuty / email themselves. diff --git a/cli/README.md b/cli/README.md index 39121512..a4e49432 100644 --- a/cli/README.md +++ b/cli/README.md @@ -107,6 +107,16 @@ bgagent list \ --output Output format (default: text) ``` +### `bgagent budget status --me` + +Show the logged-in user's personal monthly estimated spend and administrator-configured limit: + +``` +bgagent budget status --me [--output text|json] +``` + +This path uses Cognito authentication and requires no operator AWS credentials. It is read-only, does not expose team budgets, and reports personal spend even when no personal limit is configured. + ### `bgagent status ` Get detailed status for a specific task. @@ -177,6 +187,18 @@ Shared flags: | `--region ` | AWS region (defaults to `bgagent configure` region or `AWS_REGION`) | | `--stack-name ` | CloudFormation stack name (default: `backgroundagent-dev`) | +### `bgagent budget status|set` + +Inspect or configure recurring monthly user/team USD limits with operator AWS credentials: + +``` +bgagent budget status [--user | --team ] [--output text|json] +bgagent budget set (--user | --team ) \ + --monthly-usd [--hard-stop] +``` + +`set` replaces the scope's recurring limit. Omitting `--hard-stop` keeps admission open after 100% while retaining 80%/100% alerts. Team scopes are existing Cognito groups; the command does not create groups or manage membership. + ### `bgagent platform outputs` Print CloudFormation stack outputs (`ApiUrl`, `UserPoolId`, `AppClientId`, `GitHubTokenSecretArn`, etc.). diff --git a/cli/src/api-client.ts b/cli/src/api-client.ts index 6bfde513..67840812 100644 --- a/cli/src/api-client.ts +++ b/cli/src/api-client.ts @@ -49,6 +49,7 @@ import { RegistryShowResponse, SlackLinkResponse, PaginatedResponse, + PersonalBudgetStatus, ReplayBundle, SuccessResponse, TaskDetail, @@ -225,6 +226,15 @@ export class ApiClient { return this.request>('GET', path); } + /** GET /tasks?view=budget — read the authenticated caller's personal budget. */ + async getPersonalBudget(): Promise { + const res = await this.request>( + 'GET', + '/tasks?view=budget', + ); + return res.data; + } + /** GET /tasks/{task_id} — get task detail. */ async getTask(taskId: string, opts?: { signal?: AbortSignal }): Promise { const res = await this.request>( diff --git a/cli/src/bin/bgagent.ts b/cli/src/bin/bgagent.ts index 207ce36d..20ec778c 100644 --- a/cli/src/bin/bgagent.ts +++ b/cli/src/bin/bgagent.ts @@ -23,6 +23,7 @@ import { Command } from 'commander'; import { makeAdminCommand } from '../commands/admin'; import { makeApiKeyCommand } from '../commands/api-key'; import { makeApproveCommand } from '../commands/approve'; +import { makeBudgetCommand } from '../commands/budget'; import { makeCancelCommand } from '../commands/cancel'; import { makeConfigureCommand } from '../commands/configure'; import { makeDenyCommand } from '../commands/deny'; @@ -77,6 +78,7 @@ program.addCommand(makeNudgeCommand()); program.addCommand(makeApproveCommand()); program.addCommand(makeDenyCommand()); program.addCommand(makePendingCommand()); +program.addCommand(makeBudgetCommand()); program.addCommand(makePoliciesCommand()); program.addCommand(makeEventsCommand()); program.addCommand(makeSlackCommand()); diff --git a/cli/src/budget-store.ts b/cli/src/budget-store.ts new file mode 100644 index 00000000..328ade70 --- /dev/null +++ b/cli/src/budget-store.ts @@ -0,0 +1,244 @@ +/** + * 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 { + BatchGetCommand, + GetCommand, + QueryCommand, + TransactWriteCommand, +} from '@aws-sdk/lib-dynamodb'; +import { makeDocClient } from './ua'; + +const CONFIG_PERIOD = 'CONFIG'; +const CONFIG_RECORD_TYPE = 'CONFIG'; +const CONFIG_INDEX_NAME = 'record_type-scope_key-index'; +const USER_PREFIX = 'USER#'; +const TEAM_PREFIX = 'TEAM#'; +const BATCH_GET_LIMIT = 100; +const ROLLUP_RETENTION_DAYS = 400; +const SECONDS_PER_DAY = 24 * 60 * 60; + +export type BudgetScopeType = 'user' | 'team'; + +export interface BudgetScope { + readonly type: BudgetScopeType; + readonly id: string; +} + +export interface BudgetStatus { + readonly scope_type: BudgetScopeType; + readonly scope_id: string; + readonly period: string; + readonly monthly_limit_usd: number; + readonly spend_usd: number; + readonly remaining_usd: number; + readonly utilization_percent: number; + readonly hard_stop: boolean; + readonly hard_stop_active: boolean; + readonly updated_at: string | null; +} + +export function currentBudgetPeriod(date: Date = new Date()): string { + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + return `${year}-${month}`; +} + +export function budgetScopeKey(scope: BudgetScope): string { + return `${scope.type === 'user' ? USER_PREFIX : TEAM_PREFIX}${scope.id}`; +} + +function numeric(value: unknown): number { + if (typeof value === 'number') return Number.isFinite(value) ? value : 0; + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } + return 0; +} + +function ttlEpoch(now: Date): number { + return Math.floor(now.getTime() / 1000) + (ROLLUP_RETENTION_DAYS * SECONDS_PER_DAY); +} + +function toStatus( + config: Record, + spend: Record | undefined, + period: string, +): BudgetStatus { + const monthlyLimitUsd = numeric(config.monthly_limit_usd); + if (monthlyLimitUsd <= 0) { + throw new Error(`Budget config ${String(config.scope_key)} has invalid monthly_limit_usd.`); + } + const spendUsd = Math.max(0, numeric(spend?.spend_usd)); + const utilizationPercent = (spendUsd / monthlyLimitUsd) * 100; + const hardStop = config.hard_stop === true; + return { + scope_type: config.scope_type === 'team' ? 'team' : 'user', + scope_id: String(config.scope_id), + period, + monthly_limit_usd: monthlyLimitUsd, + spend_usd: spendUsd, + remaining_usd: Math.max(0, monthlyLimitUsd - spendUsd), + utilization_percent: utilizationPercent, + hard_stop: hardStop, + hard_stop_active: hardStop && utilizationPercent >= 100, + updated_at: typeof config.updated_at === 'string' ? config.updated_at : null, + }; +} + +export async function setMonthlyBudget( + region: string, + tableName: string, + scope: BudgetScope, + monthlyLimitUsd: number, + hardStop: boolean, + now: Date = new Date(), +): Promise { + const ddb = makeDocClient({ region }); + const scopeKey = budgetScopeKey(scope); + const period = currentBudgetPeriod(now); + const updatedAt = now.toISOString(); + await ddb.send(new TransactWriteCommand({ + TransactItems: [ + { + Put: { + TableName: tableName, + Item: { + scope_key: scopeKey, + period: CONFIG_PERIOD, + record_type: CONFIG_RECORD_TYPE, + scope_type: scope.type, + scope_id: scope.id, + monthly_limit_usd: monthlyLimitUsd, + hard_stop: hardStop, + updated_at: updatedAt, + }, + }, + }, + { + Update: { + TableName: tableName, + Key: { scope_key: scopeKey, period }, + UpdateExpression: + 'SET scope_type = :scopeType, scope_id = :scopeId, updated_at = :updatedAt, #ttl = :ttl ' + + 'REMOVE alerted_80_at, alerted_80_spend_usd, alerted_80_limit_usd, ' + + 'alerted_100_at, alerted_100_spend_usd, alerted_100_limit_usd', + ExpressionAttributeNames: { + '#ttl': 'ttl', + }, + ExpressionAttributeValues: { + ':scopeType': scope.type, + ':scopeId': scope.id, + ':updatedAt': updatedAt, + ':ttl': ttlEpoch(now), + }, + }, + }, + ], + })); +} + +async function loadConfigs( + region: string, + tableName: string, + scope?: BudgetScope, +): Promise[]> { + const ddb = makeDocClient({ region }); + if (scope) { + const result = await ddb.send(new GetCommand({ + TableName: tableName, + Key: { + scope_key: budgetScopeKey(scope), + period: CONFIG_PERIOD, + }, + ConsistentRead: true, + })); + return result.Item ? [result.Item] : []; + } + + const rows: Record[] = []; + let exclusiveStartKey: Record | undefined; + do { + const result = await ddb.send(new QueryCommand({ + TableName: tableName, + IndexName: CONFIG_INDEX_NAME, + KeyConditionExpression: 'record_type = :config', + ExpressionAttributeValues: { ':config': CONFIG_RECORD_TYPE }, + ExclusiveStartKey: exclusiveStartKey, + })); + rows.push(...(result.Items ?? [])); + exclusiveStartKey = result.LastEvaluatedKey; + } while (exclusiveStartKey); + return rows; +} + +async function batchGetSpend( + region: string, + tableName: string, + scopeKeys: readonly string[], + period: string, +): Promise>> { + const ddb = makeDocClient({ region }); + const rows = new Map>(); + for (let offset = 0; offset < scopeKeys.length; offset += BATCH_GET_LIMIT) { + let pendingKeys = scopeKeys + .slice(offset, offset + BATCH_GET_LIMIT) + .map(scopeKey => ({ scope_key: scopeKey, period })); + do { + const result = await ddb.send(new BatchGetCommand({ + RequestItems: { + [tableName]: { + Keys: pendingKeys, + ConsistentRead: true, + }, + }, + })); + for (const item of result.Responses?.[tableName] ?? []) { + rows.set(String(item.scope_key), item); + } + pendingKeys = (result.UnprocessedKeys?.[tableName]?.Keys ?? []) + .map(key => ({ + scope_key: String(key.scope_key), + period: String(key.period), + })); + } while (pendingKeys.length > 0); + } + return rows; +} + +export async function listBudgetStatus( + region: string, + tableName: string, + scope?: BudgetScope, + now: Date = new Date(), +): Promise { + const period = currentBudgetPeriod(now); + const configs = await loadConfigs(region, tableName, scope); + const spendByScope = await batchGetSpend( + region, + tableName, + configs.map(config => String(config.scope_key)), + period, + ); + return configs + .map(config => toStatus(config, spendByScope.get(String(config.scope_key)), period)) + .sort((a, b) => + a.scope_type.localeCompare(b.scope_type) || a.scope_id.localeCompare(b.scope_id)); +} diff --git a/cli/src/commands/budget.ts b/cli/src/commands/budget.ts new file mode 100644 index 00000000..0f11ca5d --- /dev/null +++ b/cli/src/commands/budget.ts @@ -0,0 +1,269 @@ +/** + * 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 { + AdminGetUserCommand, + GetGroupCommand, + type AdminGetUserCommandOutput, +} from '@aws-sdk/client-cognito-identity-provider'; +import { Command } from 'commander'; +import { ApiClient } from '../api-client'; +import { + type BudgetScope, + type BudgetStatus, + currentBudgetPeriod, + listBudgetStatus, + setMonthlyBudget, +} from '../budget-store'; +import { + cognitoClient, + resolveCognitoAdminContext, + resolveCognitoUsername, +} from '../cognito-admin'; +import { CliError } from '../errors'; +import { DEFAULT_STACK_NAME, resolveOperatorContext } from '../operator-context'; +import { getStackOutput } from '../stack-outputs'; +import type { PersonalBudgetStatus } from '../types'; + +const SCOPE_TYPE_WIDTH = 8; +const SCOPE_ID_WIDTH = 36; +const MONEY_WIDTH = 12; +const PERCENT_WIDTH = 9; +const MIN_MONTHLY_BUDGET_USD = 0.01; +const OUTPUT_FORMATS = new Set(['text', 'json']); + +interface ScopeOptions { + readonly user?: string; + readonly team?: string; +} + +function requestedScope(opts: ScopeOptions): BudgetScope | null { + if (opts.user && opts.team) { + throw new CliError('Choose exactly one scope: --user or --team.'); + } + if (opts.user) return { type: 'user', id: opts.user }; + if (opts.team) return { type: 'team', id: opts.team }; + return null; +} + +async function resolveScope( + opts: ScopeOptions & { region?: string; stackName?: string }, + required: boolean, +): Promise { + const scope = requestedScope(opts); + if (!scope) { + if (required) throw new CliError('One scope is required: --user or --team .'); + return undefined; + } + + const cognito = await resolveCognitoAdminContext(opts); + const client = cognitoClient(cognito.region); + if (scope.type === 'user') { + const username = await resolveCognitoUsername(client, cognito.userPoolId, scope.id); + let user: AdminGetUserCommandOutput; + try { + user = await client.send(new AdminGetUserCommand({ + UserPoolId: cognito.userPoolId, + Username: username, + })); + } catch (err) { + if (err instanceof Error && err.name === 'UserNotFoundException') { + throw new CliError(`Cognito user '${scope.id}' was not found in pool ${cognito.userPoolId}.`); + } + throw err; + } + const subject = user.UserAttributes?.find(attribute => attribute.Name === 'sub')?.Value; + if (!subject) { + throw new CliError( + `Cognito user '${scope.id}' has no sub attribute in pool ${cognito.userPoolId}.`, + ); + } + return { type: 'user', id: subject }; + } + try { + await client.send(new GetGroupCommand({ + UserPoolId: cognito.userPoolId, + GroupName: scope.id, + })); + } catch (err) { + if (err instanceof Error && err.name === 'ResourceNotFoundException') { + throw new CliError(`Cognito team/group '${scope.id}' was not found in pool ${cognito.userPoolId}.`); + } + throw err; + } + return scope; +} + +async function budgetContext(opts: { + region?: string; + stackName?: string; +}): Promise<{ region: string; stackName: string; tableName: string }> { + const { region, stackName } = resolveOperatorContext(opts); + const tableName = await getStackOutput(region, stackName, 'BudgetTableName'); + if (!tableName) { + throw new CliError( + `Stack '${stackName}' is missing output 'BudgetTableName'. Re-deploy the CDK stack.`, + ); + } + return { region, stackName, tableName }; +} + +function dollars(value: number): string { + return `$${value.toFixed(2)}`; +} + +function printStatus(rows: readonly BudgetStatus[]): void { + if (rows.length === 0) { + console.log('No monthly budgets configured for this scope.'); + return; + } + console.log( + `${'TYPE'.padEnd(SCOPE_TYPE_WIDTH)} ` + + `${'SCOPE'.padEnd(SCOPE_ID_WIDTH)} ` + + `${'SPEND'.padEnd(MONEY_WIDTH)} ` + + `${'LIMIT'.padEnd(MONEY_WIDTH)} ` + + `${'USED'.padEnd(PERCENT_WIDTH)} HARD STOP`, + ); + for (const row of rows) { + const hardStop = row.hard_stop + ? (row.hard_stop_active ? 'ACTIVE' : 'enabled') + : 'disabled'; + console.log( + `${row.scope_type.padEnd(SCOPE_TYPE_WIDTH)} ` + + `${row.scope_id.padEnd(SCOPE_ID_WIDTH)} ` + + `${dollars(row.spend_usd).padEnd(MONEY_WIDTH)} ` + + `${dollars(row.monthly_limit_usd).padEnd(MONEY_WIDTH)} ` + + `${`${row.utilization_percent.toFixed(1)}%`.padEnd(PERCENT_WIDTH)} ${hardStop}`, + ); + } +} + +function printPersonalStatus(status: PersonalBudgetStatus): void { + console.log(`Personal monthly budget (${status.period} UTC)`); + console.log(`Estimated spend: ${dollars(status.spend_usd)}`); + if (!status.configured) { + console.log('Monthly limit: not configured'); + console.log(`Resets at: ${status.resets_at}`); + console.log('Team budgets are not shown here and may also apply.'); + return; + } + console.log(`Monthly limit: ${dollars(status.monthly_limit_usd ?? 0)}`); + console.log(`Remaining: ${dollars(status.remaining_usd ?? 0)}`); + console.log(`Used: ${(status.utilization_percent ?? 0).toFixed(1)}%`); + const hardStop = status.hard_stop + ? (status.hard_stop_active ? 'ACTIVE' : 'enabled') + : 'disabled'; + console.log(`Hard stop: ${hardStop}`); + console.log(`Resets at: ${status.resets_at}`); + console.log('Team budgets are not shown here and may also apply.'); +} + +function assertOutputFormat(format: string): void { + if (!OUTPUT_FORMATS.has(format)) { + throw new CliError('--output must be text or json.'); + } +} + +function addOperatorOptions(command: Command): Command { + return command + .option('--region ', 'AWS region (defaults to configured region or AWS_REGION)') + .option('--stack-name ', 'CloudFormation stack name', DEFAULT_STACK_NAME); +} + +function addScopeOptions(command: Command): Command { + return command + .option('--user ', 'Cognito user email or username/sub') + .option('--team ', 'Cognito group name used as the team ID'); +} + +export function makeBudgetCommand(): Command { + const budget = new Command('budget') + .description('View or administer monthly user/team spend budgets'); + + budget.addCommand( + addOperatorOptions(addScopeOptions( + new Command('status') + .description('Show current UTC-month spend and limits') + .option('--me', 'Show your personal budget using Cognito authentication') + .option('--output ', 'Output format: text or json', 'text') + .action(async (opts) => { + assertOutputFormat(opts.output); + if (opts.me) { + if (opts.user || opts.team) { + throw new CliError('--me cannot be combined with --user or --team.'); + } + const status = await new ApiClient().getPersonalBudget(); + if (opts.output === 'json') { + console.log(JSON.stringify(status, null, 2)); + return; + } + printPersonalStatus(status); + return; + } + requestedScope(opts); + const ctx = await budgetContext(opts); + const scope = await resolveScope(opts, false); + const rows = await listBudgetStatus(ctx.region, ctx.tableName, scope); + if (opts.output === 'json') { + console.log(JSON.stringify({ + period: rows[0]?.period ?? currentBudgetPeriod(), + budgets: rows, + }, null, 2)); + return; + } + printStatus(rows); + }), + )), + ); + + budget.addCommand( + addOperatorOptions(addScopeOptions( + new Command('set') + .description('Set a recurring monthly USD limit') + .requiredOption('--monthly-usd ', 'Monthly limit in USD', parseFloat) + .option('--hard-stop', 'Reject new tasks at 100% utilization', false) + .action(async (opts) => { + requestedScope(opts); + if (!Number.isFinite(opts.monthlyUsd) || opts.monthlyUsd < MIN_MONTHLY_BUDGET_USD) { + throw new CliError(`--monthly-usd must be at least ${MIN_MONTHLY_BUDGET_USD}.`); + } + const ctx = await budgetContext(opts); + const scope = await resolveScope(opts, true); + if (!scope) { + throw new CliError('One scope is required: --user or --team .'); + } + await setMonthlyBudget( + ctx.region, + ctx.tableName, + scope, + opts.monthlyUsd, + opts.hardStop === true, + ); + const rows = await listBudgetStatus(ctx.region, ctx.tableName, scope); + console.log( + `Set ${scope.type} '${scope.id}' monthly budget to ${dollars(opts.monthlyUsd)} ` + + `(${opts.hardStop ? 'hard stop at 100%' : 'alerts only'}).`, + ); + printStatus(rows); + }), + )), + ); + + return budget; +} diff --git a/cli/src/types.ts b/cli/src/types.ts index 32bc4bbd..bf399e24 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -332,6 +332,28 @@ export interface TaskSummary { readonly updated_at: string; } +/** Authenticated caller's personal monthly budget status. */ +export interface PersonalBudgetStatus { + /** UTC calendar month in ``YYYY-MM`` form. */ + readonly period: string; + /** First instant of the next UTC calendar month. */ + readonly resets_at: string; + /** Whether an administrator configured a recurring personal limit. */ + readonly configured: boolean; + /** Estimated terminal-task spend attributed to the caller this month. */ + readonly spend_usd: number; + /** Configured recurring limit, or null when no personal limit exists. */ + readonly monthly_limit_usd: number | null; + /** Non-negative estimated amount remaining, or null without a limit. */ + readonly remaining_usd: number | null; + /** Percentage of the configured limit used, or null without a limit. */ + readonly utilization_percent: number | null; + /** Whether the administrator enabled admission hard-stop enforcement. */ + readonly hard_stop: boolean; + /** Whether the configured hard stop is currently blocking new tasks. */ + readonly hard_stop_active: boolean; +} + /** Task event returned by GET /v1/tasks/{task_id}/events. */ export interface TaskEvent { readonly event_id: string; diff --git a/cli/test/api-client.test.ts b/cli/test/api-client.test.ts index 54fb3043..d0189df8 100644 --- a/cli/test/api-client.test.ts +++ b/cli/test/api-client.test.ts @@ -99,6 +99,33 @@ describe('ApiClient', () => { }); }); + describe('getPersonalBudget', () => { + test('uses the authenticated budget view on the existing tasks endpoint', async () => { + const budget = { + period: '2026-08', + resets_at: '2026-09-01T00:00:00.000Z', + configured: true, + spend_usd: 25, + monthly_limit_usd: 100, + remaining_usd: 75, + utilization_percent: 25, + hard_stop: true, + hard_stop_active: false, + }; + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ data: budget }), + }); + + await expect(client.getPersonalBudget()).resolves.toEqual(budget); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/tasks?view=budget', + expect.objectContaining({ method: 'GET' }), + ); + expect(mockGetAuthToken).toHaveBeenCalled(); + }); + }); + describe('getTask', () => { test('sends GET with task ID', async () => { const taskDetail = { task_id: 'abc' }; diff --git a/cli/test/budget-store.test.ts b/cli/test/budget-store.test.ts new file mode 100644 index 00000000..5ae42aba --- /dev/null +++ b/cli/test/budget-store.test.ts @@ -0,0 +1,189 @@ +/** + * 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 { + BatchGetCommand, + GetCommand, + QueryCommand, + TransactWriteCommand, +} from '@aws-sdk/lib-dynamodb'; +import { + currentBudgetPeriod, + listBudgetStatus, + setMonthlyBudget, +} from '../src/budget-store'; +import { makeDocClient } from '../src/ua'; + +jest.mock('../src/ua', () => ({ + makeDocClient: jest.fn(), +})); + +const makeDocClientMock = makeDocClient as jest.Mock; +const sendMock = jest.fn(); + +describe('budget store', () => { + beforeEach(() => { + sendMock.mockReset(); + makeDocClientMock.mockReset(); + makeDocClientMock.mockReturnValue({ send: sendMock }); + }); + + test('uses UTC calendar months', () => { + expect(currentBudgetPeriod(new Date('2026-08-31T23:59:59Z'))).toBe('2026-08'); + expect(currentBudgetPeriod(new Date('2026-09-01T00:00:00Z'))).toBe('2026-09'); + }); + + test('writes the recurring config and resets current-month alert claims', async () => { + sendMock.mockResolvedValue({}); + + await setMonthlyBudget( + 'us-east-1', + 'Budgets', + { type: 'team', id: 'Platform' }, + 250, + true, + new Date('2026-08-18T12:00:00Z'), + ); + + const command = sendMock.mock.calls[0][0] as TransactWriteCommand; + expect(command.input.TransactItems).toHaveLength(2); + expect(command.input.TransactItems?.[0]?.Put?.Item).toMatchObject({ + scope_key: 'TEAM#Platform', + period: 'CONFIG', + record_type: 'CONFIG', + monthly_limit_usd: 250, + hard_stop: true, + }); + expect(command.input.TransactItems?.[1]?.Update).toMatchObject({ + Key: { scope_key: 'TEAM#Platform', period: '2026-08' }, + ExpressionAttributeNames: { '#ttl': 'ttl' }, + }); + expect(command.input.TransactItems?.[1]?.Update?.UpdateExpression) + .toContain('#ttl = :ttl'); + expect(command.input.TransactItems?.[1]?.Update?.UpdateExpression) + .toContain('REMOVE alerted_80_at'); + }); + + test('queries the config index and joins current spend', async () => { + sendMock + .mockResolvedValueOnce({ + Items: [{ + scope_key: 'USER#user-1', + scope_type: 'user', + scope_id: 'user-1', + monthly_limit_usd: 100, + hard_stop: true, + updated_at: '2026-08-01T00:00:00Z', + }], + }) + .mockResolvedValueOnce({ + Responses: { + Budgets: [{ + scope_key: 'USER#user-1', + period: '2026-08', + spend_usd: 85, + }], + }, + }); + + const rows = await listBudgetStatus( + 'us-east-1', + 'Budgets', + undefined, + new Date('2026-08-18T12:00:00Z'), + ); + + expect(sendMock.mock.calls[0][0]).toBeInstanceOf(QueryCommand); + expect((sendMock.mock.calls[0][0] as QueryCommand).input.IndexName) + .toBe('record_type-scope_key-index'); + expect(sendMock.mock.calls[1][0]).toBeInstanceOf(BatchGetCommand); + expect((sendMock.mock.calls[1][0] as BatchGetCommand) + .input.RequestItems?.Budgets?.ConsistentRead).toBe(true); + expect(rows).toEqual([expect.objectContaining({ + scope_type: 'user', + scope_id: 'user-1', + spend_usd: 85, + remaining_usd: 15, + utilization_percent: 85, + hard_stop_active: false, + })]); + }); + + test('uses a consistent direct read for a scoped status request', async () => { + sendMock + .mockResolvedValueOnce({ + Item: { + scope_key: 'TEAM#Platform', + scope_type: 'team', + scope_id: 'Platform', + monthly_limit_usd: 10, + hard_stop: true, + }, + }) + .mockResolvedValueOnce({ + Responses: { + Budgets: [{ + scope_key: 'TEAM#Platform', + period: '2026-08', + spend_usd: 12, + }], + }, + }); + + const rows = await listBudgetStatus( + 'us-east-1', + 'Budgets', + { type: 'team', id: 'Platform' }, + new Date('2026-08-18T12:00:00Z'), + ); + + const command = sendMock.mock.calls[0][0] as GetCommand; + expect(command).toBeInstanceOf(GetCommand); + expect(command.input).toMatchObject({ + Key: { scope_key: 'TEAM#Platform', period: 'CONFIG' }, + ConsistentRead: true, + }); + expect(rows[0]).toMatchObject({ + utilization_percent: 120, + remaining_usd: 0, + hard_stop_active: true, + }); + }); + + test('rejects a corrupt non-positive configured limit', async () => { + sendMock + .mockResolvedValueOnce({ + Item: { + scope_key: 'USER#user-1', + scope_type: 'user', + scope_id: 'user-1', + monthly_limit_usd: 0, + hard_stop: true, + }, + }) + .mockResolvedValueOnce({ Responses: { Budgets: [] } }); + + await expect(listBudgetStatus( + 'us-east-1', + 'Budgets', + { type: 'user', id: 'user-1' }, + new Date('2026-08-18T12:00:00Z'), + )).rejects.toThrow('Budget config USER#user-1 has invalid monthly_limit_usd'); + }); +}); diff --git a/cli/test/commands/budget.test.ts b/cli/test/commands/budget.test.ts new file mode 100644 index 00000000..2f0a07d0 --- /dev/null +++ b/cli/test/commands/budget.test.ts @@ -0,0 +1,321 @@ +/** + * 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 { ApiClient } from '../../src/api-client'; +import { + currentBudgetPeriod, + listBudgetStatus, + setMonthlyBudget, +} from '../../src/budget-store'; +import { + cognitoClient, + resolveCognitoAdminContext, + resolveCognitoUsername, +} from '../../src/cognito-admin'; +import { makeBudgetCommand } from '../../src/commands/budget'; +import { getStackOutput } from '../../src/stack-outputs'; + +jest.mock('../../src/budget-store'); +jest.mock('../../src/api-client'); +jest.mock('../../src/cognito-admin'); +jest.mock('../../src/stack-outputs'); +jest.mock('../../src/operator-context', () => ({ + DEFAULT_STACK_NAME: 'backgroundagent-dev', + resolveOperatorContext: jest.fn(() => ({ + region: 'us-east-1', + stackName: 'backgroundagent-dev', + })), +})); + +const listBudgetStatusMock = listBudgetStatus as jest.Mock; +const setMonthlyBudgetMock = setMonthlyBudget as jest.Mock; +const currentBudgetPeriodMock = currentBudgetPeriod as jest.Mock; +const getStackOutputMock = getStackOutput as jest.Mock; +const resolveCognitoAdminContextMock = resolveCognitoAdminContext as jest.Mock; +const resolveCognitoUsernameMock = resolveCognitoUsername as jest.Mock; +const cognitoClientMock = cognitoClient as jest.Mock; +const cognitoSend = jest.fn(); +const getPersonalBudgetMock = jest.fn(); +const ApiClientMock = ApiClient as jest.MockedClass; + +describe('budget command', () => { + let consoleSpy: jest.SpiedFunction; + + beforeEach(() => { + process.exitCode = undefined; + consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + listBudgetStatusMock.mockReset(); + setMonthlyBudgetMock.mockReset(); + currentBudgetPeriodMock.mockReset(); + getStackOutputMock.mockReset(); + resolveCognitoAdminContextMock.mockReset(); + resolveCognitoUsernameMock.mockReset(); + cognitoClientMock.mockReset(); + cognitoSend.mockReset(); + getPersonalBudgetMock.mockReset(); + ApiClientMock.mockReset(); + + getStackOutputMock.mockResolvedValue('BudgetTable'); + resolveCognitoAdminContextMock.mockResolvedValue({ + region: 'us-east-1', + userPoolId: 'us-east-1_pool', + configureBundle: null, + }); + resolveCognitoUsernameMock.mockResolvedValue('user-sub'); + cognitoClientMock.mockReturnValue({ send: cognitoSend }); + cognitoSend.mockResolvedValue({ + UserAttributes: [{ Name: 'sub', Value: 'subject-123' }], + }); + listBudgetStatusMock.mockResolvedValue([]); + setMonthlyBudgetMock.mockResolvedValue(undefined); + currentBudgetPeriodMock.mockReturnValue('2026-08'); + ApiClientMock.mockImplementation(() => ({ + getPersonalBudget: getPersonalBudgetMock, + }) as never); + getPersonalBudgetMock.mockResolvedValue({ + period: '2026-08', + resets_at: '2026-09-01T00:00:00.000Z', + configured: true, + spend_usd: 25, + monthly_limit_usd: 100, + remaining_usd: 75, + utilization_percent: 25, + hard_stop: true, + hard_stop_active: false, + }); + }); + + afterEach(() => { + process.exitCode = undefined; + consoleSpy.mockRestore(); + }); + + test('sets a hard-stop user budget after resolving email to Cognito ID', async () => { + const command = makeBudgetCommand(); + await command.parseAsync([ + 'node', + 'test', + 'set', + '--user', + 'operator@example.com', + '--monthly-usd', + '125.50', + '--hard-stop', + '--region', + 'us-east-1', + ]); + + expect(resolveCognitoUsernameMock).toHaveBeenCalledWith( + expect.anything(), + 'us-east-1_pool', + 'operator@example.com', + ); + expect(cognitoSend).toHaveBeenCalledWith(expect.objectContaining({ + input: { + UserPoolId: 'us-east-1_pool', + Username: 'user-sub', + }, + })); + expect(setMonthlyBudgetMock).toHaveBeenCalledWith( + 'us-east-1', + 'BudgetTable', + { type: 'user', id: 'subject-123' }, + 125.5, + true, + ); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('$125.50')); + }); + + test('validates a Cognito team before setting its budget', async () => { + const command = makeBudgetCommand(); + await command.parseAsync([ + 'node', + 'test', + 'set', + '--team', + 'Platform', + '--monthly-usd', + '500', + ]); + + expect(cognitoSend).toHaveBeenCalledWith(expect.objectContaining({ + input: { + UserPoolId: 'us-east-1_pool', + GroupName: 'Platform', + }, + })); + expect(setMonthlyBudgetMock).toHaveBeenCalledWith( + 'us-east-1', + 'BudgetTable', + { type: 'team', id: 'Platform' }, + 500, + false, + ); + }); + + test('rejects a Cognito user ID that does not exist', async () => { + const notFound = Object.assign(new Error('missing'), { + name: 'UserNotFoundException', + }); + cognitoSend.mockRejectedValueOnce(notFound); + const command = makeBudgetCommand(); + + await expect( + command.parseAsync([ + 'node', + 'test', + 'set', + '--user', + 'missing-user', + '--monthly-usd', + '10', + ]), + ).rejects.toThrow("Cognito user 'missing-user' was not found"); + expect(setMonthlyBudgetMock).not.toHaveBeenCalled(); + }); + + test('rejects a Cognito user with no sub attribute', async () => { + cognitoSend.mockResolvedValueOnce({ UserAttributes: [] }); + const command = makeBudgetCommand(); + + await expect( + command.parseAsync([ + 'node', + 'test', + 'set', + '--user', + 'missing-sub', + '--monthly-usd', + '10', + ]), + ).rejects.toThrow("Cognito user 'missing-sub' has no sub attribute"); + expect(setMonthlyBudgetMock).not.toHaveBeenCalled(); + }); + + test('outputs the current period in JSON when no budgets are configured', async () => { + const command = makeBudgetCommand(); + await command.parseAsync(['node', 'test', 'status', '--output', 'json']); + + expect(JSON.parse(consoleSpy.mock.calls[0][0] as string)).toEqual({ + period: '2026-08', + budgets: [], + }); + }); + + test('shows the authenticated user personal budget without operator AWS calls', async () => { + const command = makeBudgetCommand(); + await command.parseAsync(['node', 'test', 'status', '--me']); + + expect(getPersonalBudgetMock).toHaveBeenCalledTimes(1); + expect(getStackOutputMock).not.toHaveBeenCalled(); + expect(resolveCognitoAdminContextMock).not.toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith('Personal monthly budget (2026-08 UTC)'); + expect(consoleSpy).toHaveBeenCalledWith('Remaining: $75.00'); + expect(consoleSpy).toHaveBeenCalledWith('Hard stop: enabled'); + }); + + test('renders an unconfigured personal budget without hiding estimated spend', async () => { + getPersonalBudgetMock.mockResolvedValueOnce({ + period: '2026-08', + resets_at: '2026-09-01T00:00:00.000Z', + configured: false, + spend_usd: 12.25, + monthly_limit_usd: null, + remaining_usd: null, + utilization_percent: null, + hard_stop: false, + hard_stop_active: false, + }); + const command = makeBudgetCommand(); + await command.parseAsync(['node', 'test', 'status', '--me']); + + expect(consoleSpy).toHaveBeenCalledWith('Estimated spend: $12.25'); + expect(consoleSpy).toHaveBeenCalledWith('Monthly limit: not configured'); + }); + + test('shows when the personal hard stop is active', async () => { + getPersonalBudgetMock.mockResolvedValueOnce({ + period: '2026-08', + resets_at: '2026-09-01T00:00:00.000Z', + configured: true, + spend_usd: 100, + monthly_limit_usd: 100, + remaining_usd: 0, + utilization_percent: 100, + hard_stop: true, + hard_stop_active: true, + }); + const command = makeBudgetCommand(); + await command.parseAsync(['node', 'test', 'status', '--me']); + + expect(consoleSpy).toHaveBeenCalledWith('Hard stop: ACTIVE'); + }); + + test('outputs the personal budget as JSON', async () => { + const command = makeBudgetCommand(); + await command.parseAsync(['node', 'test', 'status', '--me', '--output', 'json']); + + expect(JSON.parse(consoleSpy.mock.calls[0][0] as string)).toEqual( + await getPersonalBudgetMock.mock.results[0].value, + ); + }); + + test('rejects --me with an operator-selected scope before making API calls', async () => { + const command = makeBudgetCommand(); + await expect( + command.parseAsync(['node', 'test', 'status', '--me', '--user', 'alice@example.com']), + ).rejects.toThrow('--me cannot be combined with --user or --team'); + expect(getPersonalBudgetMock).not.toHaveBeenCalled(); + expect(getStackOutputMock).not.toHaveBeenCalled(); + }); + + test('rejects invalid output before reading stack outputs', async () => { + const command = makeBudgetCommand(); + await expect( + command.parseAsync(['node', 'test', 'status', '--output', 'yaml']), + ).rejects.toThrow('--output must be text or json'); + expect(getStackOutputMock).not.toHaveBeenCalled(); + }); + + test('requires exactly one scope for set before reading stack outputs', async () => { + const command = makeBudgetCommand(); + await expect( + command.parseAsync([ + 'node', + 'test', + 'set', + '--user', + 'user-1', + '--team', + 'Platform', + '--monthly-usd', + '10', + ]), + ).rejects.toThrow('Choose exactly one scope'); + expect(getStackOutputMock).not.toHaveBeenCalled(); + }); + + test('rejects non-positive monthly limits', async () => { + const command = makeBudgetCommand(); + await expect( + command.parseAsync(['node', 'test', 'set', '--user', 'user-1', '--monthly-usd', '0']), + ).rejects.toThrow('--monthly-usd must be at least 0.01'); + expect(setMonthlyBudgetMock).not.toHaveBeenCalled(); + }); +}); diff --git a/docs/design/API_CONTRACT.md b/docs/design/API_CONTRACT.md index 9aa24135..fac01569 100644 --- a/docs/design/API_CONTRACT.md +++ b/docs/design/API_CONTRACT.md @@ -183,7 +183,7 @@ For PR tasks, `branch_name` is initially `pending:pr_resolution` and resolved to **Idempotency:** Clients may send `Idempotency-Key` (see [Conventions](#conventions)). The first successful create returns **`201 Created`** (or `202` for presigned tasks). A subsequent request with the same key and the **same authenticated user** returns **`200 OK`** with the full `TaskDetail` reflecting **current** task state, plus response header `Idempotent-Replay: true`. No duplicate task is created and the orchestrator is not invoked again for that replay. If the key is already bound to a task owned by **another** user, the API returns **`409 DUPLICATE_TASK`** without exposing that task (extremely unlikely for high-entropy keys). -**Errors:** `400 VALIDATION_ERROR` (invalid body/parameters, or task description blocked by content screening), `400 ATTACHMENT_INVALID_CONTENT` (content does not match declared MIME type or could not be sanitized), `400 ATTACHMENT_BLOCKED` (inline attachment failed content screening), `400 ATTACHMENT_INLINE_TOO_LARGE` (single inline attachment > 500 KB; total inline > 3 MB surfaces as `VALIDATION_ERROR`), `400 ATTACHMENTS_TOTAL_TOO_LARGE` (aggregate declared size > 50 MB), `401 UNAUTHORIZED`, `409 DUPLICATE_TASK` (idempotency key collision across users only), `422 REPO_NOT_ONBOARDED`, `503 SERVICE_UNAVAILABLE`, `503 ATTACHMENT_SCREENING_UNAVAILABLE`. +**Errors:** `400 VALIDATION_ERROR` (invalid body/parameters, or task description blocked by content screening), `400 ATTACHMENT_INVALID_CONTENT` (content does not match declared MIME type or could not be sanitized), `400 ATTACHMENT_BLOCKED` (inline attachment failed content screening), `400 ATTACHMENT_INLINE_TOO_LARGE` (single inline attachment > 500 KB; total inline > 3 MB surfaces as `VALIDATION_ERROR`), `400 ATTACHMENTS_TOTAL_TOO_LARGE` (aggregate declared size > 50 MB), `401 UNAUTHORIZED`, `409 DUPLICATE_TASK` (idempotency key collision across users only), `422 REPO_NOT_ONBOARDED`, `429 BUDGET_EXCEEDED` (a user or Cognito-team monthly hard-stop budget is exhausted), `503 SERVICE_UNAVAILABLE`, `503 ATTACHMENT_SCREENING_UNAVAILABLE`. > Task-description content screening (Bedrock Guardrails) that intervenes returns `400 VALIDATION_ERROR` with message "Task description was blocked by content policy." — there is no separate `GUARDRAIL_BLOCKED` code. @@ -296,9 +296,30 @@ Returns the authenticated user's tasks, newest first. Paginated. | `repo` | String | all | Filter by repository (`owner/repo`) | | `limit` | Number | 20 | Page size (1-100) | | `next_token` | String | - | Pagination token from previous response | +| `view` | String | tasks | `budget` returns the authenticated caller's personal monthly budget instead of a task page | Returns a summary subset of fields. Use `GET /v1/tasks/{task_id}` for full details. +With `view=budget`, the Cognito-authenticated caller receives only their personal estimated-spend scope: + +```json +{ + "data": { + "period": "2026-08", + "resets_at": "2026-09-01T00:00:00.000Z", + "configured": true, + "spend_usd": 25, + "monthly_limit_usd": 100, + "remaining_usd": 75, + "utilization_percent": 25, + "hard_stop": true, + "hard_stop_active": false + } +} +``` + +When no personal limit exists, `configured` is false, the limit/remaining/utilization fields are null, and `spend_usd` still reports the caller's current estimated spend. Team budgets are not returned. This read-only view backs `bgagent budget status --me`; budget mutation remains an operator-AWS-credential workflow. + **Errors:** `400 VALIDATION_ERROR`, `401 UNAUTHORIZED`. ### Cancel task @@ -529,7 +550,7 @@ HMAC verification runs in the handler (not the authorizer) because API Gateway R Tasks created via webhook record `channel_source: 'webhook'` with audit metadata (`webhook_id`, `source_ip`, `user_agent`). -**Errors:** `400 VALIDATION_ERROR` (includes task descriptions blocked by content screening), `401 UNAUTHORIZED`, `409 DUPLICATE_TASK`, `422 REPO_NOT_ONBOARDED`, `503 SERVICE_UNAVAILABLE`. +**Errors:** `400 VALIDATION_ERROR` (includes task descriptions blocked by content screening), `401 UNAUTHORIZED`, `409 DUPLICATE_TASK`, `422 REPO_NOT_ONBOARDED`, `429 BUDGET_EXCEEDED`, `503 SERVICE_UNAVAILABLE`. ## Rate limiting and throttling @@ -547,6 +568,8 @@ There is no per-user request-rate or "tasks-per-hour" limiter on task creation. - `POST /v1/tasks/{task_id}/confirm-uploads` rejects with `429 RATE_LIMIT_EXCEEDED` when the user is already at their concurrency limit. - For the orchestrator admission path (the `SUBMITTED → HYDRATING` transition), exceeding the limit does not return an HTTP error — the task is already created. The orchestrator drives the task to `FAILED` with `error_message` "User concurrency limit reached" and emits an `admission_rejected` event. +**Monthly budget admission.** Task creation checks the current UTC-month estimated spend for the authenticated user and every Cognito group captured as a team. A configured hard-stop scope at or above 100% returns `429 BUDGET_EXCEEDED` before creating a new task. Alerts-only scopes continue. Same-user idempotent replays return their existing task before this check. + ## Error codes | Code | Status | Description | @@ -578,6 +601,7 @@ There is no per-user request-rate or "tasks-per-hour" limiter on task creation. | `SCREENING_DEADLINE_EXCEEDED` | 503 | Attachment screening did not complete within the time limit (retry; already-screened attachments are skipped) | | `GITHUB_UNREACHABLE` | 502 | GitHub API unreachable during pre-flight (transient) | | `RATE_LIMIT_EXCEEDED` | 429 | Rate/concurrency gate exceeded — per-task nudge limit, the application rate limiter on approval endpoints, or the user concurrency limit on confirm-uploads | +| `BUDGET_EXCEEDED` | 429 | A configured user or Cognito-team monthly budget reached 100% with hard stop enabled | | `REQUEST_NOT_FOUND` | 404 | Cedar HITL approval request not found (also returned when the caller does not own it) | | `REQUEST_ALREADY_DECIDED` | 409 | Cedar HITL approval request was already approved or denied | | `TASK_NOT_AWAITING_APPROVAL` | 409 | Task is not in `AWAITING_APPROVAL`, so the approval decision does not apply | diff --git a/docs/design/COST_MODEL.md b/docs/design/COST_MODEL.md index 997f7237..1e158c16 100644 --- a/docs/design/COST_MODEL.md +++ b/docs/design/COST_MODEL.md @@ -2,7 +2,7 @@ This document provides an order-of-magnitude cost model for the platform. Cost efficiency is a first-class design principle (see [ARCHITECTURE.md](./ARCHITECTURE.md)). The model covers infrastructure baseline costs, per-task variable costs, and cost attribution guidance. -Detailed cost management (per-user budgets, cost attribution dashboards, token budget enforcement) builds on this baseline analysis and focuses on the dominant cost drivers. +Monthly user/team USD budgets and AWS-native cost attribution build on this baseline analysis and focus controls on the dominant cost drivers. ## Infrastructure baseline (monthly, idle) @@ -13,7 +13,7 @@ These costs are incurred regardless of task volume: | NAT Gateway (1×) | ~$32/month | Fixed hourly cost + data processing. Single AZ (see [COMPUTE.md - Network architecture](./COMPUTE.md)). | | VPC Interface Endpoints (7×, 2 AZs) | ~$102/month | $0.01/hr × 7 endpoints × 2 AZs × 730 hrs. | | VPC Flow Logs | ~$3/month | CloudWatch ingestion. | -| DynamoDB (on-demand, idle) | ~$0/month | Pay-per-request; 7 core tables (Tasks, Events, Nudges, Approvals, UserConcurrency, Webhooks, Repo). Integration tables add more when enabled (Slack: installation, user-mapping; Linear: project-mapping, user-mapping, workspace-registry, webhook-dedup). No cost when idle. | +| DynamoDB (on-demand, idle) | ~$0/month | Pay-per-request; 8 core tables (Tasks, Events, Nudges, Approvals, UserConcurrency, Budgets, Webhooks, Repo). Integration tables add more when enabled. No cost when idle. | | S3 Trace Artifacts bucket (idle) | ~$0/month | 7-day lifecycle auto-expires objects; no cost when no traces are stored. | | EventBridge reconciler rule | <$0.01/month | Invokes Lambda every 5 min (288/day). Rule itself is free; Lambda invocation is the cost (see below). | | Stranded task reconciler Lambda (idle) | <$0.01/month | 288 invocations/day × 256 MB × ~100 ms avg (early exit when no stranded tasks). ~$0.005/month total (requests + duration). | @@ -23,7 +23,7 @@ These costs are incurred regardless of task volume: ### Scale-to-zero characteristics -Most platform components are fully serverless and incur zero cost when idle: DynamoDB (PAY_PER_REQUEST, 7 core tables plus integration tables when Slack/Linear are enabled), Lambda, API Gateway, S3 (trace artifacts auto-expire in 7 days), SQS (fanout DLQ), ECS Fargate (cluster is free, when enabled), AgentCore Runtime (per-session), Bedrock (per-token), and Cognito (free tier). The stranded task reconciler adds <$0.01/month even when idle (288 Lambda invocations/day, early-exit). The always-on cost floor (~$140–150/month) is dominated by VPC networking infrastructure (NAT Gateway + 7 interface endpoints across 2 AZs) which is required for private subnet connectivity to AWS services and GitHub. See the [Deployment guide](../guides/DEPLOYMENT_GUIDE.md) for the full scale-to-zero breakdown. +Most platform components are fully serverless and incur zero cost when idle: DynamoDB (PAY_PER_REQUEST, 8 core tables plus integration tables), Lambda, API Gateway, S3 (trace artifacts auto-expire in 7 days), SQS, ECS Fargate (cluster is free, when enabled), AgentCore Runtime (per-session), Bedrock (per-token), and Cognito (free tier). The stranded task reconciler adds <$0.01/month even when idle (288 Lambda invocations/day, early-exit). The always-on cost floor (~$140–150/month) is dominated by VPC networking infrastructure (NAT Gateway + 7 interface endpoints across 2 AZs) which is required for private subnet connectivity to AWS services and GitHub. See the [Deployment guide](../guides/DEPLOYMENT_GUIDE.md) for the full scale-to-zero breakdown. ## Per-task variable costs @@ -41,7 +41,7 @@ Assuming a typical task: 1–2 hours, Claude Sonnet, ~100K input tokens, ~20K ou | Lambda fanout consumer | <$0.01 | Triggered per batch of task events (batch size 100, 5 s window). Typically 5–20 invocations per task at 256 MB. Negligible. | | Lambda nudge / trace / events | <$0.01 | On-demand per user request. Negligible unless heavily polled. | | DynamoDB reads/writes | <$0.01 | ~30–80 operations per task (task CRUD, events, nudges, counter updates). Negligible. | -| DynamoDB Streams (fanout) | <$0.01 | Stream reads charged per 25 KB. Typical task: ~20–50 event records. Negligible. | +| DynamoDB Streams (fanout and budget rollup) | <$0.01 | Stream reads charged per 25 KB. Event fanout processes progress records; budget rollup processes terminal TaskTable records. Negligible. | | S3 trace upload (if `--trace`) | <$0.01 | One PUT per task + storage (gzipped NDJSON, typically 50–500 KB, auto-expires in 7 days). | | NAT Gateway data | <$0.01 | GitHub API traffic: clone + push. Small repos: <10 MB. | | Custom step Lambdas | $0–0.05 | Only if configured. Per-invocation: ~$0.01 per step. | @@ -90,6 +90,7 @@ For multi-user deployments, cost should be attributable to individual users and - **Per-task:** Token usage and compute duration are captured in task metadata (`agent.cost_usd`, `agent.turns` - see [OBSERVABILITY.md](./OBSERVABILITY.md)). Note: `agent.cost_usd` is the Claude Agent SDK's **client-side estimate** (a build-time price table), not authoritative billing — use it for guardrails, and AWS Cost Explorer / CUR 2.0 for the real bill (see [COST_ATTRIBUTION.md](../guides/COST_ATTRIBUTION.md)). - **Per-user:** Aggregate task costs by `user_id`. +- **Per-team:** Attribute a task to the Cognito groups captured at task creation. - **Per-repo:** Aggregate task costs by `repo`. - **Dashboard:** Cost attribution dashboards should be built from the same task-level metrics. @@ -101,14 +102,23 @@ For **AWS-native** chargeback of Bedrock spend (Cost Explorer / CUR 2.0 by `user |---|---|---| | Turn limit | `max_turns` per task | 100 | | Cost budget | `max_budget_usd` per task | None (unlimited) | +| Monthly user/team warning | Estimated terminal-task cost rollup | CloudWatch/SNS at 80% and 100% | +| Monthly user/team hard stop | Admission check at 100% | Disabled per scope unless `--hard-stop` is set | | Session timeout | Orchestrator timeout | 9 hours | | Concurrency limit | Per-user atomic counter | 3 concurrent tasks | | System concurrency | System-wide counter | Account-level AgentCore quota | +Monthly budgets use UTC calendar months and the same estimated `cost_usd` stored on terminal tasks. The TaskTable stream consumer transactionally increments the user and captured Cognito-team rollups and writes a task marker so duplicate stream delivery cannot double count. Admission checks every configured applicable scope; any scope at 100% with hard stop enabled rejects a new task. In-flight tasks continue and can overshoot because their final cost is unknown until termination. + +The 80% and 100% crossings emit claimed, per-scope `ABCA/Budgets` CloudWatch metrics. Aggregate threshold alarms notify the shared `OperationalAlerts` SNS topic; simultaneous crossings can be coalesced, while the reconciler logs retain exact scope details. Metric claims normally limit each crossing to one emission per scope/month. Emission happens before the claim is persisted so a crash cannot permanently suppress an alert; a concurrent or crash retry can therefore emit a harmless duplicate. Operators configure and inspect limits with `bgagent budget set|status`. + +Authenticated users can inspect their personal scope with `bgagent budget status --me` (`GET /v1/tasks?view=budget`). The response includes estimated spend even when no personal limit is configured. It does not expose team scopes or permit mutation; administrators remain the only actors who set user/team limits. + +The controls add one on-demand DynamoDB table with PITR, two standard CloudWatch alarms, and up to two custom metric time series. Admission, terminal rollup, and user-status requests incur usage-based DynamoDB/API Gateway/Lambda/SNS charges; no dedicated continuously running compute is added. See the operator guide's [cost-control setup and cost breakdown](../guides/COST_ATTRIBUTION.md#setting-up-cost-controls). + ## Additional guardrails -- Per-user monthly token budgets with alerts at 80% and hard stop at 100%. -- Per-team monthly cost budgets. +- Token-denominated monthly budgets (the shipped fleet budget is USD-denominated). - Cost attribution dashboard in the control panel. - Automated model downgrade (e.g. Sonnet -> Haiku) when approaching budget limits. diff --git a/docs/design/OBSERVABILITY.md b/docs/design/OBSERVABILITY.md index b6db7a8d..5c413c71 100644 --- a/docs/design/OBSERVABILITY.md +++ b/docs/design/OBSERVABILITY.md @@ -113,6 +113,7 @@ All events carry `task_id` and `user_id` for filtering. ### Cost and performance - **Token usage** - Per task, per user, per repo. Feeds cost attribution and budget enforcement. +- **Monthly estimated spend** - Terminal task `cost_usd` rolled up by user and Cognito team for the UTC month. - **Task duration** - End-to-end, cold start (clone + install), and time to first agent output. - **Error rates** - By failure type (agent crash, timeout, cancellation, orchestration failure). @@ -122,6 +123,7 @@ All events carry `task_id` and `user_id` for filtering. |--------|------|---------| | Task duration (p50, p95) | Latency | Performance baseline and regression detection | | Token usage per task | Cost | Cost attribution and budget enforcement | +| `ABCA/Budgets:BudgetThresholdCrossed` | Cost | Claimed 80%/100% monthly user/team budget crossings; retries can rarely duplicate a metric | | Cold start duration | Latency | Image optimization signal | | Active tasks (RUNNING count) | Capacity | Admission control and capacity planning | | Pending tasks (SUBMITTED count) | Capacity | Backlog depth and throughput monitoring | @@ -156,6 +158,8 @@ The CloudWatch GenAI Observability console provides additional views: per-sessio | Agent crash rate spike | Sustained high session failure rate | Check for model API errors, compute quota exhaustion, image pull failures. | | Submitted backlog depth | SUBMITTED count exceeds threshold | System at capacity. Increase concurrency limits or wait for running tasks. | | Guardrail screening failures | Sustained Bedrock Guardrail API failures | Tasks fail at submission (503) and hydration (FAILED). Recovers when Bedrock recovers. | +| Monthly budget warning | Estimated spend crosses 80% | Notify the shared `OperationalAlerts` SNS topic; inspect `OrchestrationReconciler` logs for scope details. | +| Monthly budget exceeded | Estimated spend crosses 100% | Notify the shared topic. Scopes configured with hard stop reject new tasks. | ## Code attribution @@ -167,6 +171,7 @@ Task conversations, tool calls, decisions, and outcomes are persisted with metad - **TaskEvents table** - Append-only audit log of all task events. Records carry a DynamoDB TTL and are auto-deleted after the retention period (default 90 days, configurable via `taskRetentionDays`). - **Task records** - Status, timestamps, metadata. TTL is stamped when the task reaches a terminal state (default 90 days). Active tasks are retained indefinitely. +- **Budget records** - Recurring configs persist; monthly spend rows and task deduplication markers expire after about 400 days. - **Logs** - Application and usage logs retained for 90 days in CloudWatch. Traces flow to X-Ray via CloudWatch Transaction Search. - **Model invocation logs** - Bedrock model invocation logging with 90-day retention for compliance and prompt injection investigation. diff --git a/docs/guides/COST_ATTRIBUTION.md b/docs/guides/COST_ATTRIBUTION.md index a99b0b80..7711b1fa 100644 --- a/docs/guides/COST_ATTRIBUTION.md +++ b/docs/guides/COST_ATTRIBUTION.md @@ -11,7 +11,7 @@ ABCA gives you three independent views of cost. They answer different questions; | Meter | Granularity | Source of truth for | Where | |---|---|---|---| -| **In-app `cost_usd`** | Per task | Per-task budget guardrails (`max_budget_usd`) | Task metadata / control panel | +| **In-app `cost_usd`** | Per task; monthly rollups by user/team | Per-task and fleet admission guardrails | Task metadata / `bgagent budget` | | **CUR session-tag chargeback** | Per user / per repo, aggregated per usage-type per day | AWS-native FinOps chargeback | Cost Explorer / CUR 2.0 | | **Invocation-log metadata** | Per Bedrock call | Per-call forensics, reconciliation | `/aws/bedrock/model-invocation-logs/` | @@ -21,6 +21,58 @@ Why all three: the in-app meter is an estimate the platform computes; it does no Once deployed, each agent task makes its Bedrock calls under **session-tagged, refreshable credentials** carrying `{user_id, repo, task_id}`, and stamps the same values as **request metadata** on every call. You do **not** need to change any code. What remains is **operator setup in the AWS Billing console** — AWS does not surface tag-based cost data until you activate it, and (see the ordering note below) you can only activate *after* the platform has run tagged tasks. +## ABCA monthly budget guardrails + +ABCA can aggregate terminal task `cost_usd` by Cognito user and Cognito-group team, alert at 80%/100%, and optionally reject new tasks at 100%. Configure it with `bgagent budget set` and inspect the current UTC month with `bgagent budget status`; see [Monthly user and team budgets](./USER_GUIDE.md#monthly-user-and-team-budgets). + +This is an operational guardrail, not invoice reconciliation. It inherits every limitation of the SDK estimate, counts a task only when it reaches a terminal state, and can overshoot while tasks run concurrently. Use AWS Budgets over activated cost-allocation tags for authoritative billing alerts. + +### Setting up cost controls + +1. **Choose scopes.** Use one Cognito group such as `Everyone` for a shared organization pool. Add department/project groups or personal limits only when they represent a real independent control. Avoid whitespace and commas in budget-team group names: API Gateway can expose the Cognito group claim as a delimited string, where those characters are treated as separators. +2. **Create and populate groups.** Cognito group membership is the team mapping. `bgagent budget` validates groups but does not create them or add users. For an organization pool, bulk-add existing users once and add group assignment to the invitation/onboarding process. + + Get `UserPoolId` from `bgagent platform outputs`, then create the shared group and add each existing user in the Cognito console or AWS CLI: + + ```bash + aws cognito-idp create-group \ + --user-pool-id \ + --group-name Everyone + + aws cognito-idp admin-add-user-to-group \ + --user-pool-id \ + --username \ + --group-name Everyone + ``` + + Repeat `admin-add-user-to-group` during each new-user onboarding. A logged-in user should run `bgagent login` again after a membership change so interactive API requests carry current group claims; linked headless integrations resolve current groups server-side. +3. **Set recurring limits.** + + ```bash + # Shared organization pool with admission stopped at 100%. + bgagent budget set --team Everyone --monthly-usd 10000 --hard-stop + + # Optional personal alerts-only limit. + bgagent budget set --user alice@example.com --monthly-usd 100 + ``` + +4. **Connect notifications.** Confirm the deployment's `alertEmail` subscription or subscribe an operations destination to the exported `OperationalAlertsTopicArn`. +5. **Verify both views.** Operators run `bgagent budget status`; users run `bgagent budget status --me` after `bgagent login`. Users see only their personal scope and cannot change it. +6. **Test enforcement.** Use a non-production user/group and a small limit. Let a task finish so its estimated cost rolls up, then verify the 80%/100% notification and a `429 BUDGET_EXCEEDED` response for a hard-stop scope. + +The recurring configuration survives month boundaries; spend automatically starts from zero at the next UTC month. Changing a limit or toggling hard stop is one `budget set` command. There is currently no `budget unset` command and no automatic default-group assignment. + +### Cost of the controls + +There are two kinds of cost: + +- **Administrative effort:** one initial group-creation/bulk-membership pass, one budget command per user/team scope, and one group assignment per new user. A single `Everyone` scope has no recurring monthly configuration work. +- **AWS charges:** one on-demand DynamoDB table with point-in-time recovery, two standard CloudWatch alarms, up to two custom metric time series (`Threshold=80` and `Threshold=100`), and small usage-based DynamoDB/API Gateway/Lambda/SNS charges. The implementation reuses the existing task-list Lambda for `--me` and the existing TaskTable stream reconciler for rollups, so it adds no continuously running compute. + +At the public US East (N. Virginia) first-tier list rates verified in August 2026, the two standard alarms are about **$0.20/month** total. If both custom threshold metric series are active, their list-rate equivalent is up to about **$0.60/month**, making the CloudWatch portion approximately **$0.80/month** before free-tier allowance. The [CloudWatch free tier](https://aws.amazon.com/cloudwatch/pricing/) includes 10 custom metrics and 10 alarm metrics per month, shared with the rest of the account, so a lightly used account may pay $0 for that portion. + +DynamoDB is `PAY_PER_REQUEST`; costs scale with task volume, group count, retained deduplication markers, table storage, and PITR backup storage. Each task admission strongly reads its user/team scopes, and each terminal task transactionally writes one deduplication marker plus one spend increment per applicable scope. See [DynamoDB pricing](https://aws.amazon.com/dynamodb/pricing/on-demand/) and use the [AWS Pricing Calculator](https://calculator.aws/) for the deployment Region and expected task volume. API Gateway, Lambda, and SNS are request-based and normally negligible compared with agent inference for this low-frequency control plane. + ## FinOps checklist These steps are a one-time operator responsibility (CDK does not automate org-level billing — see [Out of scope](../design/BEDROCK_COST_ATTRIBUTION.md#out-of-scope-unchanged-from-issue)). diff --git a/docs/guides/DEPLOYMENT_GUIDE.md b/docs/guides/DEPLOYMENT_GUIDE.md index a32b0922..41fb8786 100644 --- a/docs/guides/DEPLOYMENT_GUIDE.md +++ b/docs/guides/DEPLOYMENT_GUIDE.md @@ -25,7 +25,7 @@ ECS Fargate is currently **opt-in** -- the `EcsAgentCluster` construct is presen | Component | Billing Model | Idle Cost | |-----------|--------------|-----------| -| DynamoDB (7 core tables; integrations add more) | PAY_PER_REQUEST | $0 | +| DynamoDB (8 core tables; integrations add more) | PAY_PER_REQUEST | $0 | | Lambda (all functions) | Per invocation | $0 | | API Gateway REST | Per request | $0 | | ECS Fargate tasks (when enabled) | Per running task | $0 (cluster is free) | @@ -51,6 +51,12 @@ The dominant idle cost is VPC networking: 7 interface endpoints across 2 AZs (~$ For the full cost model including per-task costs, see [COST_MODEL.md](../design/COST_MODEL.md). +### Incremental cost-control cost + +Monthly user/team controls add one DynamoDB on-demand table with PITR and two standard CloudWatch alarms. The table, API/Lambda reads, stream rollups, and SNS notifications are usage-based; the existing list Lambda and TaskTable reconciler perform the work, so there is no additional always-running compute. + +At public US East (N. Virginia) first-tier list rates verified in August 2026, the two alarms are approximately $0.20/month total. The two possible custom threshold metric series can add up to approximately $0.60/month when active. CloudWatch's account-wide free tier includes 10 custom metrics and 10 alarm metrics, so the incremental CloudWatch charge may be $0 when that allowance is still available. DynamoDB storage/PITR and request charges depend on task volume and the number of team scopes. See [Setting up cost controls](./COST_ATTRIBUTION.md#setting-up-cost-controls) for the operational overhead and pricing links. + ## AWS services inventory ### Compute @@ -84,10 +90,10 @@ For the full cost model including per-task costs, see [COST_MODEL.md](../design/ | Service | Used By | Scales to Zero | |---------|---------|---------------| -| DynamoDB (7 core tables, PAY_PER_REQUEST) | Task state, events, nudges, concurrency, webhooks, repo config, approvals. Enabling the Slack integration adds 2 tables (installation, user-mapping) and Linear adds 4 (project-mapping, user-mapping, workspace-registry, webhook-dedup) | Yes | -| DynamoDB Streams | TaskEventsTable → FanOut Consumer Lambda | Yes | +| DynamoDB (8 core tables, PAY_PER_REQUEST) | Task state, events, nudges, concurrency, monthly budgets, webhooks, repo config, approvals. Enabling integrations adds their mapping, registry, and deduplication tables. | Yes | +| DynamoDB Streams | TaskEventsTable → FanOut Consumer; TaskTable → combined orchestration/budget reconciler | Yes | | S3 | CDK asset bucket, ECR image layers, FUSE session storage, trace artifacts (7-day lifecycle) | Minimal | -| SQS (DLQ) | FanOut Consumer dead-letter queue | Yes | +| SQS (DLQ) | FanOut, approval metrics, screenshot, and orchestration/budget reconciler dead-letter queues | Yes | | Secrets Manager | GitHub PAT, webhook HMAC secrets | **No** (~$0.40/secret/mo) | ### API / Auth @@ -110,7 +116,7 @@ For the full cost model including per-task costs, see [COST_MODEL.md](../design/ |---------|---------|---------------| | CloudWatch Logs (multiple log groups) | Application, usage, model invocation, VPC flow, DNS query logs | **No** (storage) | | CloudWatch Dashboard | Operational metrics visualization | **No** (~$3/mo) | -| CloudWatch Alarms | Orchestrator error alerting | **No** (~$0.10/alarm) | +| CloudWatch Alarms | Operational failures plus 80%/100% monthly-budget thresholds | **No** (~$0.10/alarm) | | X-Ray | AgentCore Runtime tracing | Yes | ### Infrastructure / Deployment diff --git a/docs/guides/DEVELOPER_GUIDE.md b/docs/guides/DEVELOPER_GUIDE.md index e0c3a4c5..d0e36440 100644 --- a/docs/guides/DEVELOPER_GUIDE.md +++ b/docs/guides/DEVELOPER_GUIDE.md @@ -193,7 +193,7 @@ Token ratio 1.169; cost ratio 1.169 — identical. **The per-token rate is uncha | Per repo, Blueprint | `agent.maxBudgetUsd` on the repo's `Blueprint` construct | Works — persisted to `RepoTable.max_budget_usd`; same `0.01`–`100` range as the CLI, validated at CDK synth so an out-of-range value cannot deploy. See [Per-repo overrides](./USER_GUIDE.md#per-repo-overrides). | | Platform default | — | None by design: **unset means unlimited** | -**Unlimited-by-default is deliberate — pair it with the escape hatch.** Because no platform budget ceiling applies, the documented mitigation for cost is choosing a lighter-token model rather than relying on a cap: +**Per-task runtime budgets are unlimited by default.** Because no platform-wide per-task runtime ceiling applies, the documented mitigation for cost is choosing a lighter-token model rather than relying only on a cap: - **Per repo:** Blueprint `agent.modelId` (e.g. `us.anthropic.claude-sonnet-4-6`) — no code change, no agent redeploy - **Per task:** `model_id` in the task payload @@ -203,6 +203,8 @@ The model must be in the IAM grant list (layer 1) or the task fails at turn 0 wi **Trust boundary on the number.** `cost_usd` is the Claude Agent SDK's **client-side estimate** from that bundled price table — not authoritative billing. It drifts when Bedrock pricing changes, when the SDK version does not recognize a model, or when discounts and commitments apply. See [Cost attribution](./COST_ATTRIBUTION.md) (the warning at line 6); authoritative cost comes from AWS Cost Explorer / CUR 2.0. +**Fleet monthly budgets are a separate admission control.** `bgagent budget set --user|--team --monthly-usd [--hard-stop]` stores a recurring user or Cognito-group limit. Terminal task costs roll up by UTC month from the TaskTable stream. The 80% and 100% crossings publish CloudWatch alarms; hard-stop scopes reject new task creation at 100% without terminating in-flight work. See [Monthly user and team budgets](./USER_GUIDE.md#monthly-user-and-team-budgets). + ## Installation Follow the [Quick Start](./QUICK_START.mdx) to clone, install, deploy, and submit your first task. It covers prerequisites, toolchain setup, deployment, PAT configuration, Cognito user creation, and a smoke test. @@ -378,11 +380,13 @@ After deployment, the stack emits these outputs (retrieve with `aws cloudformati | `TaskNudgesTableName` | DynamoDB table for task nudges | | `TaskApprovalsTableName` | DynamoDB table for Cedar HITL approval gates | | `UserConcurrencyTableName` | DynamoDB table for per-user concurrency | +| `BudgetTableName` | DynamoDB table for recurring user/team limits and monthly estimated spend | | `WebhookTableName` | DynamoDB table for webhook integrations | | `RepoTableName` | DynamoDB table for per-repo Blueprint config | | `CedarWasmLayerArn` | Lambda layer ARN for the Cedar WASM policy engine | | `TraceArtifactsBucketName` | S3 bucket for agent trace artifacts (7-day lifecycle) | | `GitHubTokenSecretArn` | Secrets Manager secret ARN for the GitHub PAT | +| `OperationalAlertsTopicArn` | SNS topic for DLQ and monthly-budget alarms | When the Slack or Linear integrations are enabled, the stack emits additional outputs (e.g. `Slack*` and `Linear*` secret ARNs and integration table names). diff --git a/docs/guides/USER_GUIDE.md b/docs/guides/USER_GUIDE.md index 0ef78fb4..dfbff587 100644 --- a/docs/guides/USER_GUIDE.md +++ b/docs/guides/USER_GUIDE.md @@ -232,7 +232,7 @@ When you specify `--max-turns` (CLI) or `max_turns` (API) on a task, your value ### Where can I set `max_budget_usd`? -Every place a cost budget can come from, and nowhere else: +Every place a per-task runtime budget can come from: | Surface | How | Scope | Notes | |---|---|---|---| @@ -240,7 +240,7 @@ Every place a cost budget can come from, and nowhere else: | Per task, REST | `max_budget_usd` in the `POST /v1/tasks` body | One task | Same `0.01`–`100` range, validated server-side | | Per repo, Blueprint | `agent.maxBudgetUsd` on the repo's `Blueprint` construct | Every task on that repo | Persisted to `RepoTable.max_budget_usd`; same `0.01`–`100` range, enforced at CDK synth so an out-of-range value cannot deploy | | Local batch runs | `MAX_BUDGET_USD` shell env | One local run | **Local `entrypoint.py` batch mode only.** The deployed AgentCore **server** mode ignores this variable — it reads the budget from the `/invocations` request body, so setting it on the runtime has no effect | -| Platform-wide default | — | — | **None exists.** Unset means unlimited (see below) | +| Platform-wide per-task default | — | — | **None exists.** Unset means unlimited (see below) | The two that apply to a deployed task resolve in this order: **per-task value wins, then the repo's Blueprint default, then no budget at all.** A mid-task Blueprint edit does not move a running task's budget. @@ -256,9 +256,9 @@ new Blueprint(this, 'MyRepo', { Run `bgagent repo show ` to see which value is in effect; the `max_budget_usd` line reads `(per-blueprint override)` when the repo pins one and `(platform default) unlimited` when it does not. -### Unlimited by default is deliberate +### Per-task budgets are unlimited by default -There is intentionally no platform-wide budget ceiling. A hard global cap would kill long-running tasks mid-change — the failure mode is a half-finished branch and no PR, which is worse than a task that costs more than expected. The intended controls are the per-repo Blueprint default above (opt in where you want a ceiling), the per-task flag, and `max_turns`. +There is intentionally no platform-wide **per-task runtime** ceiling. A hard runtime cap kills a long-running task mid-change — the failure mode is a half-finished branch and no PR. The intended runtime controls are the per-repo Blueprint default above, the per-task flag, and `max_turns`. Administrators can separately configure monthly user/team admission budgets below; those reject new work instead of interrupting work already in progress. The documented escape hatch for cost is **choosing a lighter-token model** rather than relying on a cap: @@ -269,6 +269,44 @@ The model you pick must be in the platform's Bedrock IAM grant list, or the task Note that the reported `cost_usd` is a client-side estimate, not authoritative billing — see [Cost attribution](./COST_ATTRIBUTION.md). +### Monthly user and team budgets + +Operators can set recurring monthly USD limits for a Cognito user or team. Team IDs are Cognito group names, and every configured group budget applies to each member. Standard users can inspect their personal limit but cannot change it or view other users' budgets. + +For the lowest-overhead organization-wide control, create one Cognito group such as `Everyone`, add every existing user, and make group assignment part of the new-user onboarding process. The budget command validates an existing group; it does not create the group or manage membership. + +```bash +# One shared organization pool. Every member contributes to the same limit. +bgagent budget set --team Everyone --monthly-usd 10000 --hard-stop + +# Alert at 80% and 100%, but continue admitting tasks. +bgagent budget set --user alice@example.com --monthly-usd 100 + +# Reject new tasks after the Platform group reaches 100%. +bgagent budget set --team Platform --monthly-usd 1000 --hard-stop + +# Show every configured scope, or select one scope. JSON is also available. +bgagent budget status +bgagent budget status --user alice@example.com +bgagent budget status --team Platform --output json + +# Cognito-authenticated users can inspect only their own personal scope. +bgagent budget status --me +bgagent budget status --me --output json +``` + +`budget set` and operator-scoped `budget status` use AWS credentials and discover `BudgetTableName` and `UserPoolId` from the deployed CloudFormation stack. A user may be supplied by email or Cognito username/subject; a team must already exist as a Cognito group. Running `budget set` again replaces the recurring limit and enables or disables the hard stop for that scope. + +`budget status --me` is different: it uses the caller's cached Cognito login and the authenticated REST API, requires no operator AWS credentials, and never permits mutation. It reports personal estimated spend even when no personal limit is configured. Team and organization budgets are not exposed by this view and may still block new work. + +Monthly accounting uses terminal task `cost_usd` estimates and UTC calendar months. A task is attributed to its submitting user and the user's Cognito groups captured when the task was created. Failed tasks count when they report a positive cost. Running tasks do not count until they finish, so concurrent or long-running work can overshoot a limit. + +At 80% and 100%, each scope claims a CloudWatch threshold metric for the UTC month. The metric is emitted before the claim is persisted so a crash cannot permanently suppress an alert; a concurrent or crash retry can rarely emit a harmless duplicate. The aggregate threshold alarms notify the shared `OperationalAlerts` SNS topic; simultaneous scope crossings may be coalesced into one alarm notification, so inspect the `OrchestrationReconciler` Lambda logs for exact scope and spend details. `--hard-stop` rejects new task creation at 100% with `429 BUDGET_EXCEEDED`. Existing tasks, same-user idempotent replays, and tasks already awaiting upload confirmation are not interrupted. + +Like `max_budget_usd`, monthly spend is based on the Claude Agent SDK's estimated `cost_usd`, not the AWS invoice. Use AWS Cost Explorer or CUR 2.0 for authoritative financial controls. + +Administrative overhead is one budget command per controlled scope. A single `Everyone` group needs one initial bulk membership pass and one group assignment for each new user; the recurring limit and UTC-month reset require no monthly maintenance. See [Cost attribution](./COST_ATTRIBUTION.md#setting-up-cost-controls) for the setup checklist and incremental AWS cost. + ## Workflows Every task runs a **workflow** — a named, versioned recipe that decides whether to clone a repo, which tools the agent may use, and how the result is delivered. You select one with `workflow_ref` (REST/webhook) or `--workflow` (CLI); the `--pr`/`--review-pr` flags select the coding PR workflows for you. If you specify nothing, the platform resolves a default (your repo's Blueprint default, or the conservative `default/agent-v1`). Workflows replace the old `task_type` field — see [Workflows](../design/WORKFLOWS.md) for the full design and how to author your own. diff --git a/docs/src/content/docs/architecture/Api-contract.md b/docs/src/content/docs/architecture/Api-contract.md index 458773eb..9d329f4d 100644 --- a/docs/src/content/docs/architecture/Api-contract.md +++ b/docs/src/content/docs/architecture/Api-contract.md @@ -187,7 +187,7 @@ For PR tasks, `branch_name` is initially `pending:pr_resolution` and resolved to **Idempotency:** Clients may send `Idempotency-Key` (see [Conventions](#conventions)). The first successful create returns **`201 Created`** (or `202` for presigned tasks). A subsequent request with the same key and the **same authenticated user** returns **`200 OK`** with the full `TaskDetail` reflecting **current** task state, plus response header `Idempotent-Replay: true`. No duplicate task is created and the orchestrator is not invoked again for that replay. If the key is already bound to a task owned by **another** user, the API returns **`409 DUPLICATE_TASK`** without exposing that task (extremely unlikely for high-entropy keys). -**Errors:** `400 VALIDATION_ERROR` (invalid body/parameters, or task description blocked by content screening), `400 ATTACHMENT_INVALID_CONTENT` (content does not match declared MIME type or could not be sanitized), `400 ATTACHMENT_BLOCKED` (inline attachment failed content screening), `400 ATTACHMENT_INLINE_TOO_LARGE` (single inline attachment > 500 KB; total inline > 3 MB surfaces as `VALIDATION_ERROR`), `400 ATTACHMENTS_TOTAL_TOO_LARGE` (aggregate declared size > 50 MB), `401 UNAUTHORIZED`, `409 DUPLICATE_TASK` (idempotency key collision across users only), `422 REPO_NOT_ONBOARDED`, `503 SERVICE_UNAVAILABLE`, `503 ATTACHMENT_SCREENING_UNAVAILABLE`. +**Errors:** `400 VALIDATION_ERROR` (invalid body/parameters, or task description blocked by content screening), `400 ATTACHMENT_INVALID_CONTENT` (content does not match declared MIME type or could not be sanitized), `400 ATTACHMENT_BLOCKED` (inline attachment failed content screening), `400 ATTACHMENT_INLINE_TOO_LARGE` (single inline attachment > 500 KB; total inline > 3 MB surfaces as `VALIDATION_ERROR`), `400 ATTACHMENTS_TOTAL_TOO_LARGE` (aggregate declared size > 50 MB), `401 UNAUTHORIZED`, `409 DUPLICATE_TASK` (idempotency key collision across users only), `422 REPO_NOT_ONBOARDED`, `429 BUDGET_EXCEEDED` (a user or Cognito-team monthly hard-stop budget is exhausted), `503 SERVICE_UNAVAILABLE`, `503 ATTACHMENT_SCREENING_UNAVAILABLE`. > Task-description content screening (Bedrock Guardrails) that intervenes returns `400 VALIDATION_ERROR` with message "Task description was blocked by content policy." — there is no separate `GUARDRAIL_BLOCKED` code. @@ -300,9 +300,30 @@ Returns the authenticated user's tasks, newest first. Paginated. | `repo` | String | all | Filter by repository (`owner/repo`) | | `limit` | Number | 20 | Page size (1-100) | | `next_token` | String | - | Pagination token from previous response | +| `view` | String | tasks | `budget` returns the authenticated caller's personal monthly budget instead of a task page | Returns a summary subset of fields. Use `GET /v1/tasks/{task_id}` for full details. +With `view=budget`, the Cognito-authenticated caller receives only their personal estimated-spend scope: + +```json +{ + "data": { + "period": "2026-08", + "resets_at": "2026-09-01T00:00:00.000Z", + "configured": true, + "spend_usd": 25, + "monthly_limit_usd": 100, + "remaining_usd": 75, + "utilization_percent": 25, + "hard_stop": true, + "hard_stop_active": false + } +} +``` + +When no personal limit exists, `configured` is false, the limit/remaining/utilization fields are null, and `spend_usd` still reports the caller's current estimated spend. Team budgets are not returned. This read-only view backs `bgagent budget status --me`; budget mutation remains an operator-AWS-credential workflow. + **Errors:** `400 VALIDATION_ERROR`, `401 UNAUTHORIZED`. ### Cancel task @@ -533,7 +554,7 @@ HMAC verification runs in the handler (not the authorizer) because API Gateway R Tasks created via webhook record `channel_source: 'webhook'` with audit metadata (`webhook_id`, `source_ip`, `user_agent`). -**Errors:** `400 VALIDATION_ERROR` (includes task descriptions blocked by content screening), `401 UNAUTHORIZED`, `409 DUPLICATE_TASK`, `422 REPO_NOT_ONBOARDED`, `503 SERVICE_UNAVAILABLE`. +**Errors:** `400 VALIDATION_ERROR` (includes task descriptions blocked by content screening), `401 UNAUTHORIZED`, `409 DUPLICATE_TASK`, `422 REPO_NOT_ONBOARDED`, `429 BUDGET_EXCEEDED`, `503 SERVICE_UNAVAILABLE`. ## Rate limiting and throttling @@ -551,6 +572,8 @@ There is no per-user request-rate or "tasks-per-hour" limiter on task creation. - `POST /v1/tasks/{task_id}/confirm-uploads` rejects with `429 RATE_LIMIT_EXCEEDED` when the user is already at their concurrency limit. - For the orchestrator admission path (the `SUBMITTED → HYDRATING` transition), exceeding the limit does not return an HTTP error — the task is already created. The orchestrator drives the task to `FAILED` with `error_message` "User concurrency limit reached" and emits an `admission_rejected` event. +**Monthly budget admission.** Task creation checks the current UTC-month estimated spend for the authenticated user and every Cognito group captured as a team. A configured hard-stop scope at or above 100% returns `429 BUDGET_EXCEEDED` before creating a new task. Alerts-only scopes continue. Same-user idempotent replays return their existing task before this check. + ## Error codes | Code | Status | Description | @@ -582,6 +605,7 @@ There is no per-user request-rate or "tasks-per-hour" limiter on task creation. | `SCREENING_DEADLINE_EXCEEDED` | 503 | Attachment screening did not complete within the time limit (retry; already-screened attachments are skipped) | | `GITHUB_UNREACHABLE` | 502 | GitHub API unreachable during pre-flight (transient) | | `RATE_LIMIT_EXCEEDED` | 429 | Rate/concurrency gate exceeded — per-task nudge limit, the application rate limiter on approval endpoints, or the user concurrency limit on confirm-uploads | +| `BUDGET_EXCEEDED` | 429 | A configured user or Cognito-team monthly budget reached 100% with hard stop enabled | | `REQUEST_NOT_FOUND` | 404 | Cedar HITL approval request not found (also returned when the caller does not own it) | | `REQUEST_ALREADY_DECIDED` | 409 | Cedar HITL approval request was already approved or denied | | `TASK_NOT_AWAITING_APPROVAL` | 409 | Task is not in `AWAITING_APPROVAL`, so the approval decision does not apply | diff --git a/docs/src/content/docs/architecture/Cost-model.md b/docs/src/content/docs/architecture/Cost-model.md index d0044f92..d0bda8ee 100644 --- a/docs/src/content/docs/architecture/Cost-model.md +++ b/docs/src/content/docs/architecture/Cost-model.md @@ -6,7 +6,7 @@ title: Cost model This document provides an order-of-magnitude cost model for the platform. Cost efficiency is a first-class design principle (see [ARCHITECTURE.md](/sample-autonomous-cloud-coding-agents/architecture/architecture)). The model covers infrastructure baseline costs, per-task variable costs, and cost attribution guidance. -Detailed cost management (per-user budgets, cost attribution dashboards, token budget enforcement) builds on this baseline analysis and focuses on the dominant cost drivers. +Monthly user/team USD budgets and AWS-native cost attribution build on this baseline analysis and focus controls on the dominant cost drivers. ## Infrastructure baseline (monthly, idle) @@ -17,7 +17,7 @@ These costs are incurred regardless of task volume: | NAT Gateway (1×) | ~$32/month | Fixed hourly cost + data processing. Single AZ (see [COMPUTE.md - Network architecture](/sample-autonomous-cloud-coding-agents/architecture/compute)). | | VPC Interface Endpoints (7×, 2 AZs) | ~$102/month | $0.01/hr × 7 endpoints × 2 AZs × 730 hrs. | | VPC Flow Logs | ~$3/month | CloudWatch ingestion. | -| DynamoDB (on-demand, idle) | ~$0/month | Pay-per-request; 7 core tables (Tasks, Events, Nudges, Approvals, UserConcurrency, Webhooks, Repo). Integration tables add more when enabled (Slack: installation, user-mapping; Linear: project-mapping, user-mapping, workspace-registry, webhook-dedup). No cost when idle. | +| DynamoDB (on-demand, idle) | ~$0/month | Pay-per-request; 8 core tables (Tasks, Events, Nudges, Approvals, UserConcurrency, Budgets, Webhooks, Repo). Integration tables add more when enabled. No cost when idle. | | S3 Trace Artifacts bucket (idle) | ~$0/month | 7-day lifecycle auto-expires objects; no cost when no traces are stored. | | EventBridge reconciler rule | <$0.01/month | Invokes Lambda every 5 min (288/day). Rule itself is free; Lambda invocation is the cost (see below). | | Stranded task reconciler Lambda (idle) | <$0.01/month | 288 invocations/day × 256 MB × ~100 ms avg (early exit when no stranded tasks). ~$0.005/month total (requests + duration). | @@ -27,7 +27,7 @@ These costs are incurred regardless of task volume: ### Scale-to-zero characteristics -Most platform components are fully serverless and incur zero cost when idle: DynamoDB (PAY_PER_REQUEST, 7 core tables plus integration tables when Slack/Linear are enabled), Lambda, API Gateway, S3 (trace artifacts auto-expire in 7 days), SQS (fanout DLQ), ECS Fargate (cluster is free, when enabled), AgentCore Runtime (per-session), Bedrock (per-token), and Cognito (free tier). The stranded task reconciler adds <$0.01/month even when idle (288 Lambda invocations/day, early-exit). The always-on cost floor (~$140–150/month) is dominated by VPC networking infrastructure (NAT Gateway + 7 interface endpoints across 2 AZs) which is required for private subnet connectivity to AWS services and GitHub. See the [Deployment guide](/sample-autonomous-cloud-coding-agents/getting-started/deployment-guide) for the full scale-to-zero breakdown. +Most platform components are fully serverless and incur zero cost when idle: DynamoDB (PAY_PER_REQUEST, 8 core tables plus integration tables), Lambda, API Gateway, S3 (trace artifacts auto-expire in 7 days), SQS, ECS Fargate (cluster is free, when enabled), AgentCore Runtime (per-session), Bedrock (per-token), and Cognito (free tier). The stranded task reconciler adds <$0.01/month even when idle (288 Lambda invocations/day, early-exit). The always-on cost floor (~$140–150/month) is dominated by VPC networking infrastructure (NAT Gateway + 7 interface endpoints across 2 AZs) which is required for private subnet connectivity to AWS services and GitHub. See the [Deployment guide](/sample-autonomous-cloud-coding-agents/getting-started/deployment-guide) for the full scale-to-zero breakdown. ## Per-task variable costs @@ -45,7 +45,7 @@ Assuming a typical task: 1–2 hours, Claude Sonnet, ~100K input tokens, ~20K ou | Lambda fanout consumer | <$0.01 | Triggered per batch of task events (batch size 100, 5 s window). Typically 5–20 invocations per task at 256 MB. Negligible. | | Lambda nudge / trace / events | <$0.01 | On-demand per user request. Negligible unless heavily polled. | | DynamoDB reads/writes | <$0.01 | ~30–80 operations per task (task CRUD, events, nudges, counter updates). Negligible. | -| DynamoDB Streams (fanout) | <$0.01 | Stream reads charged per 25 KB. Typical task: ~20–50 event records. Negligible. | +| DynamoDB Streams (fanout and budget rollup) | <$0.01 | Stream reads charged per 25 KB. Event fanout processes progress records; budget rollup processes terminal TaskTable records. Negligible. | | S3 trace upload (if `--trace`) | <$0.01 | One PUT per task + storage (gzipped NDJSON, typically 50–500 KB, auto-expires in 7 days). | | NAT Gateway data | <$0.01 | GitHub API traffic: clone + push. Small repos: <10 MB. | | Custom step Lambdas | $0–0.05 | Only if configured. Per-invocation: ~$0.01 per step. | @@ -94,6 +94,7 @@ For multi-user deployments, cost should be attributable to individual users and - **Per-task:** Token usage and compute duration are captured in task metadata (`agent.cost_usd`, `agent.turns` - see [OBSERVABILITY.md](/sample-autonomous-cloud-coding-agents/architecture/observability)). Note: `agent.cost_usd` is the Claude Agent SDK's **client-side estimate** (a build-time price table), not authoritative billing — use it for guardrails, and AWS Cost Explorer / CUR 2.0 for the real bill (see [COST_ATTRIBUTION.md](/sample-autonomous-cloud-coding-agents/getting-started/cost-attribution)). - **Per-user:** Aggregate task costs by `user_id`. +- **Per-team:** Attribute a task to the Cognito groups captured at task creation. - **Per-repo:** Aggregate task costs by `repo`. - **Dashboard:** Cost attribution dashboards should be built from the same task-level metrics. @@ -105,14 +106,23 @@ For **AWS-native** chargeback of Bedrock spend (Cost Explorer / CUR 2.0 by `user |---|---|---| | Turn limit | `max_turns` per task | 100 | | Cost budget | `max_budget_usd` per task | None (unlimited) | +| Monthly user/team warning | Estimated terminal-task cost rollup | CloudWatch/SNS at 80% and 100% | +| Monthly user/team hard stop | Admission check at 100% | Disabled per scope unless `--hard-stop` is set | | Session timeout | Orchestrator timeout | 9 hours | | Concurrency limit | Per-user atomic counter | 3 concurrent tasks | | System concurrency | System-wide counter | Account-level AgentCore quota | +Monthly budgets use UTC calendar months and the same estimated `cost_usd` stored on terminal tasks. The TaskTable stream consumer transactionally increments the user and captured Cognito-team rollups and writes a task marker so duplicate stream delivery cannot double count. Admission checks every configured applicable scope; any scope at 100% with hard stop enabled rejects a new task. In-flight tasks continue and can overshoot because their final cost is unknown until termination. + +The 80% and 100% crossings emit claimed, per-scope `ABCA/Budgets` CloudWatch metrics. Aggregate threshold alarms notify the shared `OperationalAlerts` SNS topic; simultaneous crossings can be coalesced, while the reconciler logs retain exact scope details. Metric claims normally limit each crossing to one emission per scope/month. Emission happens before the claim is persisted so a crash cannot permanently suppress an alert; a concurrent or crash retry can therefore emit a harmless duplicate. Operators configure and inspect limits with `bgagent budget set|status`. + +Authenticated users can inspect their personal scope with `bgagent budget status --me` (`GET /v1/tasks?view=budget`). The response includes estimated spend even when no personal limit is configured. It does not expose team scopes or permit mutation; administrators remain the only actors who set user/team limits. + +The controls add one on-demand DynamoDB table with PITR, two standard CloudWatch alarms, and up to two custom metric time series. Admission, terminal rollup, and user-status requests incur usage-based DynamoDB/API Gateway/Lambda/SNS charges; no dedicated continuously running compute is added. See the operator guide's [cost-control setup and cost breakdown](/sample-autonomous-cloud-coding-agents/getting-started/cost-attribution#setting-up-cost-controls). + ## Additional guardrails -- Per-user monthly token budgets with alerts at 80% and hard stop at 100%. -- Per-team monthly cost budgets. +- Token-denominated monthly budgets (the shipped fleet budget is USD-denominated). - Cost attribution dashboard in the control panel. - Automated model downgrade (e.g. Sonnet -> Haiku) when approaching budget limits. diff --git a/docs/src/content/docs/architecture/Observability.md b/docs/src/content/docs/architecture/Observability.md index e032fd65..09745112 100644 --- a/docs/src/content/docs/architecture/Observability.md +++ b/docs/src/content/docs/architecture/Observability.md @@ -117,6 +117,7 @@ All events carry `task_id` and `user_id` for filtering. ### Cost and performance - **Token usage** - Per task, per user, per repo. Feeds cost attribution and budget enforcement. +- **Monthly estimated spend** - Terminal task `cost_usd` rolled up by user and Cognito team for the UTC month. - **Task duration** - End-to-end, cold start (clone + install), and time to first agent output. - **Error rates** - By failure type (agent crash, timeout, cancellation, orchestration failure). @@ -126,6 +127,7 @@ All events carry `task_id` and `user_id` for filtering. |--------|------|---------| | Task duration (p50, p95) | Latency | Performance baseline and regression detection | | Token usage per task | Cost | Cost attribution and budget enforcement | +| `ABCA/Budgets:BudgetThresholdCrossed` | Cost | Claimed 80%/100% monthly user/team budget crossings; retries can rarely duplicate a metric | | Cold start duration | Latency | Image optimization signal | | Active tasks (RUNNING count) | Capacity | Admission control and capacity planning | | Pending tasks (SUBMITTED count) | Capacity | Backlog depth and throughput monitoring | @@ -160,6 +162,8 @@ The CloudWatch GenAI Observability console provides additional views: per-sessio | Agent crash rate spike | Sustained high session failure rate | Check for model API errors, compute quota exhaustion, image pull failures. | | Submitted backlog depth | SUBMITTED count exceeds threshold | System at capacity. Increase concurrency limits or wait for running tasks. | | Guardrail screening failures | Sustained Bedrock Guardrail API failures | Tasks fail at submission (503) and hydration (FAILED). Recovers when Bedrock recovers. | +| Monthly budget warning | Estimated spend crosses 80% | Notify the shared `OperationalAlerts` SNS topic; inspect `OrchestrationReconciler` logs for scope details. | +| Monthly budget exceeded | Estimated spend crosses 100% | Notify the shared topic. Scopes configured with hard stop reject new tasks. | ## Code attribution @@ -171,6 +175,7 @@ Task conversations, tool calls, decisions, and outcomes are persisted with metad - **TaskEvents table** - Append-only audit log of all task events. Records carry a DynamoDB TTL and are auto-deleted after the retention period (default 90 days, configurable via `taskRetentionDays`). - **Task records** - Status, timestamps, metadata. TTL is stamped when the task reaches a terminal state (default 90 days). Active tasks are retained indefinitely. +- **Budget records** - Recurring configs persist; monthly spend rows and task deduplication markers expire after about 400 days. - **Logs** - Application and usage logs retained for 90 days in CloudWatch. Traces flow to X-Ray via CloudWatch Transaction Search. - **Model invocation logs** - Bedrock model invocation logging with 90-day retention for compliance and prompt injection investigation. diff --git a/docs/src/content/docs/customizing/Per-repo-overrides.md b/docs/src/content/docs/customizing/Per-repo-overrides.md index 3eca5867..e41d7555 100644 --- a/docs/src/content/docs/customizing/Per-repo-overrides.md +++ b/docs/src/content/docs/customizing/Per-repo-overrides.md @@ -19,7 +19,7 @@ When you specify `--max-turns` (CLI) or `max_turns` (API) on a task, your value ### Where can I set `max_budget_usd`? -Every place a cost budget can come from, and nowhere else: +Every place a per-task runtime budget can come from: | Surface | How | Scope | Notes | |---|---|---|---| @@ -27,7 +27,7 @@ Every place a cost budget can come from, and nowhere else: | Per task, REST | `max_budget_usd` in the `POST /v1/tasks` body | One task | Same `0.01`–`100` range, validated server-side | | Per repo, Blueprint | `agent.maxBudgetUsd` on the repo's `Blueprint` construct | Every task on that repo | Persisted to `RepoTable.max_budget_usd`; same `0.01`–`100` range, enforced at CDK synth so an out-of-range value cannot deploy | | Local batch runs | `MAX_BUDGET_USD` shell env | One local run | **Local `entrypoint.py` batch mode only.** The deployed AgentCore **server** mode ignores this variable — it reads the budget from the `/invocations` request body, so setting it on the runtime has no effect | -| Platform-wide default | — | — | **None exists.** Unset means unlimited (see below) | +| Platform-wide per-task default | — | — | **None exists.** Unset means unlimited (see below) | The two that apply to a deployed task resolve in this order: **per-task value wins, then the repo's Blueprint default, then no budget at all.** A mid-task Blueprint edit does not move a running task's budget. @@ -43,9 +43,9 @@ new Blueprint(this, 'MyRepo', { Run `bgagent repo show ` to see which value is in effect; the `max_budget_usd` line reads `(per-blueprint override)` when the repo pins one and `(platform default) unlimited` when it does not. -### Unlimited by default is deliberate +### Per-task budgets are unlimited by default -There is intentionally no platform-wide budget ceiling. A hard global cap would kill long-running tasks mid-change — the failure mode is a half-finished branch and no PR, which is worse than a task that costs more than expected. The intended controls are the per-repo Blueprint default above (opt in where you want a ceiling), the per-task flag, and `max_turns`. +There is intentionally no platform-wide **per-task runtime** ceiling. A hard runtime cap kills a long-running task mid-change — the failure mode is a half-finished branch and no PR. The intended runtime controls are the per-repo Blueprint default above, the per-task flag, and `max_turns`. Administrators can separately configure monthly user/team admission budgets below; those reject new work instead of interrupting work already in progress. The documented escape hatch for cost is **choosing a lighter-token model** rather than relying on a cap: @@ -54,4 +54,42 @@ The documented escape hatch for cost is **choosing a lighter-token model** rathe The model you pick must be in the platform's Bedrock IAM grant list, or the task fails at turn 0 with `AccessDenied` — the grant is the gate, so a lighter model is only reachable if it has been granted. For how the model layers resolve, the grant list, and the measured cost comparison, see [Model configuration](/sample-autonomous-cloud-coding-agents/developer-guide/model-configuration). -Note that the reported `cost_usd` is a client-side estimate, not authoritative billing — see [Cost attribution](/sample-autonomous-cloud-coding-agents/getting-started/cost-attribution). \ No newline at end of file +Note that the reported `cost_usd` is a client-side estimate, not authoritative billing — see [Cost attribution](/sample-autonomous-cloud-coding-agents/getting-started/cost-attribution). + +### Monthly user and team budgets + +Operators can set recurring monthly USD limits for a Cognito user or team. Team IDs are Cognito group names, and every configured group budget applies to each member. Standard users can inspect their personal limit but cannot change it or view other users' budgets. + +For the lowest-overhead organization-wide control, create one Cognito group such as `Everyone`, add every existing user, and make group assignment part of the new-user onboarding process. The budget command validates an existing group; it does not create the group or manage membership. + +```bash +# One shared organization pool. Every member contributes to the same limit. +bgagent budget set --team Everyone --monthly-usd 10000 --hard-stop + +# Alert at 80% and 100%, but continue admitting tasks. +bgagent budget set --user alice@example.com --monthly-usd 100 + +# Reject new tasks after the Platform group reaches 100%. +bgagent budget set --team Platform --monthly-usd 1000 --hard-stop + +# Show every configured scope, or select one scope. JSON is also available. +bgagent budget status +bgagent budget status --user alice@example.com +bgagent budget status --team Platform --output json + +# Cognito-authenticated users can inspect only their own personal scope. +bgagent budget status --me +bgagent budget status --me --output json +``` + +`budget set` and operator-scoped `budget status` use AWS credentials and discover `BudgetTableName` and `UserPoolId` from the deployed CloudFormation stack. A user may be supplied by email or Cognito username/subject; a team must already exist as a Cognito group. Running `budget set` again replaces the recurring limit and enables or disables the hard stop for that scope. + +`budget status --me` is different: it uses the caller's cached Cognito login and the authenticated REST API, requires no operator AWS credentials, and never permits mutation. It reports personal estimated spend even when no personal limit is configured. Team and organization budgets are not exposed by this view and may still block new work. + +Monthly accounting uses terminal task `cost_usd` estimates and UTC calendar months. A task is attributed to its submitting user and the user's Cognito groups captured when the task was created. Failed tasks count when they report a positive cost. Running tasks do not count until they finish, so concurrent or long-running work can overshoot a limit. + +At 80% and 100%, each scope claims a CloudWatch threshold metric for the UTC month. The metric is emitted before the claim is persisted so a crash cannot permanently suppress an alert; a concurrent or crash retry can rarely emit a harmless duplicate. The aggregate threshold alarms notify the shared `OperationalAlerts` SNS topic; simultaneous scope crossings may be coalesced into one alarm notification, so inspect the `OrchestrationReconciler` Lambda logs for exact scope and spend details. `--hard-stop` rejects new task creation at 100% with `429 BUDGET_EXCEEDED`. Existing tasks, same-user idempotent replays, and tasks already awaiting upload confirmation are not interrupted. + +Like `max_budget_usd`, monthly spend is based on the Claude Agent SDK's estimated `cost_usd`, not the AWS invoice. Use AWS Cost Explorer or CUR 2.0 for authoritative financial controls. + +Administrative overhead is one budget command per controlled scope. A single `Everyone` group needs one initial bulk membership pass and one group assignment for each new user; the recurring limit and UTC-month reset require no monthly maintenance. See [Cost attribution](/sample-autonomous-cloud-coding-agents/getting-started/cost-attribution#setting-up-cost-controls) for the setup checklist and incremental AWS cost. \ No newline at end of file diff --git a/docs/src/content/docs/developer-guide/Installation.md b/docs/src/content/docs/developer-guide/Installation.md index 6811e81d..3e31c7b2 100644 --- a/docs/src/content/docs/developer-guide/Installation.md +++ b/docs/src/content/docs/developer-guide/Installation.md @@ -175,11 +175,13 @@ After deployment, the stack emits these outputs (retrieve with `aws cloudformati | `TaskNudgesTableName` | DynamoDB table for task nudges | | `TaskApprovalsTableName` | DynamoDB table for Cedar HITL approval gates | | `UserConcurrencyTableName` | DynamoDB table for per-user concurrency | +| `BudgetTableName` | DynamoDB table for recurring user/team limits and monthly estimated spend | | `WebhookTableName` | DynamoDB table for webhook integrations | | `RepoTableName` | DynamoDB table for per-repo Blueprint config | | `CedarWasmLayerArn` | Lambda layer ARN for the Cedar WASM policy engine | | `TraceArtifactsBucketName` | S3 bucket for agent trace artifacts (7-day lifecycle) | | `GitHubTokenSecretArn` | Secrets Manager secret ARN for the GitHub PAT | +| `OperationalAlertsTopicArn` | SNS topic for DLQ and monthly-budget alarms | When the Slack or Linear integrations are enabled, the stack emits additional outputs (e.g. `Slack*` and `Linear*` secret ARNs and integration table names). diff --git a/docs/src/content/docs/developer-guide/Model-configuration.md b/docs/src/content/docs/developer-guide/Model-configuration.md index bef6469d..0646540d 100644 --- a/docs/src/content/docs/developer-guide/Model-configuration.md +++ b/docs/src/content/docs/developer-guide/Model-configuration.md @@ -79,7 +79,7 @@ Token ratio 1.169; cost ratio 1.169 — identical. **The per-token rate is uncha | Per repo, Blueprint | `agent.maxBudgetUsd` on the repo's `Blueprint` construct | Works — persisted to `RepoTable.max_budget_usd`; same `0.01`–`100` range as the CLI, validated at CDK synth so an out-of-range value cannot deploy. See [Per-repo overrides](/sample-autonomous-cloud-coding-agents/customizing/per-repo-overrides). | | Platform default | — | None by design: **unset means unlimited** | -**Unlimited-by-default is deliberate — pair it with the escape hatch.** Because no platform budget ceiling applies, the documented mitigation for cost is choosing a lighter-token model rather than relying on a cap: +**Per-task runtime budgets are unlimited by default.** Because no platform-wide per-task runtime ceiling applies, the documented mitigation for cost is choosing a lighter-token model rather than relying only on a cap: - **Per repo:** Blueprint `agent.modelId` (e.g. `us.anthropic.claude-sonnet-4-6`) — no code change, no agent redeploy - **Per task:** `model_id` in the task payload @@ -87,4 +87,6 @@ Token ratio 1.169; cost ratio 1.169 — identical. **The per-token rate is uncha The model must be in the IAM grant list (layer 1) or the task fails at turn 0 with `AccessDenied` — the grant is the gate, so a lighter model is only reachable if it is granted. -**Trust boundary on the number.** `cost_usd` is the Claude Agent SDK's **client-side estimate** from that bundled price table — not authoritative billing. It drifts when Bedrock pricing changes, when the SDK version does not recognize a model, or when discounts and commitments apply. See [Cost attribution](/sample-autonomous-cloud-coding-agents/getting-started/cost-attribution) (the warning at line 6); authoritative cost comes from AWS Cost Explorer / CUR 2.0. \ No newline at end of file +**Trust boundary on the number.** `cost_usd` is the Claude Agent SDK's **client-side estimate** from that bundled price table — not authoritative billing. It drifts when Bedrock pricing changes, when the SDK version does not recognize a model, or when discounts and commitments apply. See [Cost attribution](/sample-autonomous-cloud-coding-agents/getting-started/cost-attribution) (the warning at line 6); authoritative cost comes from AWS Cost Explorer / CUR 2.0. + +**Fleet monthly budgets are a separate admission control.** `bgagent budget set --user|--team --monthly-usd [--hard-stop]` stores a recurring user or Cognito-group limit. Terminal task costs roll up by UTC month from the TaskTable stream. The 80% and 100% crossings publish CloudWatch alarms; hard-stop scopes reject new task creation at 100% without terminating in-flight work. See [Monthly user and team budgets](/sample-autonomous-cloud-coding-agents/using/overview#monthly-user-and-team-budgets). \ No newline at end of file diff --git a/docs/src/content/docs/getting-started/Cost-attribution.md b/docs/src/content/docs/getting-started/Cost-attribution.md index d148fbc3..7db40962 100644 --- a/docs/src/content/docs/getting-started/Cost-attribution.md +++ b/docs/src/content/docs/getting-started/Cost-attribution.md @@ -15,7 +15,7 @@ ABCA gives you three independent views of cost. They answer different questions; | Meter | Granularity | Source of truth for | Where | |---|---|---|---| -| **In-app `cost_usd`** | Per task | Per-task budget guardrails (`max_budget_usd`) | Task metadata / control panel | +| **In-app `cost_usd`** | Per task; monthly rollups by user/team | Per-task and fleet admission guardrails | Task metadata / `bgagent budget` | | **CUR session-tag chargeback** | Per user / per repo, aggregated per usage-type per day | AWS-native FinOps chargeback | Cost Explorer / CUR 2.0 | | **Invocation-log metadata** | Per Bedrock call | Per-call forensics, reconciliation | `/aws/bedrock/model-invocation-logs/` | @@ -25,6 +25,58 @@ Why all three: the in-app meter is an estimate the platform computes; it does no Once deployed, each agent task makes its Bedrock calls under **session-tagged, refreshable credentials** carrying `{user_id, repo, task_id}`, and stamps the same values as **request metadata** on every call. You do **not** need to change any code. What remains is **operator setup in the AWS Billing console** — AWS does not surface tag-based cost data until you activate it, and (see the ordering note below) you can only activate *after* the platform has run tagged tasks. +## ABCA monthly budget guardrails + +ABCA can aggregate terminal task `cost_usd` by Cognito user and Cognito-group team, alert at 80%/100%, and optionally reject new tasks at 100%. Configure it with `bgagent budget set` and inspect the current UTC month with `bgagent budget status`; see [Monthly user and team budgets](/sample-autonomous-cloud-coding-agents/using/overview#monthly-user-and-team-budgets). + +This is an operational guardrail, not invoice reconciliation. It inherits every limitation of the SDK estimate, counts a task only when it reaches a terminal state, and can overshoot while tasks run concurrently. Use AWS Budgets over activated cost-allocation tags for authoritative billing alerts. + +### Setting up cost controls + +1. **Choose scopes.** Use one Cognito group such as `Everyone` for a shared organization pool. Add department/project groups or personal limits only when they represent a real independent control. Avoid whitespace and commas in budget-team group names: API Gateway can expose the Cognito group claim as a delimited string, where those characters are treated as separators. +2. **Create and populate groups.** Cognito group membership is the team mapping. `bgagent budget` validates groups but does not create them or add users. For an organization pool, bulk-add existing users once and add group assignment to the invitation/onboarding process. + + Get `UserPoolId` from `bgagent platform outputs`, then create the shared group and add each existing user in the Cognito console or AWS CLI: + + ```bash + aws cognito-idp create-group \ + --user-pool-id \ + --group-name Everyone + + aws cognito-idp admin-add-user-to-group \ + --user-pool-id \ + --username \ + --group-name Everyone + ``` + + Repeat `admin-add-user-to-group` during each new-user onboarding. A logged-in user should run `bgagent login` again after a membership change so interactive API requests carry current group claims; linked headless integrations resolve current groups server-side. +3. **Set recurring limits.** + + ```bash + # Shared organization pool with admission stopped at 100%. + bgagent budget set --team Everyone --monthly-usd 10000 --hard-stop + + # Optional personal alerts-only limit. + bgagent budget set --user alice@example.com --monthly-usd 100 + ``` + +4. **Connect notifications.** Confirm the deployment's `alertEmail` subscription or subscribe an operations destination to the exported `OperationalAlertsTopicArn`. +5. **Verify both views.** Operators run `bgagent budget status`; users run `bgagent budget status --me` after `bgagent login`. Users see only their personal scope and cannot change it. +6. **Test enforcement.** Use a non-production user/group and a small limit. Let a task finish so its estimated cost rolls up, then verify the 80%/100% notification and a `429 BUDGET_EXCEEDED` response for a hard-stop scope. + +The recurring configuration survives month boundaries; spend automatically starts from zero at the next UTC month. Changing a limit or toggling hard stop is one `budget set` command. There is currently no `budget unset` command and no automatic default-group assignment. + +### Cost of the controls + +There are two kinds of cost: + +- **Administrative effort:** one initial group-creation/bulk-membership pass, one budget command per user/team scope, and one group assignment per new user. A single `Everyone` scope has no recurring monthly configuration work. +- **AWS charges:** one on-demand DynamoDB table with point-in-time recovery, two standard CloudWatch alarms, up to two custom metric time series (`Threshold=80` and `Threshold=100`), and small usage-based DynamoDB/API Gateway/Lambda/SNS charges. The implementation reuses the existing task-list Lambda for `--me` and the existing TaskTable stream reconciler for rollups, so it adds no continuously running compute. + +At the public US East (N. Virginia) first-tier list rates verified in August 2026, the two standard alarms are about **$0.20/month** total. If both custom threshold metric series are active, their list-rate equivalent is up to about **$0.60/month**, making the CloudWatch portion approximately **$0.80/month** before free-tier allowance. The [CloudWatch free tier](https://aws.amazon.com/cloudwatch/pricing/) includes 10 custom metrics and 10 alarm metrics per month, shared with the rest of the account, so a lightly used account may pay $0 for that portion. + +DynamoDB is `PAY_PER_REQUEST`; costs scale with task volume, group count, retained deduplication markers, table storage, and PITR backup storage. Each task admission strongly reads its user/team scopes, and each terminal task transactionally writes one deduplication marker plus one spend increment per applicable scope. See [DynamoDB pricing](https://aws.amazon.com/dynamodb/pricing/on-demand/) and use the [AWS Pricing Calculator](https://calculator.aws/) for the deployment Region and expected task volume. API Gateway, Lambda, and SNS are request-based and normally negligible compared with agent inference for this low-frequency control plane. + ## FinOps checklist These steps are a one-time operator responsibility (CDK does not automate org-level billing — see [Out of scope](/sample-autonomous-cloud-coding-agents/architecture/bedrock-cost-attribution#out-of-scope-unchanged-from-issue)). diff --git a/docs/src/content/docs/getting-started/Deployment-guide.md b/docs/src/content/docs/getting-started/Deployment-guide.md index e7eccb25..36225b04 100644 --- a/docs/src/content/docs/getting-started/Deployment-guide.md +++ b/docs/src/content/docs/getting-started/Deployment-guide.md @@ -29,7 +29,7 @@ ECS Fargate is currently **opt-in** -- the `EcsAgentCluster` construct is presen | Component | Billing Model | Idle Cost | |-----------|--------------|-----------| -| DynamoDB (7 core tables; integrations add more) | PAY_PER_REQUEST | $0 | +| DynamoDB (8 core tables; integrations add more) | PAY_PER_REQUEST | $0 | | Lambda (all functions) | Per invocation | $0 | | API Gateway REST | Per request | $0 | | ECS Fargate tasks (when enabled) | Per running task | $0 (cluster is free) | @@ -55,6 +55,12 @@ The dominant idle cost is VPC networking: 7 interface endpoints across 2 AZs (~$ For the full cost model including per-task costs, see [COST_MODEL.md](/sample-autonomous-cloud-coding-agents/architecture/cost-model). +### Incremental cost-control cost + +Monthly user/team controls add one DynamoDB on-demand table with PITR and two standard CloudWatch alarms. The table, API/Lambda reads, stream rollups, and SNS notifications are usage-based; the existing list Lambda and TaskTable reconciler perform the work, so there is no additional always-running compute. + +At public US East (N. Virginia) first-tier list rates verified in August 2026, the two alarms are approximately $0.20/month total. The two possible custom threshold metric series can add up to approximately $0.60/month when active. CloudWatch's account-wide free tier includes 10 custom metrics and 10 alarm metrics, so the incremental CloudWatch charge may be $0 when that allowance is still available. DynamoDB storage/PITR and request charges depend on task volume and the number of team scopes. See [Setting up cost controls](/sample-autonomous-cloud-coding-agents/getting-started/cost-attribution#setting-up-cost-controls) for the operational overhead and pricing links. + ## AWS services inventory ### Compute @@ -88,10 +94,10 @@ For the full cost model including per-task costs, see [COST_MODEL.md](/sample-au | Service | Used By | Scales to Zero | |---------|---------|---------------| -| DynamoDB (7 core tables, PAY_PER_REQUEST) | Task state, events, nudges, concurrency, webhooks, repo config, approvals. Enabling the Slack integration adds 2 tables (installation, user-mapping) and Linear adds 4 (project-mapping, user-mapping, workspace-registry, webhook-dedup) | Yes | -| DynamoDB Streams | TaskEventsTable → FanOut Consumer Lambda | Yes | +| DynamoDB (8 core tables, PAY_PER_REQUEST) | Task state, events, nudges, concurrency, monthly budgets, webhooks, repo config, approvals. Enabling integrations adds their mapping, registry, and deduplication tables. | Yes | +| DynamoDB Streams | TaskEventsTable → FanOut Consumer; TaskTable → combined orchestration/budget reconciler | Yes | | S3 | CDK asset bucket, ECR image layers, FUSE session storage, trace artifacts (7-day lifecycle) | Minimal | -| SQS (DLQ) | FanOut Consumer dead-letter queue | Yes | +| SQS (DLQ) | FanOut, approval metrics, screenshot, and orchestration/budget reconciler dead-letter queues | Yes | | Secrets Manager | GitHub PAT, webhook HMAC secrets | **No** (~$0.40/secret/mo) | ### API / Auth @@ -114,7 +120,7 @@ For the full cost model including per-task costs, see [COST_MODEL.md](/sample-au |---------|---------|---------------| | CloudWatch Logs (multiple log groups) | Application, usage, model invocation, VPC flow, DNS query logs | **No** (storage) | | CloudWatch Dashboard | Operational metrics visualization | **No** (~$3/mo) | -| CloudWatch Alarms | Orchestrator error alerting | **No** (~$0.10/alarm) | +| CloudWatch Alarms | Operational failures plus 80%/100% monthly-budget thresholds | **No** (~$0.10/alarm) | | X-Ray | AgentCore Runtime tracing | Yes | ### Infrastructure / Deployment diff --git a/yarn.lock b/yarn.lock index 5a888aa6..47f1e5ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -460,6 +460,20 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/client-cognito-identity-provider@^3.1078.0": + version "3.1115.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-cognito-identity-provider/-/client-cognito-identity-provider-3.1115.0.tgz#d742aa50c9ae2d2e7fe7253c45ab143b5e0fbe83" + integrity sha512-HPScZ0AtuZ55vqIEk+t07MqciuQaNaxt4icgP5M47ErRJQnfYBr7UBTk2ozlwRrf9+36CHYpFVvMy0deO73Udw== + dependencies: + "@aws-sdk/core" "^3.977.8" + "@aws-sdk/credential-provider-node" "^3.972.80" + "@aws-sdk/types" "^3.974.4" + "@smithy/core" "^3.31.1" + "@smithy/fetch-http-handler" "^5.6.13" + "@smithy/node-http-handler" "^4.9.13" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/client-dynamodb@3.1078.0": version "3.1078.0" resolved "https://registry.yarnpkg.com/@aws-sdk/client-dynamodb/-/client-dynamodb-3.1078.0.tgz#b4beeed90a9dfb7f818c21443bc0bcaecdc37510" @@ -607,6 +621,20 @@ bowser "^2.11.0" tslib "^2.6.2" +"@aws-sdk/core@^3.977.8": + version "3.977.8" + resolved "https://registry.yarnpkg.com/@aws-sdk/core/-/core-3.977.8.tgz#b2860d6d6bd4b147dbf906c5e3c8155337742657" + integrity sha512-7+Kcrkvrk9lM/m7jRhHpT4jCdvzGHsuaSRbF8TdzzkY1mRzp/Ogwf9c7H29k4gGhey0BBWhCWr16+t0J61gwmg== + dependencies: + "@aws-sdk/types" "^3.974.4" + "@aws-sdk/xml-builder" "^3.972.39" + "@aws/lambda-invoke-store" "^0.3.0" + "@smithy/core" "^3.31.1" + "@smithy/signature-v4" "^5.6.12" + "@smithy/types" "^4.16.1" + bowser "^2.11.0" + tslib "^2.6.2" + "@aws-sdk/credential-provider-env@^3.972.55": version "3.972.55" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.55.tgz#1129acf8860db362a30a02a531387fd399448268" @@ -629,6 +657,17 @@ "@smithy/types" "^4.16.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-env@^3.972.69": + version "3.972.69" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.69.tgz#9ef8e8e6abd048ae4c9bd8ea71fe8e79bcb48204" + integrity sha512-AreCFzcB4kH2HF9031Ot0jSJr3KXvRg6e8uDeub20JEVdZU3Bv0sTq1plc7VsT3KiqutlzH7l0j50UcCWHUioA== + dependencies: + "@aws-sdk/core" "^3.977.8" + "@aws-sdk/types" "^3.974.4" + "@smithy/core" "^3.31.1" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/credential-provider-http@^3.972.57": version "3.972.57" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.57.tgz#4f73bb2f0e03525ab11e001ac18c39cb120dd14c" @@ -655,6 +694,19 @@ "@smithy/types" "^4.16.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-http@^3.972.71": + version "3.972.71" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.71.tgz#94f74f2e145df28b0b15849330b99f9c83c3f978" + integrity sha512-A8ObcqVmDMnk4F9NozZ7JwmUu9Q4xyBJkmyq1C5U+wNM9ht9J7+EuuyabsLWXZnOoTqFaJuYBYTKf5CTipkEjA== + dependencies: + "@aws-sdk/core" "^3.977.8" + "@aws-sdk/types" "^3.974.4" + "@smithy/core" "^3.31.1" + "@smithy/fetch-http-handler" "^5.6.13" + "@smithy/node-http-handler" "^4.9.13" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/credential-provider-ini@^3.972.62": version "3.972.62" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.62.tgz#a2ca7fc7a1899c0a4a38e501baa666cf1449f2e9" @@ -693,6 +745,25 @@ "@smithy/types" "^4.16.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-ini@^3.973.14": + version "3.973.14" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.14.tgz#650d856227a36fbfb954f8b93b89f747e8430dd4" + integrity sha512-7c+Wti2LsERNWMfm7ySz3/6RPopFW3Nmn7s63Xpcq6R/tRuY5hpvkHA2xVgi5ukJbvok9l0IDtVEvqTtg+X7dw== + dependencies: + "@aws-sdk/core" "^3.977.8" + "@aws-sdk/credential-provider-env" "^3.972.69" + "@aws-sdk/credential-provider-http" "^3.972.71" + "@aws-sdk/credential-provider-login" "^3.972.76" + "@aws-sdk/credential-provider-process" "^3.972.69" + "@aws-sdk/credential-provider-sso" "^3.973.13" + "@aws-sdk/credential-provider-web-identity" "^3.972.75" + "@aws-sdk/nested-clients" "^3.997.43" + "@aws-sdk/types" "^3.974.4" + "@smithy/core" "^3.31.1" + "@smithy/credential-provider-imds" "^4.4.16" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/credential-provider-login@^3.972.61": version "3.972.61" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.61.tgz#f5ba696bae9af3505ae5f3e2a70d7e216d0d37f6" @@ -717,6 +788,18 @@ "@smithy/types" "^4.16.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-login@^3.972.76": + version "3.972.76" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.76.tgz#83dc4012f8d218c6867ecea389103c4ab78f4f7f" + integrity sha512-LVixwOnEJfrrfKHeZjBA8pIMTZjNDq8ak8VpcoWUuCJDrSnBNU8POJksULMgvN089P0MXtQYH2Zs627/MK1K0g== + dependencies: + "@aws-sdk/core" "^3.977.8" + "@aws-sdk/nested-clients" "^3.997.43" + "@aws-sdk/types" "^3.974.4" + "@smithy/core" "^3.31.1" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/credential-provider-node@^3.972.61", "@aws-sdk/credential-provider-node@^3.972.64": version "3.972.64" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.64.tgz#282456c24bad616faefe2a6c68701913f8289c10" @@ -751,6 +834,23 @@ "@smithy/types" "^4.16.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-node@^3.972.80": + version "3.972.80" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.80.tgz#1a3db0a35091468c5cd32d869f031fc6a2420d8e" + integrity sha512-bE2qh8ww4iClO1jHsBXdOE8FUgzDbdxbyorNjSCoPSkQd51k3jODItuPZfuwcLHZqDXsH+bI4AMHhqtuyR7mSg== + dependencies: + "@aws-sdk/credential-provider-env" "^3.972.69" + "@aws-sdk/credential-provider-http" "^3.972.71" + "@aws-sdk/credential-provider-ini" "^3.973.14" + "@aws-sdk/credential-provider-process" "^3.972.69" + "@aws-sdk/credential-provider-sso" "^3.973.13" + "@aws-sdk/credential-provider-web-identity" "^3.972.75" + "@aws-sdk/types" "^3.974.4" + "@smithy/core" "^3.31.1" + "@smithy/credential-provider-imds" "^4.4.16" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/credential-provider-process@^3.972.55": version "3.972.55" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.55.tgz#bebd30d0065ca34f64e14a870f54a09f23cf11e2" @@ -773,6 +873,17 @@ "@smithy/types" "^4.16.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-process@^3.972.69": + version "3.972.69" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.69.tgz#d97e64a37d25662913f6314562781a9c9e95516b" + integrity sha512-9kpTNdZTrcqXTfhxM7fgl9Z68ek3Fu5oe3Yf+A/pJGibEqpgZxz2tSY7SinmyCIU2PJ+ygY4FPoBBnLpocMtrQ== + dependencies: + "@aws-sdk/core" "^3.977.8" + "@aws-sdk/types" "^3.974.4" + "@smithy/core" "^3.31.1" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/credential-provider-sso@^3.972.61": version "3.972.61" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.61.tgz#8c15846c65d2f03bfca3347626e1fcd1a0f40726" @@ -799,6 +910,19 @@ "@smithy/types" "^4.16.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-sso@^3.973.13": + version "3.973.13" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.13.tgz#005f76bf069e78089c79ec2c1b9d50a07eddabb3" + integrity sha512-Oc81qauMPzUoTnAS2YKpNwY6sY/LUyQTEeaf6yP197WMxkEBQfcKLR1MFpD7+pNTubXnfkH6gwpji+Gc7iyD2Q== + dependencies: + "@aws-sdk/core" "^3.977.8" + "@aws-sdk/nested-clients" "^3.997.43" + "@aws-sdk/token-providers" "3.1111.0" + "@aws-sdk/types" "^3.974.4" + "@smithy/core" "^3.31.1" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/credential-provider-web-identity@^3.972.61": version "3.972.61" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.61.tgz#1938cc425e6156acd673484f10b437c5c4c85158" @@ -823,6 +947,18 @@ "@smithy/types" "^4.16.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-web-identity@^3.972.75": + version "3.972.75" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.75.tgz#c0a5980c55301aabfbf5f9938f2ba78444ee026e" + integrity sha512-YPN6uoGDgjjjeVFZrcOeCJqmB6zpXoeeNgIjqe+DexJaWqdjVfCCe+VAZwli9Z2h8KhFW8oxkO39emQ1tyz/Mw== + dependencies: + "@aws-sdk/core" "^3.977.8" + "@aws-sdk/nested-clients" "^3.997.43" + "@aws-sdk/types" "^3.974.4" + "@smithy/core" "^3.31.1" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/dynamodb-codec@^3.973.26", "@aws-sdk/dynamodb-codec@^3.973.29": version "3.973.29" resolved "https://registry.yarnpkg.com/@aws-sdk/dynamodb-codec/-/dynamodb-codec-3.973.29.tgz#ea72996b2e1f2c687254c69c357fa4278e42f1da" @@ -947,6 +1083,20 @@ "@smithy/types" "^4.16.1" tslib "^2.6.2" +"@aws-sdk/nested-clients@^3.997.43": + version "3.997.43" + resolved "https://registry.yarnpkg.com/@aws-sdk/nested-clients/-/nested-clients-3.997.43.tgz#6fa60e5fad1e97267b2773f0750d37511f9d698a" + integrity sha512-bit+VpqWNyi3wHxFoTsTliNXimCSL2r2OeDTm7ZrG+YsTZ2D7ofDJ6r/t9PVBn80i6/v0X2h9Tgw6QP2MAKfPw== + dependencies: + "@aws-sdk/core" "^3.977.8" + "@aws-sdk/signature-v4-multi-region" "^3.996.45" + "@aws-sdk/types" "^3.974.4" + "@smithy/core" "^3.31.1" + "@smithy/fetch-http-handler" "^5.6.13" + "@smithy/node-http-handler" "^4.9.13" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/s3-presigned-post@^3.1078.0": version "3.1081.0" resolved "https://registry.yarnpkg.com/@aws-sdk/s3-presigned-post/-/s3-presigned-post-3.1081.0.tgz#ed27961af8e772e23745e7ff34f34083bc69c291" @@ -992,6 +1142,16 @@ "@smithy/types" "^4.16.1" tslib "^2.6.2" +"@aws-sdk/signature-v4-multi-region@^3.996.45": + version "3.996.45" + resolved "https://registry.yarnpkg.com/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.45.tgz#7d8d1d2c769327b9b5c624ee577ea1248826af7a" + integrity sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA== + dependencies: + "@aws-sdk/types" "^3.974.4" + "@smithy/signature-v4" "^5.6.12" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/token-providers@3.1078.0": version "3.1078.0" resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.1078.0.tgz#554be2f2c42f21191f31aead718bcedf2047926d" @@ -1028,6 +1188,18 @@ "@smithy/types" "^4.16.1" tslib "^2.6.2" +"@aws-sdk/token-providers@3.1111.0": + version "3.1111.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.1111.0.tgz#0a91fe03ab928b32032d0d30c8a191eccb91b3a5" + integrity sha512-JfljgoVtl+s3Qy21n9a7Z48uCQaOXcN74KJ3TEQfPoB293GrXFSt6HSQJF1sTZ8c/5QedEvd3NjJQMO4u9qa5A== + dependencies: + "@aws-sdk/core" "^3.977.8" + "@aws-sdk/nested-clients" "^3.997.43" + "@aws-sdk/types" "^3.974.4" + "@smithy/core" "^3.31.1" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/types@^3.222.0", "@aws-sdk/types@^3.973.15": version "3.973.15" resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.973.15.tgz#98a4860bed33c32c7088924d0ab52f9eabbdf7c3" @@ -1044,6 +1216,14 @@ "@smithy/types" "^4.16.1" tslib "^2.6.2" +"@aws-sdk/types@^3.974.4": + version "3.974.4" + resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.974.4.tgz#c68582caa8568de90d106e4717f1559164b6949a" + integrity sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A== + dependencies: + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/util-dynamodb@^3.996.5": version "3.996.5" resolved "https://registry.yarnpkg.com/@aws-sdk/util-dynamodb/-/util-dynamodb-3.996.5.tgz#2ad647e1532c20c76570a878e49f058a8d21e012" @@ -1067,6 +1247,14 @@ "@smithy/types" "^4.16.1" tslib "^2.6.2" +"@aws-sdk/xml-builder@^3.972.39": + version "3.972.39" + resolved "https://registry.yarnpkg.com/@aws-sdk/xml-builder/-/xml-builder-3.972.39.tgz#d55108a214b60f1bf88429dc952cb4e9ba9e0632" + integrity sha512-FTti8DS5MMWXNUWiRwXAJeYS+0GHHiMy0+7XOhcwk63ILHmfS2UFy2z/HNpZCSOJJ3P3dnWY6hfYNW3DF0nXUA== + dependencies: + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws/durable-execution-sdk-js@^2.0.0": version "2.1.0" resolved "https://registry.yarnpkg.com/@aws/durable-execution-sdk-js/-/durable-execution-sdk-js-2.1.0.tgz#a020f8f3eae0fd3b577ad55397e454241a9e3029"