@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 +121 -0
- package/dist/evident-scale-to-zero-construct.d.ts +91 -0
- package/dist/evident-scale-to-zero-construct.js +203 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +7 -0
- package/dist/waker/construct.d.ts +30 -0
- package/dist/waker/construct.js +107 -0
- package/dist/waker-lambda/handler.js +160 -0
- package/package.json +50 -0
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;
|
package/dist/index.d.ts
ADDED
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
|
+
}
|