Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
2b57211
feat(screenshot): preview-deploy screenshot pipeline (no stack wiring…
isadeks May 20, 2026
ca5ab14
feat(screenshot): GitHubScreenshotIntegration construct + stack wiring
isadeks May 20, 2026
8138e86
fix(screenshot): suppress AwsSolutions-S2 on the public-read screensh…
isadeks May 20, 2026
235710e
fix(screenshot): private S3 bucket + CloudFront distribution
isadeks May 20, 2026
36e8d14
fix(waf): exempt /v1/github/webhook from CRS like /v1/linear/webhook
isadeks May 21, 2026
bb5e5d1
fix(screenshot): read environment_url from deployment_status, not dep…
isadeks May 21, 2026
8b7adf4
fix(agentcore-browser): use ws package for SigV4-signed WebSocket han…
isadeks May 21, 2026
043cb84
fix(agentcore-browser): SigV4-presign WSS URL instead of signing headers
isadeks May 21, 2026
a2466cb
fix(iam): grant bedrock-agentcore:* to the screenshot processor
isadeks May 21, 2026
7bd6412
feat(screenshot): also post screenshot comment to linked Linear issue
isadeks May 21, 2026
e7d3a19
fix(screenshot): retry PR lookup to handle deploy-before-PR race
isadeks May 21, 2026
b81eee6
fix(linear): silent label gate + default to 'abca' to stop unlabeled-…
May 21, 2026
bce3aa6
docs(screenshots): add the screenshot pipeline guide
isadeks May 21, 2026
62829a0
feat(github): bgagent github webhook-info + set-webhook-secret
isadeks May 27, 2026
734c124
docs/code(screenshots): de-Vercel-ize the screenshot pipeline
isadeks May 27, 2026
1ce013d
docs(screenshots): drop redundant Step 3 + condescending hardening pr…
isadeks May 27, 2026
99e2b06
docs(screenshots): drop 'followup' framing — describe gaps as current…
isadeks May 27, 2026
a444266
docs(screenshots): de-Linear-ize — Linear is opt-in, not required
isadeks May 27, 2026
6e57515
feat(screenshot): hide URL behind 'preview link' label in comments
isadeks May 28, 2026
7d994b8
docs(screenshots): add USER_GUIDE / COST_MODEL / ROADMAP coverage
isadeks Jun 1, 2026
f9824f4
docs(linear): clarify teammate-onboarding handshake
isadeks Jun 2, 2026
d4c3aa0
fix(github-cli): de-Vercel-ize webhook-info / set-webhook-secret strings
isadeks Jun 2, 2026
dac4e31
fix(github-cli): replace template literal with single quotes (eslint …
isadeks Jun 2, 2026
3ba880d
feat(notifications): platform-side Linear final-status comment with c…
isadeks Jun 2, 2026
0957f0e
Merge branch 'main' into feat/239-linear-fanout-dispatcher
krokoko Jun 2, 2026
3280d2c
fix(linear-dispatcher): krokoko PR-243 review nits + test coverage
isadeks Jun 4, 2026
b84ce1e
fix(linear): drop redundant PR url + agent step-3 comment after first…
isadeks Jun 5, 2026
adff287
Merge branch 'main' into feat/239-linear-fanout-dispatcher
isadeks Jun 5, 2026
4480fa6
Merge branch 'main' into feat/239-linear-fanout-dispatcher
isadeks Jun 8, 2026
d8d9479
fix(screenshot): krokoko PR-243 review — scope IAM + cosmetic Vercel …
isadeks Jun 8, 2026
a445ba7
Merge remote-tracking branch 'upstream/main' into feat/239-linear-fan…
isadeks Jun 8, 2026
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
15 changes: 9 additions & 6 deletions agent/src/prompt_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,15 @@ def _channel_prompt_addendum(config: TaskConfig) -> str:
"transition the issue state to `In Review` (fall back to `In Progress` "
"if that state doesn't exist). If neither exists, skip the state "
"transition — the PR comment alone is enough. Do not invent state "
"names or loop on `list_issue_statuses`.\n"
"3. **On completion or failure** — call `mcp__linear-server__save_comment` "
"with the final status (succeeded / failed + short reason).\n\n"
"Keep comments concise. Do not mirror the full agent transcript back to "
"Linear. Even small tasks must post all three updates — users rely on "
"them to track progress."
"names or loop on `list_issue_statuses`.\n\n"
"**Do NOT post a final 'task completed' or 'task failed' comment.** "
"The platform fan-out plane (issue #239) posts a structured "
"✅/⚠️/❌ summary on terminal events with cost / turns / duration / "
"PR-link metrics that you don't have visibility into. A redundant "
"agent-side completion comment would just stack two near-identical "
"comments on the issue.\n\n"
"Keep the start + PR-opened comments concise. Do not mirror the full "
"agent transcript back to Linear."
)


Expand Down
10 changes: 8 additions & 2 deletions cdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,29 @@
"@aws-cdk/aws-bedrock-agentcore-alpha": "2.257.0-alpha.0",
"@aws-cdk/aws-bedrock-alpha": "2.257.0-alpha.0",
"@aws-cdk/mixins-preview": "2.257.0-alpha.0",
"@aws-crypto/sha256-js": "^5.2.0",
"@aws-sdk/client-bedrock-agentcore": "^3.1046.0",
"@aws-sdk/client-bedrock-runtime": "^3.1021.0",
"@aws-sdk/client-dynamodb": "^3.1021.0",
"@aws-sdk/client-ecs": "^3.1021.0",
"@aws-sdk/client-lambda": "^3.1021.0",
"@aws-sdk/client-s3": "^3.1021.0",
"@aws-sdk/client-secrets-manager": "^3.1021.0",
"@aws-sdk/credential-provider-node": "^3.972.29",
"@aws-sdk/lib-dynamodb": "^3.1021.0",
"@aws-sdk/s3-presigned-post": "^3.1021.0",
"@aws-sdk/s3-request-presigner": "^3.1021.0",
"@aws/durable-execution-sdk-js": "^1.1.0",
"@cedar-policy/cedar-wasm": "4.8.2",
"@smithy/protocol-http": "^5.3.12",
"@smithy/signature-v4": "^5.3.14",
"aws-cdk-lib": "^2.257.0",
"cdk-nag": "^2.38.2",
"constructs": "^10.3.0",
"pdf-parse": "^1.1.1",
"js-yaml": "^4.1.1",
"ulid": "^3.0.2"
"pdf-parse": "^1.1.1",
"ulid": "^3.0.2",
"ws": "^8.18.0"
},
"devDependencies": {
"@cdklabs/eslint-plugin": "^2",
Expand All @@ -44,6 +49,7 @@
"@types/js-yaml": "^4.0.9",
"@types/node": "^20",
"@types/pdf-parse": "^1.1.4",
"@types/ws": "^8.5.13",
"@typescript-eslint/eslint-plugin": "^8",
"@typescript-eslint/parser": "^8",
"aws-cdk": "^2",
Expand Down
41 changes: 41 additions & 0 deletions cdk/src/constructs/fanout-consumer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,23 @@ export interface FanOutConsumerProps {
*/
readonly slackSecretArnPattern?: string;

/**
* LinearWorkspaceRegistryTable — the Linear dispatcher reads this to
* resolve per-workspace OAuth tokens at comment-post time. Optional:
* when omitted, the dispatcher logs and skips so a deployment without
* Linear onboarding doesn't accumulate dangling IAM grants.
*/
readonly linearWorkspaceRegistryTable?: dynamodb.ITable;

/**
* Secrets Manager ARN-prefix pattern for per-workspace Linear OAuth
* bundles. Mirrors ``slackSecretArnPattern`` shape — typically
* ``bgagent-linear-oauth-*``. Required when ``linearWorkspaceRegistryTable``
* is set; without it the dispatcher would resolve the registry row but
* fail at the SM GetSecretValue call.
*/
readonly linearOauthSecretArnPattern?: string;

/**
* Maximum batch size delivered to the Lambda per invocation.
*
Expand Down Expand Up @@ -173,6 +190,30 @@ export class FanOutConsumer extends Construct {
}));
}

// Linear dispatcher plumbing. Same guarded shape as Slack/GitHub:
// a deployment without Linear onboarding gets no IAM grants and
// the dispatcher logs-and-skips on missing env. The registry table
// tells us per-workspace OAuth-secret ARN; the secret holds the
// access token that ``postIssueComment`` uses to drive
// ``commentCreate`` GraphQL.
if (props.linearWorkspaceRegistryTable) {
props.linearWorkspaceRegistryTable.grantReadData(this.fn);
this.fn.addEnvironment(
'LINEAR_WORKSPACE_REGISTRY_TABLE_NAME',
props.linearWorkspaceRegistryTable.tableName,
);
}
if (props.linearOauthSecretArnPattern) {
this.fn.addToRolePolicy(new iam.PolicyStatement({
// GetSecretValue + PutSecretValue: the resolver may rotate the
// OAuth token (writes the refreshed bundle back to SM) — same
// grants the LinearIntegration's webhook-processor Lambda holds
// for the same reason.
actions: ['secretsmanager:GetSecretValue', 'secretsmanager:PutSecretValue'],
resources: [props.linearOauthSecretArnPattern],
}));
}

this.fn.addEventSource(new DynamoEventSource(props.taskEventsTable, {
startingPosition: StartingPosition.LATEST,
batchSize: props.batchSize ?? 100,
Expand Down
275 changes: 275 additions & 0 deletions cdk/src/constructs/github-screenshot-integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
/**
* MIT No Attribution
*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/

import * as path from 'path';
import { ArnFormat, Duration, RemovalPolicy, Stack } from 'aws-cdk-lib';
import * as apigw from 'aws-cdk-lib/aws-apigateway';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as iam from 'aws-cdk-lib/aws-iam';
import { Architecture, Runtime } from 'aws-cdk-lib/aws-lambda';
import * as lambda from 'aws-cdk-lib/aws-lambda-nodejs';
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
import { NagSuppressions } from 'cdk-nag';
import { Construct } from 'constructs';
import { ScreenshotBucket } from './screenshot-bucket';

/**
* Properties for GitHubScreenshotIntegration construct.
*/
export interface GitHubScreenshotIntegrationProps {
/** The existing REST API to add the GitHub webhook route to. */
readonly api: apigw.RestApi;

/**
* Existing GitHub PAT secret. The processor reuses ABCA's main GitHub
* token to (a) look up which PR a deploy SHA belongs to via the
* Commits API, and (b) post the screenshot comment on that PR.
* No new GitHub credential is provisioned by this construct.
*/
readonly githubTokenSecret: secretsmanager.ISecret;

/**
* Optional — when provided, the processor also tries to post the
* screenshot to a linked Linear issue. Resolved from the GitHub PR
* title/body via a Linear-identifier regex (e.g. `ABCA-42`), then
* looked up across all `status='active'` workspaces in the registry
* via Linear's `issueVcsBranchSearch` GraphQL.
*/
readonly linearWorkspaceRegistryTable?: dynamodb.ITable;

/**
* Removal policy for the dedup table + screenshot bucket. Defaults
* to DESTROY so dev stacks don't accumulate orphans on `cdk destroy`.
*/
readonly removalPolicy?: RemovalPolicy;

/**
* Override for the GitHub deployment `environment` value we
* screenshot. Different providers use different conventions:
* `Preview` (Vercel's per-PR label, the default), branch names
* (Amplify Hosting), `Deploy Preview <PR#>` (Netlify), or whatever
* your GitHub Actions workflow passes. Set this when your provider
* uses a different name and you want per-PR-only screenshots.
* @default 'Preview'
*/
readonly screenshotTargetEnvironment?: string;
}

/**
* CDK construct that adds the GitHub-deployment-status → screenshot →
* PR-comment pipeline.
*
* Topology mirrors `LinearIntegration`:
* - Receiver Lambda (HMAC-verifies, dedups, async-invokes processor)
* - Async processor Lambda (drives AgentCore Browser, uploads PNG,
* posts the PR comment)
* - Dedup DynamoDB table (1h TTL — covers GitHub's 5-attempt retry
* window with slack)
* - Webhook signing-secret (Secrets Manager placeholder; populated
* manually when the operator pastes GitHub's value into the secret)
* - Public-read screenshot S3 bucket
* - API Gateway route `POST /v1/github/webhook`
*
* Inbound-only adapter — there's no outbound polling or stream
* consumer, just the webhook → screenshot → comment fan-out.
*/
export class GitHubScreenshotIntegration extends Construct {
/** Public-read bucket hosting the screenshot PNGs. */
public readonly screenshotBucket: ScreenshotBucket;

/**
* GitHub webhook signing secret — placeholder. The operator pastes
* GitHub's signing-secret value here after configuring the webhook
* in the demo repo's settings; the secret is otherwise empty.
*/
public readonly webhookSecret: secretsmanager.Secret;

/** Webhook dedup table (composite key = `repo#deployment_id#status_id`). */
public readonly webhookDedupTable: dynamodb.Table;

/** Webhook receiver Lambda (HMAC verifier + dispatcher). */
public readonly webhookFn: lambda.NodejsFunction;

/** Async processor Lambda (browser + S3 + PR comment). */
public readonly webhookProcessorFn: lambda.NodejsFunction;

constructor(scope: Construct, id: string, props: GitHubScreenshotIntegrationProps) {
super(scope, id);

const removalPolicy = props.removalPolicy ?? RemovalPolicy.DESTROY;

// --- Screenshot bucket (public-read on `screenshots/*`) ---
this.screenshotBucket = new ScreenshotBucket(this, 'ScreenshotBucket', {
removalPolicy,
});

// --- Webhook signing secret (operator-populated placeholder) ---
this.webhookSecret = new secretsmanager.Secret(this, 'WebhookSecret', {
description: 'GitHub deployment-status webhook signing secret — populate manually after configuring the GitHub webhook',
removalPolicy,
});

// --- Dedup table ---
this.webhookDedupTable = new dynamodb.Table(this, 'WebhookDedupTable', {
partitionKey: { name: 'dedup_key', type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
timeToLiveAttribute: 'ttl',
pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
removalPolicy,
});

const handlersDir = path.join(__dirname, '..', 'handlers');
const commonBundling: lambda.BundlingOptions = {
externalModules: ['@aws-sdk/*'],
};

// --- Async processor (browser + S3 + comment) ---
// Timeout budget: 60s screenshot + 5s navigate slack + 30s slack for
// the GitHub PR-lookup + comment + S3 PUT + JSON encode = 95s. Round
// to 120 for headroom on cold-start CDP handshake.
this.webhookProcessorFn = new lambda.NodejsFunction(this, 'WebhookProcessorFn', {
entry: path.join(handlersDir, 'github-webhook-processor.ts'),
handler: 'handler',
runtime: Runtime.NODEJS_24_X,
architecture: Architecture.ARM_64,
timeout: Duration.seconds(120),
memorySize: 512,
environment: {
SCREENSHOT_BUCKET_NAME: this.screenshotBucket.bucket.bucketName,
SCREENSHOT_PUBLIC_HOST: this.screenshotBucket.distribution.domainName,
GITHUB_TOKEN_SECRET_ARN: props.githubTokenSecret.secretArn,
...(props.linearWorkspaceRegistryTable && {
LINEAR_WORKSPACE_REGISTRY_TABLE_NAME: props.linearWorkspaceRegistryTable.tableName,
}),
},
bundling: commonBundling,
});

this.screenshotBucket.bucket.grantPut(this.webhookProcessorFn);
props.githubTokenSecret.grantRead(this.webhookProcessorFn);

// Optional Linear feedback path. Wired only when a registry table
// is provided. The processor scans the registry for active
// workspaces, then per-workspace looks up the OAuth token from
// Secrets Manager (`bgagent-linear-oauth-*` prefix, written by
// `bgagent linear setup`).
if (props.linearWorkspaceRegistryTable) {
props.linearWorkspaceRegistryTable.grantReadData(this.webhookProcessorFn);
this.webhookProcessorFn.addToRolePolicy(new iam.PolicyStatement({
actions: ['secretsmanager:GetSecretValue', 'secretsmanager:PutSecretValue'],
resources: [
Stack.of(this).formatArn({
service: 'secretsmanager',
resource: 'secret',
arnFormat: ArnFormat.COLON_RESOURCE_NAME,
resourceName: 'bgagent-linear-oauth-*',
}),
],
}));
}

// AgentCore Browser session lifecycle + automation-stream connect.
// The data-plane API doesn't support per-resource ARNs (sessions
// are ephemeral), so resource wildcards are required — annotated
// with a cdk-nag suppression below. Action set is narrowed to the
// three calls the handler actually makes:
// - StartBrowserSession (REST, creates session)
// - StopBrowserSession (REST, finally-block cleanup)
// - ConnectBrowserAutomationStream (SigV4-presigned WSS dial)
this.webhookProcessorFn.addToRolePolicy(new iam.PolicyStatement({
actions: [
'bedrock-agentcore:StartBrowserSession',
'bedrock-agentcore:StopBrowserSession',
'bedrock-agentcore:ConnectBrowserAutomationStream',
],
resources: ['*'],
}));

// --- Webhook receiver (verify, dedup, dispatch) ---
this.webhookFn = new lambda.NodejsFunction(this, 'WebhookFn', {
entry: path.join(handlersDir, 'github-webhook.ts'),
handler: 'handler',
runtime: Runtime.NODEJS_24_X,
architecture: Architecture.ARM_64,
timeout: Duration.seconds(10),
environment: {
GITHUB_WEBHOOK_SECRET_ARN: this.webhookSecret.secretArn,
GITHUB_WEBHOOK_DEDUP_TABLE_NAME: this.webhookDedupTable.tableName,
GITHUB_WEBHOOK_PROCESSOR_FUNCTION_NAME: this.webhookProcessorFn.functionName,
...(props.screenshotTargetEnvironment && {
SCREENSHOT_TARGET_ENVIRONMENT: props.screenshotTargetEnvironment,
}),
},
bundling: commonBundling,
});

this.webhookSecret.grantRead(this.webhookFn);
this.webhookDedupTable.grantReadWriteData(this.webhookFn);
this.webhookProcessorFn.grantInvoke(this.webhookFn);

// --- API Gateway route ---
const githubResource = props.api.root.addResource('github');
const webhookResource = githubResource.addResource('webhook');
const webhookMethod = webhookResource.addMethod(
'POST',
new apigw.LambdaIntegration(this.webhookFn),
{ authorizationType: apigw.AuthorizationType.NONE },
);

NagSuppressions.addResourceSuppressions(webhookMethod, [
{
id: 'AwsSolutions-APIG4',
reason: 'GitHub webhook endpoint authenticates via X-Hub-Signature-256 HMAC, not Cognito — required by GitHub webhook protocol.',
},
{
id: 'AwsSolutions-COG4',
reason: 'GitHub webhook endpoint authenticates via X-Hub-Signature-256 HMAC, not Cognito — required by GitHub webhook protocol.',
},
]);

NagSuppressions.addResourceSuppressions(this.webhookFn, [
{
id: 'AwsSolutions-IAM4',
reason: 'AWSLambdaBasicExecutionRole is the standard managed policy for Lambda CloudWatch Logs writes.',
},
{
id: 'AwsSolutions-IAM5',
reason: 'DynamoDB grants from CDK helpers expand to table-arn/index/* wildcards; receiver only writes to the dedup table.',
},
], true);

NagSuppressions.addResourceSuppressions(this.webhookProcessorFn, [
{
id: 'AwsSolutions-IAM4',
reason: 'AWSLambdaBasicExecutionRole is the standard managed policy for Lambda CloudWatch Logs writes.',
},
{
id: 'AwsSolutions-IAM5',
reason: 'AgentCore Browser sessions are ephemeral and have no per-resource ARN; the data-plane API requires wildcards. S3 PutObject uses CDK grant helpers that expand to bucket/* wildcards.',
},
], true);

NagSuppressions.addResourceSuppressions(this.webhookSecret, [
{
id: 'AwsSolutions-SMG4',
reason: 'GitHub webhook signing-secret rotation is owned by GitHub (operator regenerates on the GitHub side and pastes the new value here). No automated rotation Lambda needed.',
},
]);
}
}
Loading
Loading