Skip to content
Merged
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
74 changes: 74 additions & 0 deletions infra/packages/api/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions infra/packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"jose": "^6"
},
"devDependencies": {
"@aws-sdk/client-ec2": "^3.1014.0",
"@eslint/js": "^9.39.4",
"@types/aws-lambda": "^8",
"@types/node": "^25.5.0",
Expand Down
51 changes: 51 additions & 0 deletions infra/packages/api/src/lib/ec2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {
EC2Client,
DescribeInstancesCommand,
} from "@aws-sdk/client-ec2";

const REGION = process.env.AWS_REGION ?? "us-west-2";
const RUNNER_TAG_KEY = process.env.EC2_TAG_KEY ?? "Role";
const RUNNER_TAG_VALUE = process.env.EC2_TAG_VALUE ?? "task-forge-runner";

const ec2 = new EC2Client({ region: REGION });

let cached: string | undefined;

/**
* Resolve the task-runner EC2 instance ID by tag lookup.
* Falls back to EC2_INSTANCE_ID env var. Result is cached for the
* Lambda's lifetime so the EC2 API is called at most once per cold start.
*/
export async function resolveInstanceId(): Promise<string> {
if (cached !== undefined) return cached;

const envId = (process.env.EC2_INSTANCE_ID ?? "").trim();
if (envId) {
cached = envId;
return cached;
}

try {
const resp = await ec2.send(
new DescribeInstancesCommand({
Filters: [
{ Name: `tag:${RUNNER_TAG_KEY}`, Values: [RUNNER_TAG_VALUE] },
{ Name: "instance-state-name", Values: ["running"] },
],
}),
);
for (const r of resp.Reservations ?? []) {
for (const inst of r.Instances ?? []) {
if (inst.InstanceId) {
cached = inst.InstanceId;
return cached;
}
}
}
} catch (err) {
console.warn("ec2: failed to resolve instance by tag:", err);
}

cached = "";
return cached;
}
13 changes: 4 additions & 9 deletions infra/packages/api/src/lib/ssm.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
import { SSMClient, SendCommandCommand } from "@aws-sdk/client-ssm";
import { Resource } from "sst";
import { resolveInstanceId } from "./ec2";

const REGION = process.env.AWS_REGION ?? "us-west-2";
const WORK_DIR = process.env.EC2_WORK_DIR ?? "/home/ec2-user/workspace/task-forge";
const VENV_PYTHON = `${WORK_DIR}/.venv/bin/python3`;
const RUN_TASK_SCRIPT = `${WORK_DIR}/run_task.py`;

const ssmClient = new SSMClient({ region: REGION });

function getInstanceId(): string {
return process.env.EC2_INSTANCE_ID || (Resource as any).Ec2InstanceId?.value || "";
}
const ssmClient = new SSMClient({ region: process.env.AWS_REGION ?? "us-west-2" });

/** Thrown when runner cannot be dispatched (e.g. EC2 instance ID not configured). */
export class RunnerUnavailableError extends Error {
Expand All @@ -21,10 +16,10 @@ export class RunnerUnavailableError extends Error {
}

async function sendCommand(command: string): Promise<void> {
const instanceId = getInstanceId();
const instanceId = await resolveInstanceId();
if (!instanceId) {
throw new RunnerUnavailableError(
"EC2_INSTANCE_ID not set — runner dispatch unavailable"
"Could not resolve EC2 instance — check Role tag or EC2_INSTANCE_ID"
);
}
await ssmClient.send(
Expand Down
14 changes: 3 additions & 11 deletions infra/packages/autopilot/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb";
import { SSMClient, SendCommandCommand } from "@aws-sdk/client-ssm";
import { Resource } from "sst";
import { resolveInstanceId } from "../../api/src/lib/ec2";

const TABLE = process.env.DYNAMO_TABLE ?? "agent-tasks";
const REGION = process.env.AWS_REGION ?? "us-west-2";
Expand All @@ -14,14 +14,6 @@ const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region: REGION }),
});
const ssm = new SSMClient({ region: REGION });

function getInstanceId(): string {
return (
process.env.EC2_INSTANCE_ID ||
(Resource as { Ec2InstanceId?: { value: string } }).Ec2InstanceId?.value ||
""
);
}

