@evident-ai/runner-cdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,121 @@
1
+ # `@evident-ai/runner-cdk`
2
+
3
+ A reusable AWS CDK construct for running **one scale-to-zero [Evident](https://evident.run)
4
+ agent runner** on Fargate, in **your** AWS account, against **your** repo.
5
+
6
+ The runner sleeps when nobody is talking to it: after `idleTimeoutSeconds` of inactivity
7
+ the task scales its own service to `desiredCount 0`, and an HMAC-authenticated waker
8
+ Lambda scales it back up on the next message. You pay for the time the agent is actually
9
+ working.
10
+
11
+ ## What it creates
12
+
13
+ Per instantiation (one agent):
14
+
15
+ - an ECS **task definition** + **Fargate service** (`<resourcePrefix>-<agentName>-<envName>`),
16
+ - a **per-agent self-stop IAM role** the task assumes to set its own `desiredCount` to 0 —
17
+ scoped to that one service, so an agent can't stop its neighbours,
18
+ - **S3 grants scoped to that agent's replica prefix** (`agents/<evidentAgentId>`), which is
19
+ what keeps session-DB replication single-writer,
20
+ - a **waker Lambda** (`EvidentWaker`) that verifies an HMAC signature and the expected
21
+ `agent_id` before it will scale anything up.
22
+
23
+ **Shared** infrastructure — the cluster, security group, task role, log group and replica
24
+ bucket — is *not* created here. You create it once in your stack and pass it in, so one
25
+ cluster can host several agents.
26
+
27
+ ## Getting started
28
+
29
+ For a full end-to-end walkthrough — shared infra, secrets without SOPS/KMS, the image,
30
+ the two grants, deploy, and wiring the wake webhook — see
31
+ [`GETTING-STARTED.md`](./GETTING-STARTED.md). The snippet below is the short version.
32
+
33
+ ```ts
34
+ import { EvidentScaleToZeroConstruct } from "@evident-ai/runner-cdk";
35
+
36
+ const agent = new EvidentScaleToZeroConstruct(this, "MyAgent", {
37
+ agentName: "my-agent",
38
+ evidentAgentId: "00000000-0000-0000-0000-000000000000", // public agent UUID
39
+ gitRepo: "my-org/my-repo",
40
+ envName: "prod",
41
+
42
+ // Your Evident environment — no defaults, on purpose (see below).
43
+ evidentApiUrl: "https://api.evident.run",
44
+ evidentTunnelUrl: "wss://tunnel.evident.run",
45
+
46
+ idleTimeoutSeconds: 900, // 15 min
47
+ cpu: 2048,
48
+ memoryLimitMiB: 16384,
49
+
50
+ // Shared infra you own.
51
+ cluster,
52
+ securityGroup,
53
+ taskRole,
54
+ logGroup,
55
+ replicaBucket,
56
+
57
+ // Your image (see "The image is an input" below).
58
+ image: ecs.ContainerImage.fromRegistry("ghcr.io/my-org/my-runner:latest"),
59
+
60
+ // Your secrets — any Secrets Manager secret, however you manage it.
61
+ agentSecret,
62
+ availableSecretKeys: new Set(["EVIDENT_AGENT_KEY", "GH_TOKEN"]),
63
+ wakerSecret,
64
+ });
65
+
66
+ // The stack grants sts:AssumeRole on this, and the S3 ListBucket prefix condition.
67
+ agent.selfStopRoleArn;
68
+ agent.replicaPrefix;
69
+ ```
70
+
71
+ For a complete working example, see `infrastructure/evident-runner/` in this repo — it is
72
+ the construct's own dogfooding consumer.
73
+
74
+ ## Props
75
+
76
+ | Prop | Type | Notes |
77
+ | --- | --- | --- |
78
+ | `agentName` | `string` | Lowercase slug, `/^[a-z0-9-]+$/`. Drives the service name, log stream prefix, self-stop role name. |
79
+ | `evidentAgentId` | `string` | Public (non-secret) agent UUID. Derives the S3 replica prefix and is the only `agent_id` the waker accepts. |
80
+ | `gitRepo` | `string` | `owner/repo` the agent clones and works on. |
81
+ | `gitBranch` | `string?` | Defaults to `main`. |
82
+ | `envName` | `string` | Suffixed onto the service name. |
83
+ | `evidentApiUrl` | `string` | **Required, no default.** Passed to `evident run --endpoint`. |
84
+ | `evidentTunnelUrl` | `string` | **Required, no default.** Passed to `evident run --tunnel`. |
85
+ | `idleTimeoutSeconds` | `number` | Idle seconds before self-scaling to zero. Required — this construct is always scale-to-zero. |
86
+ | `cpu` / `memoryLimitMiB` | `number` | Must be a valid Fargate combination. |
87
+ | `resourcePrefix` | `string?` | Prefix for fixed-name resources. Defaults to `evident-selfhosted`. **Changing it on an existing deployment replaces live infrastructure** — fixed names can't be renamed in place. |
88
+ | `extraEnvironment` | `Record<string,string>?` | Extra container env, merged over the construct's own. |
89
+ | `cluster`, `securityGroup`, `taskRole`, `logGroup`, `replicaBucket` | interfaces | Shared infra you create and pass in. |
90
+ | `image` | `ecs.ContainerImage` | Your runner image. |
91
+ | `agentSecret`, `wakerSecret` | `secretsmanager.ISecret` | Any Secrets Manager secret. |
92
+ | `availableSecretKeys` | `Set<string>` | Keys present in the agent secret. Requires `EVIDENT_AGENT_KEY` + `GH_TOKEN`; MCP credentials (`BRAVE_API_KEY`, `CLOUDFLARE_API_TOKEN`, `NEON_API_KEY`) are injected only when listed. |
93
+
94
+ ### Two deliberate "no default" choices
95
+
96
+ - **The endpoints are required.** Defaulting them would either bake *our* dev environment
97
+ into your stack or silently point a mis-wired deployment at production. You state your
98
+ environment explicitly.
99
+ - **The secrets are plain `secretsmanager.ISecret`.** The construct does not care how you
100
+ get your secrets into Secrets Manager — SOPS, an existing secret, a manual one. Nothing
101
+ here requires the SOPS+KMS setup this repo happens to use.
102
+
103
+ ### The image is an input
104
+
105
+ This construct never builds an image. Pass any `ecs.ContainerImage` — from a registry, or
106
+ built from a `Dockerfile` you control. The generic runner image lives separately in
107
+ `packages/runner-image`, so you can adopt the image, the construct, or both.
108
+
109
+ ## Status / limitations
110
+
111
+ - **Publishable, but not yet published.** The package is `@evident-ai/runner-cdk`
112
+ (MIT), buildable to a self-contained `dist/` (`pnpm --filter @evident-ai/runner-cdk
113
+ build`) with no `workspace:`/`@evident/*` runtime dependency — see
114
+ [#957](https://github.com/sroze/evident/issues/957). The **first** `npm publish` is a
115
+ one-time human bootstrap step (OIDC/Trusted Publishing can't perform a package's
116
+ first publish, same as [#612](https://github.com/sroze/evident/issues/612) was for
117
+ `@evident-ai/runner-synchroniser`) and hasn't happened yet; until then this repo's own
118
+ consumer (`infrastructure/evident-runner`) keeps consuming it as a `workspace:*`
119
+ dependency, and following this guide outside this repo means working from a checkout.
120
+ - **AWS/ECS-specific.** Non-AWS clouds are an open question on the epic, not a supported
121
+ path.
@@ -0,0 +1,91 @@
1
+ import { Construct } from 'constructs';
2
+ import * as ec2 from 'aws-cdk-lib/aws-ec2';
3
+ import * as ecs from 'aws-cdk-lib/aws-ecs';
4
+ import * as iam from 'aws-cdk-lib/aws-iam';
5
+ import * as logs from 'aws-cdk-lib/aws-logs';
6
+ import * as s3 from 'aws-cdk-lib/aws-s3';
7
+ import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
8
+ export type EvidentScaleToZeroConstructProps = {
9
+ /**
10
+ * Short lowercase slug (e.g. `evident`). Drives the service name
11
+ * (`<resourcePrefix>-<agentName>-<envName>`), the log stream prefix, the
12
+ * self-stop role name, and the waker output. Must match `/^[a-z0-9-]+$/`.
13
+ */
14
+ agentName: string;
15
+ /**
16
+ * Public (non-secret) Evident agent UUID. Derives the S3 replica prefix
17
+ * (`agents/<id>`) and is the single expected `agent_id` the waker checks.
18
+ */
19
+ evidentAgentId: string;
20
+ /** GitHub `owner/repo` the agent clones and works on (e.g. `sroze/evident`). */
21
+ gitRepo: string;
22
+ /** Branch to clone. Defaults to `main` when omitted. */
23
+ gitBranch?: string;
24
+ /**
25
+ * Idle seconds before the runner self-scales its service to `desiredCount 0`
26
+ * (scale-to-zero). REQUIRED — this construct is always scale-to-zero.
27
+ */
28
+ idleTimeoutSeconds: number;
29
+ /** Fargate vCPU units (e.g. 2048 = 2 vCPU). */
30
+ cpu: number;
31
+ /** Fargate memory (MiB). Must be a valid Fargate combination for `cpu`. */
32
+ memoryLimitMiB: number;
33
+ /**
34
+ * Prefix for all FIXED-name physical resources this construct creates
35
+ * (service, log stream prefix, self-stop role). Defaults to
36
+ * {@link DEFAULT_RESOURCE_PREFIX}. Fixed names can't be renamed in place —
37
+ * changing this for an existing deployment replaces live infrastructure.
38
+ */
39
+ resourcePrefix?: string;
40
+ /** Environment slug, suffixed onto the service name (`<resourcePrefix>-<agentName>-<envName>`). */
41
+ envName: string;
42
+ /**
43
+ * Evident API endpoint the runner talks to (passed to `evident run` as
44
+ * `--endpoint`). No default — every adopter must choose their own environment.
45
+ */
46
+ evidentApiUrl: string;
47
+ /**
48
+ * Evident tunnel endpoint the runner connects to (passed to `evident run` as
49
+ * `--tunnel`). No default — every adopter must choose their own environment.
50
+ */
51
+ evidentTunnelUrl: string;
52
+ /**
53
+ * Extra container environment variables, merged on top of the construct's own
54
+ * (e.g. a dev-box `DATABASE_URL`). Values here win over the construct's own.
55
+ */
56
+ extraEnvironment?: Record<string, string>;
57
+ cluster: ecs.ICluster;
58
+ securityGroup: ec2.ISecurityGroup;
59
+ taskRole: iam.IRole;
60
+ logGroup: logs.ILogGroup;
61
+ replicaBucket: s3.IBucket;
62
+ image: ecs.ContainerImage;
63
+ /**
64
+ * Container secrets. Requires EVIDENT_AGENT_KEY + GH_TOKEN; the MCP
65
+ * credentials BRAVE_API_KEY / CLOUDFLARE_API_TOKEN / NEON_API_KEY are injected
66
+ * only if present (see availableSecretKeys).
67
+ */
68
+ agentSecret: secretsmanager.ISecret;
69
+ /** Key names present in the agent secret; gates the optional MCP creds. */
70
+ availableSecretKeys: Set<string>;
71
+ /** Waker secret (EVIDENT_WAKE_SECRET), consumed by the waker at runtime. */
72
+ wakerSecret: secretsmanager.ISecret;
73
+ };
74
+ /**
75
+ * One scale-to-zero Evident agent on Fargate: a task-def + `Runner` container +
76
+ * Fargate service, a per-agent self-stop role, S3 prefix grants, and a waker
77
+ * Lambda. Instantiate once per agent; the stack owns the shared infra.
78
+ *
79
+ * The agent self-stops (`desiredCount 0`) on a clean idle exit and is woken on
80
+ * demand by its waker. Exposes {@link selfStopRoleArn} and {@link replicaPrefix}
81
+ * so the stack can grant `sts:AssumeRole` and the `s3:ListBucket` prefix condition.
82
+ */
83
+ export declare class EvidentScaleToZeroConstruct extends Construct {
84
+ /** ARN of the per-agent self-stop role (the stack grants AssumeRole on it). */
85
+ readonly selfStopRoleArn: string;
86
+ /** This agent's S3 replica prefix (`agents/<evidentAgentId>`). */
87
+ readonly replicaPrefix: string;
88
+ /** The created Fargate service. */
89
+ readonly service: ecs.FargateService;
90
+ constructor(scope: Construct, id: string, props: EvidentScaleToZeroConstructProps);
91
+ }
@@ -0,0 +1,203 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.EvidentScaleToZeroConstruct = void 0;
37
+ const cdk = __importStar(require("aws-cdk-lib"));
38
+ const constructs_1 = require("constructs");
39
+ const ec2 = __importStar(require("aws-cdk-lib/aws-ec2"));
40
+ const ecs = __importStar(require("aws-cdk-lib/aws-ecs"));
41
+ const iam = __importStar(require("aws-cdk-lib/aws-iam"));
42
+ const construct_1 = require("./waker/construct");
43
+ /** Current fixed value of `resourcePrefix`, kept as the default so an adopter
44
+ * omitting the prop reproduces today's physical names exactly. */
45
+ const DEFAULT_RESOURCE_PREFIX = 'evident-selfhosted';
46
+ /**
47
+ * One scale-to-zero Evident agent on Fargate: a task-def + `Runner` container +
48
+ * Fargate service, a per-agent self-stop role, S3 prefix grants, and a waker
49
+ * Lambda. Instantiate once per agent; the stack owns the shared infra.
50
+ *
51
+ * The agent self-stops (`desiredCount 0`) on a clean idle exit and is woken on
52
+ * demand by its waker. Exposes {@link selfStopRoleArn} and {@link replicaPrefix}
53
+ * so the stack can grant `sts:AssumeRole` and the `s3:ListBucket` prefix condition.
54
+ */
55
+ class EvidentScaleToZeroConstruct extends constructs_1.Construct {
56
+ /** ARN of the per-agent self-stop role (the stack grants AssumeRole on it). */
57
+ selfStopRoleArn;
58
+ /** This agent's S3 replica prefix (`agents/<evidentAgentId>`). */
59
+ replicaPrefix;
60
+ /** The created Fargate service. */
61
+ service;
62
+ constructor(scope, id, props) {
63
+ super(scope, id);
64
+ const { agentName, evidentAgentId, gitRepo, gitBranch, idleTimeoutSeconds, cpu, memoryLimitMiB, resourcePrefix = DEFAULT_RESOURCE_PREFIX, envName, evidentApiUrl, evidentTunnelUrl, extraEnvironment, cluster, securityGroup, taskRole, logGroup, replicaBucket, image, agentSecret, availableSecretKeys, wakerSecret, } = props;
65
+ this.replicaPrefix = `agents/${evidentAgentId}`;
66
+ // A plain STRING (not service.serviceName) so the container env is static and
67
+ // has no construct-ordering dependency on the service.
68
+ const serviceName = `${resourcePrefix}-${agentName}-${envName}`;
69
+ const taskDefinition = new ecs.FargateTaskDefinition(this, 'TaskDef', {
70
+ cpu,
71
+ memoryLimitMiB,
72
+ taskRole,
73
+ // 50 GiB: local disk holds the cloned monorepo, node_modules, and
74
+ // opencode.db. Fargate bills only for storage above the free 20 GiB.
75
+ ephemeralStorageGiB: 50,
76
+ });
77
+ // This agent's single service ARN — used by both the waker and the self-stop
78
+ // role. Built from CFN pseudo-params so it's synth-safe and available before
79
+ // the service resource exists.
80
+ const serviceArn = `arn:aws:ecs:${cdk.Stack.of(this).region}:${cdk.Stack.of(this).account}:service/${cluster.clusterName}/${serviceName}`;
81
+ // Per-agent self-stop role scoped to ONLY this service ARN, trusting the task
82
+ // role so the container can assume it to scale itself to 0 on idle. MUST run
83
+ // before addContainer so EVIDENT_SELFSTOP_ROLE_ARN lands in the container def.
84
+ const selfStopRole = new iam.Role(this, 'SelfStopRole', {
85
+ roleName: `${resourcePrefix}-selfstop-${agentName}`,
86
+ assumedBy: new iam.ArnPrincipal(taskRole.roleArn),
87
+ description: "Per-agent self-stop: UpdateService on ONLY this agent's service.",
88
+ maxSessionDuration: cdk.Duration.hours(1),
89
+ });
90
+ selfStopRole.addToPolicy(new iam.PolicyStatement({
91
+ sid: 'SelfStopOwnService',
92
+ effect: iam.Effect.ALLOW,
93
+ actions: ['ecs:UpdateService', 'ecs:DescribeServices'],
94
+ resources: [serviceArn],
95
+ }));
96
+ this.selfStopRoleArn = selfStopRole.roleArn;
97
+ const environment = {
98
+ OPENCODE_PORT: '4096',
99
+ HOME: '/home/runner',
100
+ // Selects the Evident environment (passed to `evident run` as
101
+ // --endpoint / --tunnel). The agent is resolved from EVIDENT_AGENT_KEY.
102
+ EVIDENT_API_URL: evidentApiUrl,
103
+ EVIDENT_TUNNEL_URL: evidentTunnelUrl,
104
+ // Litestream replica target (read by packages/runner-synchroniser, which
105
+ // generates /etc/evident/litestream.yml at boot).
106
+ LITESTREAM_BUCKET: replicaBucket.bucketName,
107
+ LITESTREAM_PREFIX: this.replicaPrefix,
108
+ AWS_REGION: cdk.Stack.of(this).region,
109
+ GIT_REPO: gitRepo,
110
+ GIT_BRANCH: gitBranch ?? 'main',
111
+ // Scale-to-zero self-stop inputs (the entrypoint uses these to scale to 0).
112
+ EVIDENT_IDLE_TIMEOUT_SECONDS: String(idleTimeoutSeconds),
113
+ // Auto-cleanup (issue #190): `evident run` reads this directly and, once a
114
+ // retention rule is set, sweeps OpenCode sessions idle longer than the
115
+ // window (protected/active sessions are never touched). Tightened from 7d
116
+ // to 24h per #537: at 7d, opencode.db was observed growing ~11x (77 MB ->
117
+ // 849 MB) in ~24h, so 7d was far too permissive at the observed growth
118
+ // rate. 24h is a tighter mitigation while the growth's root cause is
119
+ // investigated (see also the OPENCODE_VERSION pin above and the
120
+ // [memwatch] telemetry in entrypoint.sh). Sweep runs on the default 1h
121
+ // interval and never touches a protected/active session.
122
+ EVIDENT_SESSION_CLEANUP_MAX_AGE: '24h',
123
+ CLUSTER: cluster.clusterName,
124
+ SERVICE: serviceName,
125
+ EVIDENT_SELFSTOP_ROLE_ARN: selfStopRole.roleArn,
126
+ // Adopter-supplied extras (e.g. this repo's dev-box DATABASE_URL). Spread
127
+ // LAST so an adopter can override any key above if they ever need to.
128
+ ...extraEnvironment,
129
+ };
130
+ const secrets = {
131
+ EVIDENT_AGENT_KEY: ecs.Secret.fromSecretsManager(agentSecret, 'EVIDENT_AGENT_KEY'),
132
+ GH_TOKEN: ecs.Secret.fromSecretsManager(agentSecret, 'GH_TOKEN'),
133
+ };
134
+ // Optional MCP creds (consumed by opencode.runner.jsonc): BRAVE_API_KEY →
135
+ // brave-search, CLOUDFLARE_API_TOKEN → the Cloudflare MCP, NEON_API_KEY →
136
+ // the Neon MCP (read-only). Injected only if present in the SOPS file — a
137
+ // referenced-but-absent key fails ECS task startup, so an operator can
138
+ // enable an MCP by just adding its key + redeploy.
139
+ for (const key of ['BRAVE_API_KEY', 'CLOUDFLARE_API_TOKEN', 'NEON_API_KEY']) {
140
+ if (availableSecretKeys.has(key)) {
141
+ secrets[key] = ecs.Secret.fromSecretsManager(agentSecret, key);
142
+ }
143
+ }
144
+ // Container name pinned to "Runner": operator `execute-command --container
145
+ // Runner` examples rely on it.
146
+ taskDefinition.addContainer('Runner', {
147
+ image,
148
+ logging: ecs.LogDrivers.awsLogs({
149
+ streamPrefix: resourcePrefix,
150
+ logGroup,
151
+ }),
152
+ // Fargate max (120s, vs the 30s default) so the entrypoint's graceful
153
+ // shutdown can land Litestream's FINAL sync to S3 before ECS SIGKILLs the
154
+ // task — otherwise the last session writes are lost on deploy/stop.
155
+ stopTimeout: cdk.Duration.seconds(120),
156
+ environment,
157
+ secrets,
158
+ });
159
+ // Object Get/Put/Delete scoped to this agent's prefix. DeleteObject is
160
+ // required: Litestream's retention/compaction prunes old LTX files.
161
+ replicaBucket.grantReadWrite(taskRole, `${this.replicaPrefix}/*`);
162
+ this.service = new ecs.FargateService(this, 'Service', {
163
+ cluster,
164
+ taskDefinition,
165
+ serviceName,
166
+ // Starts at 1; the runner self-scales to 0 on idle. A deploy resets this to
167
+ // 1 (CFN), waking a sleeping agent that then re-naps (benign).
168
+ desiredCount: 1,
169
+ enableExecuteCommand: true,
170
+ assignPublicIp: true,
171
+ vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
172
+ securityGroups: [securityGroup],
173
+ // No spare task during deploys (preserves single-writer-per-prefix).
174
+ minHealthyPercent: 0,
175
+ maxHealthyPercent: 100,
176
+ circuitBreaker: { rollback: true },
177
+ });
178
+ // Waker: reads the wake secret at runtime, scoped to this service ARN only.
179
+ new construct_1.EvidentWaker(this, 'Waker', {
180
+ wakeSecret: wakerSecret,
181
+ agentName,
182
+ expectedAgentId: evidentAgentId,
183
+ cluster: cluster.clusterName,
184
+ service: serviceName,
185
+ serviceArn,
186
+ });
187
+ // --- Per-agent outputs (cluster/log group/bucket are shared) ------------
188
+ const pascalName = agentName.charAt(0).toUpperCase() + agentName.slice(1);
189
+ new cdk.CfnOutput(this, `${pascalName}ServiceName`, {
190
+ description: `ECS service name for the ${agentName} agent`,
191
+ value: this.service.serviceName,
192
+ });
193
+ new cdk.CfnOutput(this, `${pascalName}ExecCommandHint`, {
194
+ description: `How to 'SSH' into the running ${agentName} runner task`,
195
+ value: [
196
+ `aws ecs list-tasks --cluster ${cluster.clusterName} --service-name ${serviceName}`,
197
+ 'then:',
198
+ `aws ecs execute-command --cluster ${cluster.clusterName} --task <task-id> --container Runner --interactive --command /bin/bash`,
199
+ ].join(' '),
200
+ });
201
+ }
202
+ }
203
+ exports.EvidentScaleToZeroConstruct = EvidentScaleToZeroConstruct;
@@ -0,0 +1,2 @@
1
+ export { EvidentScaleToZeroConstruct, type EvidentScaleToZeroConstructProps, } from './evident-scale-to-zero-construct';
2
+ export { EvidentWaker, type EvidentWakerProps } from './waker/construct';
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EvidentWaker = exports.EvidentScaleToZeroConstruct = void 0;
4
+ var evident_scale_to_zero_construct_1 = require("./evident-scale-to-zero-construct");
5
+ Object.defineProperty(exports, "EvidentScaleToZeroConstruct", { enumerable: true, get: function () { return evident_scale_to_zero_construct_1.EvidentScaleToZeroConstruct; } });
6
+ var construct_1 = require("./waker/construct");
7
+ Object.defineProperty(exports, "EvidentWaker", { enumerable: true, get: function () { return construct_1.EvidentWaker; } });
@@ -0,0 +1,30 @@
1
+ import { Construct } from 'constructs';
2
+ import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
3
+ export type EvidentWakerProps = {
4
+ /**
5
+ * The agent's secret. The waker fetches `EVIDENT_WAKE_SECRET` at RUNTIME
6
+ * (granted GetSecretValue on its ARN); only the ARN + field name enter the
7
+ * Lambda env, never the value.
8
+ */
9
+ wakeSecret: secretsmanager.ISecret;
10
+ /** Slug of the agent this waker serves (e.g. `evident`). Namespaces the output. */
11
+ agentName: string;
12
+ /** Evident's public `agent_id`; the handler checks the wake payload against it. */
13
+ expectedAgentId: string;
14
+ /** ECS cluster name holding the agent's service. */
15
+ cluster: string;
16
+ /** ECS service name to scale to `desiredCount 1` on a verified wake. */
17
+ service: string;
18
+ /** ARN of the agent's single service; the Lambda role is scoped to exactly it. */
19
+ serviceArn: string;
20
+ };
21
+ /**
22
+ * A per-agent waker Lambda: a Function URL (auth is HMAC verification of the wake
23
+ * body, NOT IAM) that scales ONE agent's ECS service to `desiredCount 1`. Evident
24
+ * POSTs a signed agent-event webhook (`agent.message_queued` / `agent.wake_requested`)
25
+ * here when a disconnected agent needs to be woken. The Function URL is emitted as a
26
+ * CfnOutput for the dashboard.
27
+ */
28
+ export declare class EvidentWaker extends Construct {
29
+ constructor(scope: Construct, id: string, props: EvidentWakerProps);
30
+ }
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.EvidentWaker = void 0;
37
+ const path = __importStar(require("path"));
38
+ const cdk = __importStar(require("aws-cdk-lib"));
39
+ const constructs_1 = require("constructs");
40
+ const iam = __importStar(require("aws-cdk-lib/aws-iam"));
41
+ const lambda = __importStar(require("aws-cdk-lib/aws-lambda"));
42
+ /** The JSON field name inside the wake secret; the handler fetches it at runtime. */
43
+ const WAKE_SECRET_KEY = 'EVIDENT_WAKE_SECRET';
44
+ /**
45
+ * A per-agent waker Lambda: a Function URL (auth is HMAC verification of the wake
46
+ * body, NOT IAM) that scales ONE agent's ECS service to `desiredCount 1`. Evident
47
+ * POSTs a signed agent-event webhook (`agent.message_queued` / `agent.wake_requested`)
48
+ * here when a disconnected agent needs to be woken. The Function URL is emitted as a
49
+ * CfnOutput for the dashboard.
50
+ */
51
+ class EvidentWaker extends constructs_1.Construct {
52
+ constructor(scope, id, props) {
53
+ super(scope, id);
54
+ const { wakeSecret, agentName, expectedAgentId, cluster, service, serviceArn } = props;
55
+ const handler = new lambda.Function(this, 'Function', {
56
+ runtime: lambda.Runtime.NODEJS_22_X,
57
+ // Pre-bundled at PACKAGE build time (`pnpm --filter @evident-ai/runner-cdk
58
+ // build`, see scripts/build.ts), not at synth time: esbuild inlines
59
+ // @evident/webhook-signature's HMAC verifier into
60
+ // dist/waker-lambda/handler.js, so neither this package's published npm
61
+ // artifact (which ships dist/ already built) nor a consuming app needs
62
+ // that private workspace package, or esbuild, on synth's PATH. Requires
63
+ // a build before synth when consuming this package via `workspace:*` —
64
+ // see infrastructure/evident-runner's README "local gate".
65
+ //
66
+ // Resolved from the PACKAGE ROOT (../.. ), not `__dirname` directly:
67
+ // `__dirname` is `dist/waker` at runtime (compiled) but `src/waker` when
68
+ // this file runs straight off source via ts-node (this package's own
69
+ // tests) — both are exactly two directories below the package root, so
70
+ // this resolves to the same `dist/waker-lambda/` either way. A
71
+ // DIRECTORY, not the bundled file directly: `Code.fromAsset` on a lone
72
+ // non-zip file fails at synth with `AssetMustBeZipFile`.
73
+ code: lambda.Code.fromAsset(path.join(__dirname, '..', '..', 'dist', 'waker-lambda')),
74
+ handler: 'handler.handler',
75
+ memorySize: 128,
76
+ timeout: cdk.Duration.seconds(10),
77
+ environment: {
78
+ CLUSTER: cluster,
79
+ SERVICE: service,
80
+ EXPECTED_AGENT_ID: expectedAgentId,
81
+ // Non-secret: the secret's ARN + field name (the handler fetches the value
82
+ // at runtime).
83
+ WAKE_SECRET_ARN: wakeSecret.secretArn,
84
+ WAKE_SECRET_KEY,
85
+ },
86
+ });
87
+ // Runtime GetSecretValue on ONLY this agent's wake secret.
88
+ wakeSecret.grantRead(handler);
89
+ // Scale ONLY this agent's single service (beyond the default Logs role).
90
+ handler.addToRolePolicy(new iam.PolicyStatement({
91
+ sid: 'WakeRunnerService',
92
+ effect: iam.Effect.ALLOW,
93
+ actions: ['ecs:UpdateService', 'ecs:DescribeServices'],
94
+ resources: [serviceArn],
95
+ }));
96
+ const functionUrl = handler.addFunctionUrl({
97
+ // HMAC verification in the handler IS the auth — no IAM on the URL.
98
+ authType: lambda.FunctionUrlAuthType.NONE,
99
+ });
100
+ const pascalName = agentName.charAt(0).toUpperCase() + agentName.slice(1);
101
+ new cdk.CfnOutput(scope, `WakerFunctionUrl${pascalName}`, {
102
+ description: `POST target for Evident's signed agent-event webhook (agent.message_queued / agent.wake_requested, HMAC-verified) for the ${agentName} agent.`,
103
+ value: functionUrl.url,
104
+ });
105
+ }
106
+ }
107
+ exports.EvidentWaker = EvidentWaker;
@@ -0,0 +1,160 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/waker/handler.ts
21
+ var handler_exports = {};
22
+ __export(handler_exports, {
23
+ handleWake: () => handleWake,
24
+ handler: () => handler
25
+ });
26
+ module.exports = __toCommonJS(handler_exports);
27
+ var import_client_ecs = require("@aws-sdk/client-ecs");
28
+ var import_client_secrets_manager = require("@aws-sdk/client-secrets-manager");
29
+
30
+ // ../webhook-signature/src/verify-wake-signature.ts
31
+ var import_node_crypto = require("node:crypto");
32
+ function verifyWakeSignature(rawBody, signatureHeader, secret) {
33
+ if (!signatureHeader || !secret) {
34
+ return false;
35
+ }
36
+ const provided = signatureHeader.startsWith("sha256=") ? signatureHeader.slice("sha256=".length) : signatureHeader;
37
+ if (provided.length === 0) {
38
+ return false;
39
+ }
40
+ const expected = (0, import_node_crypto.createHmac)("sha256", secret).update(rawBody).digest("hex");
41
+ const providedBuffer = Buffer.from(provided, "hex");
42
+ const expectedBuffer = Buffer.from(expected, "hex");
43
+ if (providedBuffer.length !== expectedBuffer.length) {
44
+ return false;
45
+ }
46
+ return (0, import_node_crypto.timingSafeEqual)(providedBuffer, expectedBuffer);
47
+ }
48
+
49
+ // src/waker/handler.ts
50
+ var ACCEPTED_WAKE_EVENT_TYPES = ["agent.message_queued", "agent.wake_requested"];
51
+ function jsonResponse(statusCode, message) {
52
+ return {
53
+ statusCode,
54
+ headers: { "content-type": "application/json" },
55
+ body: JSON.stringify({ message })
56
+ };
57
+ }
58
+ async function handleWake({
59
+ rawBody,
60
+ signatureHeader,
61
+ wakeSecret,
62
+ expectedAgentId,
63
+ cluster,
64
+ service,
65
+ ecs
66
+ }) {
67
+ if (!verifyWakeSignature(rawBody, signatureHeader, wakeSecret)) {
68
+ const { statusCode: statusCode2, body: body2 } = jsonResponse(401, "invalid signature");
69
+ return { statusCode: statusCode2, body: body2, calledUpdateService: false };
70
+ }
71
+ let agentId;
72
+ let eventType;
73
+ try {
74
+ ({ agent_id: agentId, type: eventType } = JSON.parse(rawBody));
75
+ } catch {
76
+ const { statusCode: statusCode2, body: body2 } = jsonResponse(400, "invalid JSON body");
77
+ return { statusCode: statusCode2, body: body2, calledUpdateService: false };
78
+ }
79
+ if (eventType !== void 0 && !ACCEPTED_WAKE_EVENT_TYPES.includes(eventType)) {
80
+ console.warn("wake unsupported event type");
81
+ const { statusCode: statusCode2, body: body2 } = jsonResponse(400, "unsupported event type");
82
+ return { statusCode: statusCode2, body: body2, calledUpdateService: false };
83
+ }
84
+ if (agentId !== expectedAgentId) {
85
+ console.warn("wake agent_id mismatch");
86
+ const { statusCode: statusCode2, body: body2 } = jsonResponse(404, "unknown agent_id");
87
+ return { statusCode: statusCode2, body: body2, calledUpdateService: false };
88
+ }
89
+ try {
90
+ await ecs.updateService(cluster, service);
91
+ } catch (error) {
92
+ console.error("UpdateService failed for", service, error);
93
+ const { statusCode: statusCode2, body: body2 } = jsonResponse(500, "failed to wake service");
94
+ return { statusCode: statusCode2, body: body2, calledUpdateService: true };
95
+ }
96
+ const { statusCode, body } = jsonResponse(200, "waking");
97
+ return { statusCode, body, calledUpdateService: true };
98
+ }
99
+ var ecsClient = new import_client_ecs.ECSClient({});
100
+ var secretsClient = new import_client_secrets_manager.SecretsManagerClient({});
101
+ var runtimeEcs = {
102
+ async updateService(cluster, service) {
103
+ await ecsClient.send(new import_client_ecs.UpdateServiceCommand({ cluster, service, desiredCount: 1 }));
104
+ }
105
+ };
106
+ var cachedWakeSecret;
107
+ async function fetchWakeSecret(arn, key) {
108
+ const { SecretString } = await secretsClient.send(new import_client_secrets_manager.GetSecretValueCommand({ SecretId: arn }));
109
+ if (!SecretString) {
110
+ throw new Error("wake secret has no SecretString");
111
+ }
112
+ const parsed = JSON.parse(SecretString);
113
+ const value = parsed[key];
114
+ if (typeof value !== "string") {
115
+ throw new Error(`wake secret is missing the '${key}' field`);
116
+ }
117
+ return value;
118
+ }
119
+ function getWakeSecret(arn, key) {
120
+ if (!cachedWakeSecret) {
121
+ cachedWakeSecret = fetchWakeSecret(arn, key);
122
+ }
123
+ return cachedWakeSecret;
124
+ }
125
+ function requireEnv(name) {
126
+ const value = process.env[name];
127
+ if (!value) {
128
+ throw new Error(`missing required env var ${name}`);
129
+ }
130
+ return value;
131
+ }
132
+ var handler = async (event) => {
133
+ const cluster = requireEnv("CLUSTER");
134
+ const service = requireEnv("SERVICE");
135
+ const expectedAgentId = requireEnv("EXPECTED_AGENT_ID");
136
+ const wakeSecretArn = requireEnv("WAKE_SECRET_ARN");
137
+ const wakeSecretKey = requireEnv("WAKE_SECRET_KEY");
138
+ const rawBody = event.isBase64Encoded ? Buffer.from(event.body ?? "", "base64").toString("utf8") : event.body ?? "";
139
+ const signatureHeader = event.headers?.["x-evident-signature"];
140
+ const wakeSecret = await getWakeSecret(wakeSecretArn, wakeSecretKey);
141
+ const { statusCode, body } = await handleWake({
142
+ rawBody,
143
+ signatureHeader,
144
+ wakeSecret,
145
+ expectedAgentId,
146
+ cluster,
147
+ service,
148
+ ecs: runtimeEcs
149
+ });
150
+ return {
151
+ statusCode,
152
+ headers: { "content-type": "application/json" },
153
+ body
154
+ };
155
+ };
156
+ // Annotate the CommonJS export names for ESM import in node:
157
+ 0 && (module.exports = {
158
+ handleWake,
159
+ handler
160
+ });
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@evident-ai/runner-cdk",
3
+ "version": "0.1.0",
4
+ "description": "Reusable CDK construct for a single scale-to-zero Evident agent runner on Fargate (task + service + per-agent self-stop role + waker Lambda). Instantiate once per agent from your own stack.",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "scripts": {
17
+ "build": "ts-node scripts/build.ts",
18
+ "typecheck": "tsc --noEmit",
19
+ "test": "node --test --require ts-node/register 'src/**/*.test.ts'",
20
+ "format": "prettier --write 'src/**/*.ts'",
21
+ "lint": "eslint 'src/**/*.ts' --max-warnings=0"
22
+ },
23
+ "peerDependencies": {
24
+ "aws-cdk-lib": "^2.240.0",
25
+ "constructs": "^10.5.0"
26
+ },
27
+ "devDependencies": {
28
+ "@aws-sdk/client-ecs": "^3.682.0",
29
+ "@aws-sdk/client-secrets-manager": "^3.682.0",
30
+ "@evident/webhook-signature": "workspace:*",
31
+ "@types/node": "^22",
32
+ "aws-cdk-lib": "^2.240.0",
33
+ "constructs": "^10.5.0",
34
+ "esbuild": "^0.25.2",
35
+ "prettier": "^3.3.3",
36
+ "ts-node": "^10.9.2",
37
+ "typescript": "^5.6.3"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "https://github.com/sroze/evident.git",
45
+ "directory": "packages/runner-cdk"
46
+ },
47
+ "homepage": "https://evident.run",
48
+ "author": "Evident <contact@evident.run>",
49
+ "license": "MIT"
50
+ }