Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
67 changes: 67 additions & 0 deletions cdk/src/constructs/budget-alerts.ts
Original file line number Diff line number Diff line change
@@ -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).',
});
}
}
80 changes: 80 additions & 0 deletions cdk/src/constructs/budget-table.ts
Original file line number Diff line number Diff line change
@@ -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#<cognito-sub>`` or ``TEAM#<cognito-group>``
* - ``period = CONFIG`` for the recurring limit
* - ``period = YYYY-MM`` for one month's spend
* - ``scope_key = TASK#<task-id>, 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,
});
}
}
14 changes: 14 additions & 0 deletions cdk/src/constructs/jira-integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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', {
Expand Down Expand Up @@ -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'],
Expand Down
14 changes: 14 additions & 0 deletions cdk/src/constructs/linear-integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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', {
Expand Down Expand Up @@ -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'],
Expand Down
18 changes: 12 additions & 6 deletions cdk/src/constructs/orchestration-reconciler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
}),
Expand Down Expand Up @@ -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 +
Expand Down
14 changes: 14 additions & 0 deletions cdk/src/constructs/slack-integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'],
Expand Down
28 changes: 27 additions & 1 deletion cdk/src/constructs/task-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

}

/**
Expand All @@ -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)
Expand Down Expand Up @@ -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'),
Expand All @@ -629,12 +640,16 @@ export class TaskApi extends Construct {
bundling: commonBundling,
});

const listTasksEnv: Record<string, string> = { ...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,
});

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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({
Expand Down
Loading
Loading