function shouldTriggerProposePlan(item: Record<string, unknown>): boolean {
if (item.autopilot !== true || !item.project_id) return false;
const mode =
Expand Down Expand Up @@ -60,9 +52,9 @@ async function listActiveAutopilotProjectIds(): Promise<string[]> {
}

async function triggerProposePlan(projectId: string): Promise<void> {
const instanceId = getInstanceId();
const instanceId = await resolveInstanceId();
if (!instanceId) {
console.warn("No EC2_INSTANCE_ID — skipping autopilot plan trigger");
console.warn("Could not resolve EC2 instance — skipping autopilot plan trigger");
return;
}
const esc = (s: string) => s.replace(/'/g, "'\\''");
Expand Down
14 changes: 3 additions & 11 deletions infra/packages/metrics/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import {
UpdateCommand,
} from "@aws-sdk/lib-dynamodb";
import { SSMClient, SendCommandCommand } from "@aws-sdk/client-ssm";
import { Resource } from "sst";
import { fetchPageSpeedMetrics } from "./pagespeed.js";
import { fetchGitHubMetrics } from "./github.js";
import { resolveInstanceId } from "../../api/src/lib/ec2";

const TABLE = process.env.DYNAMO_TABLE ?? "agent-tasks";
const REGION = process.env.AWS_REGION ?? "us-west-2";
Expand Down Expand Up @@ -114,18 +114,10 @@ async function updateKPICurrentValues(
);
}

function getInstanceId(): string {
return (
process.env.EC2_INSTANCE_ID ||
(Resource as { Ec2InstanceId?: { value: string } }).Ec2InstanceId?.value ||
""
);
}

async function triggerDailyCycle(projectId: string): Promise<void> {
const instanceId = getInstanceId();
const instanceId = await resolveInstanceId();
if (!instanceId) {
console.warn("No EC2_INSTANCE_ID — skipping daily cycle trigger");
console.warn("Could not resolve EC2 instance — skipping daily cycle trigger");
return;
}
const esc = (s: string) => s.replace(/'/g, "'\\''");
Expand Down
10 changes: 3 additions & 7 deletions infra/packages/repo-scanner/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
ScanCommand,
} from "@aws-sdk/lib-dynamodb";
import { SSMClient, SendCommandCommand } from "@aws-sdk/client-ssm";
import { Resource } from "sst";
import { resolveInstanceId } from "../../api/src/lib/ec2";

const REGION = process.env.AWS_REGION ?? "us-west-2";
const TABLE_NAME = process.env.DYNAMO_TABLE ?? "agent-tasks";
Expand Down Expand Up @@ -63,14 +63,10 @@ const VENV_PYTHON = `${WORK_DIR}/.venv/bin/python3`;
const RUN_TASK_SCRIPT = `${WORK_DIR}/run_task.py`;
const ssmClient = new SSMClient({ region: REGION });

function getInstanceId(): string {
return process.env.EC2_INSTANCE_ID || (Resource as any).Ec2InstanceId?.value || "";
}

async function triggerRunner(taskId: string): Promise<void> {
const instanceId = getInstanceId();
const instanceId = await resolveInstanceId();
if (!instanceId) {
console.warn("EC2_INSTANCE_ID not set — skipping runner trigger for %s", taskId);
console.warn("Could not resolve EC2 instance — skipping runner trigger for %s", taskId);
return;
}
try {
Expand Down
4 changes: 0 additions & 4 deletions infra/sst-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,6 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"Ec2InstanceId": {
"type": "sst.sst.Secret"
"value": string
}
"GitHubToken": {
"type": "sst.sst.Secret"
"value": string
Expand Down
24 changes: 19 additions & 5 deletions infra/sst.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,13 @@ export default $config({
const authSecretKey = new sst.Secret("AuthSecretKey");
const authEmail = new sst.Secret("AuthEmail");
const authPassword = new sst.Secret("AuthPassword");
const ec2InstanceId = new sst.Secret("Ec2InstanceId");
const gitHubToken = new sst.Secret("GitHubToken");

const ec2LookupPermission = {
actions: ["ec2:DescribeInstances"],
resources: ["*"],
};

// -- DynamoDB --
const table = new sst.aws.Dynamo("Tasks", {
fields: {
Expand Down Expand Up @@ -136,12 +140,13 @@ export default $config({
runtime: "nodejs22.x",
timeout: "120 seconds",
memory: "256 MB",
link: [table, gitHubToken, ec2InstanceId],
link: [table, gitHubToken],
permissions: [
{
actions: ["ssm:SendCommand"],
resources: ["*"],
},
ec2LookupPermission,
],
environment: {
DYNAMO_TABLE: tablePhysicalName,
Expand All @@ -158,12 +163,13 @@ export default $config({
runtime: "nodejs22.x",
timeout: "120 seconds",
memory: "256 MB",
link: [table, ec2InstanceId],
link: [table],
permissions: [
{
actions: ["ssm:SendCommand"],
resources: ["*"],
},
ec2LookupPermission,
],
environment: {
DYNAMO_TABLE: tablePhysicalName,
Expand All @@ -179,14 +185,21 @@ export default $config({
runtime: "nodejs22.x",
timeout: "60 seconds",
memory: "128 MB",
link: [table, gitHubToken, ec2InstanceId],
link: [table, gitHubToken],
environment: {
DYNAMO_TABLE: tablePhysicalName,
GITHUB_OWNER: githubOwner,
SCAN_REPOS: scanRepos,
ISSUE_LABEL: "agent",
SCAN_CI: "true",
},
permissions: [
{
actions: ["ssm:SendCommand"],
resources: ["*"],
},
ec2LookupPermission,
],
},
});

Expand All @@ -210,7 +223,7 @@ export default $config({
runtime: "nodejs22.x",
timeout: "30 seconds",
memory: "256 MB",
link: [table, authSecretKey, authEmail, authPassword, ec2InstanceId],
link: [table, authSecretKey, authEmail, authPassword],
permissions: [
{
actions: ["ssm:SendCommand"],
Expand All @@ -220,6 +233,7 @@ export default $config({
actions: ["bedrock:InvokeModel"],
resources: ["*"],
},
ec2LookupPermission,
],
environment: {
DYNAMO_TABLE: tablePhysicalName,
Expand Down
Loading