@moinulmoin/eden 0.9.0 → 0.11.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 CHANGED
@@ -12,15 +12,15 @@ If you already have an Eve project, start with `eden deploy`.
12
12
  Use any one of these package managers:
13
13
 
14
14
  ```sh
15
- npm install --global @moinulmoin/eden@0.9.0
15
+ npm install --global @moinulmoin/eden@0.11.0
16
16
  ```
17
17
 
18
18
  ```sh
19
- pnpm add --global @moinulmoin/eden@0.9.0
19
+ pnpm add --global @moinulmoin/eden@0.11.0
20
20
  ```
21
21
 
22
22
  ```sh
23
- bun add --global @moinulmoin/eden@0.9.0
23
+ bun add --global @moinulmoin/eden@0.11.0
24
24
  ```
25
25
 
26
26
  Then confirm the CLI is available:
package/dist/SKILL.md ADDED
@@ -0,0 +1,215 @@
1
+ ---
2
+ name: eden
3
+ description: Deploy, update, check, or remove a Vercel Eve agent on the user's own Cloudflare account with the Eden CLI. Use when someone wants to run an Eve project off Vercel, check whether an Eve project is ready for Cloudflare, ship a change to an Eden deployment, upgrade Eve on one, or tear one down.
4
+ ---
5
+
6
+ # Eden: run an Eve agent on Cloudflare
7
+
8
+ Eden (`@moinulmoin/eden`) builds an existing Eve project as-is and runs it in a
9
+ Cloudflare Container behind a Worker on the user's own account. It never
10
+ rewrites the project; you make any needed changes yourself, guided by
11
+ preflight.
12
+
13
+ `npx @moinulmoin/eden skill` prints this guide for the installed CLI version.
14
+ Docs: https://github.com/moinulmoin/eden (failure and warning codes:
15
+ `docs/troubleshooting.md`).
16
+
17
+ ## The loop
18
+
19
+ 1. Check the machine and account (below).
20
+ 2. Write the env file (below).
21
+ 3. Run preflight and read the JSON:
22
+
23
+ ```sh
24
+ npx @moinulmoin/eden preflight --env-file ~/.config/<agent>/preview.env --json
25
+ ```
26
+
27
+ Preflight builds and boots the agent locally and checks Cloudflare access.
28
+ It changes nothing on Cloudflare. It prints one JSON object:
29
+ `ok`, `checks[]` (`id`, `status`, `message`, `remediation`) and
30
+ `warnings[]` (`id`, `message`, `remediation`).
31
+ 4. Fix every failed check, then every warning, using its `remediation`. Re-run
32
+ until `ok` is `true` and no warning applies. A warning does not block a
33
+ deploy, but each one names something that breaks or loses data on
34
+ Cloudflare; only leave one if the user accepts that.
35
+ 5. A clean preflight means the project can run on Cloudflare. It does not
36
+ check that keys are real: before deploying, make sure the user has replaced
37
+ every placeholder secret in the env file.
38
+ 6. Deploy, then verify with a real message (below). Confirm with the user
39
+ first for production.
40
+
41
+ ## Machine and account
42
+
43
+ - macOS or Linux, Node `>=24.17.0`
44
+ - Docker or OrbStack running, with linux/amd64 support
45
+ - Cloudflare Workers Paid plan (Containers need it), and Wrangler logged in:
46
+ `npx wrangler@4.147.0 login` (the user must do this in a browser)
47
+
48
+ ## Project setup Cloudflare needs
49
+
50
+ These are what preflight checks most often. Fix them before the first run to
51
+ save a build.
52
+
53
+ - **One lockfile.** Either a root `package-lock.json`, or an exact
54
+ `"packageManager": "pnpm@x.y.z"` with a root `pnpm-lock.yaml`. Not both, not
55
+ Bun. Dependency install scripts do not run.
56
+ - **Sandbox.** Add `just-bash` as a production dependency. On Eve `0.83.0` and
57
+ later, also select it explicitly in `agent/sandbox.ts` (or
58
+ `agent/sandbox/sandbox.ts` if an `agent/sandbox/` folder exists):
59
+
60
+ ```ts
61
+ import { defineSandbox } from 'eve/sandbox';
62
+ import { JustBashSandbox } from 'eve/sandbox/just-bash';
63
+
64
+ export const environment = JustBashSandbox.environment();
65
+ export default defineSandbox(() => environment.open());
66
+ ```
67
+
68
+ Vercel Sandbox, Docker, and other providers do not run on Eden.
69
+ - **Channel auth (`EVE_AUTH_SCAFFOLD`).** The `eve init` auth in
70
+ `agent/channels/eve.ts` (`vercelOidc()`, `localDev()`, `placeholderAuth()`)
71
+ rejects every Cloudflare caller with 401. Replace it with a real
72
+ authenticator, for example a bearer token read from the env file. Keep
73
+ `localDev()` so `eve dev` still works locally; it is ignored in production:
74
+
75
+ ```ts
76
+ import { localDev, type AuthFn } from "eve/channels/auth";
77
+ import { eveChannel } from "eve/channels/eve";
78
+
79
+ const authenticate: AuthFn<Request> = (request) => {
80
+ const expected = process.env.AGENT_AUTH_TOKEN;
81
+ if (!expected || request.headers.get("authorization") !== `Bearer ${expected}`) {
82
+ return null;
83
+ }
84
+ return {
85
+ attributes: {},
86
+ authenticator: "bearer-token",
87
+ principalId: "app-client",
88
+ principalType: "app",
89
+ };
90
+ };
91
+
92
+ export default eveChannel({ auth: [authenticate, localDev()] });
93
+ ```
94
+
95
+ - **Model key (`EVE_AI_GATEWAY_KEY_MISSING`).** A string model such as
96
+ `"openai/gpt-5"` goes through Vercel AI Gateway. On Vercel that
97
+ authenticates automatically; on Cloudflare it needs `AI_GATEWAY_API_KEY` in
98
+ the env file. A direct provider such as `openai("…")` from `@ai-sdk/openai`
99
+ needs that provider's key instead.
100
+ - **Vercel Connect (`EVE_VERCEL_CONNECT`).** `connect()` from
101
+ `@vercel/connect/eve` gets credentials using an identity only Vercel
102
+ provides, so those connections fail on Cloudflare. Put the service's own key
103
+ or bot token in the env file and replace `connect(...)` in the connection
104
+ with `auth: { getToken: async () => ({ token: process.env.SERVICE_TOKEN! }) }`.
105
+ For per-user sign-in, use `defineInteractiveAuthorization` from
106
+ `eve/connections` with your own OAuth app. Having `@vercel/connect` in
107
+ `package.json` without importing it is fine.
108
+ - **Durable state (`EVE_WORLD_LOCAL`).** The default local Workflow World loses
109
+ sessions and pending approvals on every update. Use one of:
110
+ - `@moinulmoin/eden-world-cloudflare`: no database; sessions also survive Eve
111
+ upgrades. Add it as a production dependency and set in `agent/agent.ts`:
112
+
113
+ ```ts
114
+ experimental: { workflow: { world: "@moinulmoin/eden-world-cloudflare" } },
115
+ ```
116
+
117
+ - `@workflow/world-postgres` with a direct, unpooled Postgres URL in the env
118
+ file.
119
+
120
+ ## Env file and secrets
121
+
122
+ - Lives **outside the project**, mode 600, for example
123
+ `~/.config/<agent>/preview.env`. Passed with `--env-file`.
124
+ - Exactly `KEY=value` per line: no quotes (they become part of the value), no
125
+ spaces around the name, `#` comment lines allowed.
126
+ - Holds the model key and every secret the agent reads (auth token, service
127
+ keys, database URL).
128
+ - Never print, log, or commit it, and never pass a secret as a command
129
+ argument. Ask the user to fill in values you do not have.
130
+
131
+ ## Commands
132
+
133
+ ```sh
134
+ # deploy to preview; the name comes from package.json
135
+ npx @moinulmoin/eden deploy --env-file ~/.config/<agent>/preview.env --json
136
+
137
+ # ship a change: the same command updates in place, on the same URL
138
+ npx @moinulmoin/eden deploy --env-file ~/.config/<agent>/preview.env --json
139
+
140
+ # production always needs an explicit --name
141
+ npx @moinulmoin/eden deploy --env production --name <name> --env-file <file> --json
142
+
143
+ # remove one target (always needs the exact --name deploy printed;
144
+ # add --env production for a production target)
145
+ npx @moinulmoin/eden destroy --name <name> --json
146
+ ```
147
+
148
+ - `--project <dir>` selects a project other than the current directory.
149
+ - A fresh deploy takes a few minutes, mostly building the container. The
150
+ result's `deployment.stableWorkersDevOrigin` is the live URL.
151
+ - An `eve init` project's `npm run deploy` script still deploys to Vercel, not
152
+ Eden.
153
+ - Confirm with the user before any `destroy` or production deploy. Destroy
154
+ deletes the agent immediately, even mid-conversation, and with the Cloudflare
155
+ World also its stored sessions. An external Postgres database is not touched.
156
+
157
+ ## Verify
158
+
159
+ Health only proves the server started; it needs no auth:
160
+
161
+ ```sh
162
+ curl -s https://<url>/eve/v1/health # expect "status":"ready"
163
+ ```
164
+
165
+ Then prove auth, the model key, and the agent work with a real message, using
166
+ the channel's credential (the bearer token from the env file):
167
+
168
+ ```sh
169
+ curl -s -X POST https://<url>/eve/v1/session \
170
+ -H "Authorization: Bearer $AGENT_AUTH_TOKEN" \
171
+ -H "Content-Type: application/json" \
172
+ -d '{"message":"Reply with one word: ready"}'
173
+ # {"ok":true,"sessionId":"wrun_…"}; then read the reply:
174
+ curl -s -N https://<url>/eve/v1/session/<sessionId>/stream \
175
+ -H "Authorization: Bearer $AGENT_AUTH_TOKEN"
176
+ ```
177
+
178
+ Load the token into the shell from the env file without printing it. The
179
+ first request after the agent sleeps takes about 10–15 seconds.
180
+
181
+ ## Upgrading Eve
182
+
183
+ - Bump `eve`, then reinstall so the lockfile matches. Keep
184
+ `@moinulmoin/eden-world-cloudflare` on its latest version.
185
+ - Moving an `eve init` project to Eve `1.0` also needs `@vercel/connect`
186
+ `2.7.0` or later; otherwise npm fails with `ERESOLVE`.
187
+ - Then deploy as usual. With the current Cloudflare World, existing sessions
188
+ continue on the new version. With the Postgres World or an older Cloudflare
189
+ World they return HTTP 409 `session_stranded` and users start new ones.
190
+
191
+ ## Limits to tell the user
192
+
193
+ - The sandbox is `just-bash`, a simulated shell: files, grep, sed, jq,
194
+ sqlite3, and curl work; python, git, and npm do not.
195
+ - One container per agent, `workers.dev` URL only, no custom domains yet.
196
+ - The container sleeps after 1 hour idle and wakes on the next request.
197
+ Standard 5-field cron schedules still fire while it sleeps; other cron forms
198
+ only fire while it is awake (`EVE_SCHEDULE_UNSUPPORTED`).
199
+
200
+ ## When something fails
201
+
202
+ - Checks run before anything changes on Cloudflare. Fix the first failed
203
+ check by its `remediation` and run again.
204
+ - `VAL-CLI-007-TARGET-CONFLICT`: a resource with that name exists that Eden
205
+ cannot prove it owns. For a brand-new agent, pick another `--name`; never
206
+ delete the resource to make room.
207
+ - `VAL-CLI-007-CLOUDFLARE-ACCESS`: Wrangler is not logged in or the account
208
+ lacks Workers Paid/Containers. The user has to fix this
209
+ (`npx wrangler@4.147.0 whoami`).
210
+ - `DEPLOY_INDETERMINATE`: Eden could not prove the result; the old version
211
+ usually keeps serving. Check the URL's health, then retry with the exact
212
+ same `--project`, `--env`, and `--name`. Never switch to a new name to get
213
+ past it, never delete resources by prefix, and never delete the project's
214
+ `.eden/` folder: it is the ownership proof `destroy` needs.
215
+ - Look up any other code in `docs/troubleshooting.md`.
@@ -128,6 +128,11 @@ export interface EveDeploymentIdentity {
128
128
  readonly sourceDigest: string;
129
129
  readonly generationId: string;
130
130
  readonly deploymentId: string;
131
+ readonly releaseId?: string;
132
+ readonly eveVersion?: string;
133
+ readonly affinityContract?: number;
134
+ readonly retainedDeployments?: Readonly<Record<string, EveAffinityDeployment>>;
135
+ readonly pendingRetirements?: Readonly<Record<string, EveAffinityDeployment>>;
131
136
  readonly environment: EveCliEnvironment;
132
137
  readonly name: string;
133
138
  readonly accountId: string;
@@ -144,6 +149,28 @@ export type EveDeploymentIdentityProof = Partial<EveDeploymentIdentity> & {
144
149
  /** The deployment the serving Container verifiably started under; set only by a deployed Eden host that proves its running image before stamping. */
145
150
  readonly startedDeploymentId?: string;
146
151
  };
152
+ export interface EveAffinityDeployment {
153
+ readonly image: string;
154
+ readonly generationId: string;
155
+ readonly runtimeRevisionHandle?: string;
156
+ readonly eveVersion?: string;
157
+ readonly affinityContract?: number;
158
+ readonly releaseId?: string;
159
+ readonly worldRpcAuth?: boolean;
160
+ }
161
+ export interface EveDeploymentDrainState {
162
+ readonly liveRuns: number;
163
+ readonly liveSessions: number;
164
+ readonly pendingMessages: number;
165
+ readonly unfinishedTransfers: number;
166
+ readonly drained: boolean;
167
+ }
168
+ export type EveDeploymentRpcRunner = (request: {
169
+ readonly origin: string;
170
+ readonly operation: "deployments.drainState" | "deployments.retire";
171
+ readonly deploymentIds: readonly string[];
172
+ readonly controlToken: string;
173
+ }) => Promise<unknown>;
147
174
  export interface EveDeploymentPublicationRequest {
148
175
  readonly identity: EveDeploymentIdentity;
149
176
  readonly hostConfig: EveHostConfig;
@@ -233,6 +260,7 @@ export interface EvePreflightOptions {
233
260
  readonly protectedStore?: EveRuntimeProtectedStore;
234
261
  readonly publish?: EveDeploymentPublicationRunner;
235
262
  readonly publishImage?: EveImagePublicationRunner;
263
+ readonly deploymentRpc?: EveDeploymentRpcRunner;
236
264
  readonly compensate?: EveDeploymentCompensationRunner;
237
265
  readonly health?: EveDeploymentHealthRunner;
238
266
  readonly afterPromotion?: () => void | Promise<void>;
@@ -1,3 +1,4 @@
1
+ import { setTimeout as waitForActivation } from "node:timers/promises";
1
2
  import { createRequire, } from "node:module";
2
3
  import { createHash, randomUUID, } from "node:crypto";
3
4
  import { join, dirname, relative, } from "node:path";
@@ -11,7 +12,7 @@ import { EveCliError, EVE_NAME_PATTERN, } from "./eve.js";
11
12
  import { buildEveProjectSnapshot, checkEveSandboxProvider, createDockerEveProjectBuilder, revalidateEveProjectCandidateInputs, EvePackagingError, IMAGE_ID_PATTERN, } from "./eve-packaging.js";
12
13
  import { buildEveRuntimeImage, discardEveRuntimeImage, revalidateEveRuntimeCandidate, validateEveHostRequirements, } from "./eve-runtime-image.js";
13
14
  import { EveRuntimeConfigError, prepareEveRuntimeInjection, redactEveRuntimeOutput, } from "./eve-runtime-config.js";
14
- import { createEveHostConfig, generateEveHostWorkerSource, parseEveScheduleCron, resolveStableWorkersDevOrigin, } from "@moinulmoin/eden-runtime-cloudflare";
15
+ import { EDEN_AFFINITY_CONTRACT, callDeploymentControlRpc, createEveHostConfig, generateEveHostWorkerSource, parseEveScheduleCron, resolveStableWorkersDevOrigin, } from "@moinulmoin/eden-runtime-cloudflare";
15
16
  const execFileAsync = promisify(execFile);
16
17
  const require = createRequire(import.meta.url);
17
18
  const wranglerEntrypoint = require.resolve("wrangler");
@@ -32,6 +33,54 @@ export const DEFAULT_EVE_NODE_IMAGE = Object.freeze({
32
33
  });
33
34
  /** The single tag Eden pushes to the managed Cloudflare registry per generation. */
34
35
  const EVE_REGISTRY_IMAGE_TAG = "candidate";
36
+ function validateDeploymentDrainStates(value, ids) {
37
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
38
+ throw deploymentFailure("EVE_AFFINITY_DRAIN_UNPROVEN", "The authenticated World did not return deployment drain states.");
39
+ }
40
+ const states = value;
41
+ for (const id of ids) {
42
+ const state = states[id];
43
+ if (typeof state !== "object" || state === null ||
44
+ typeof state.drained !== "boolean" ||
45
+ [state.liveRuns, state.liveSessions, state.pendingMessages, state.unfinishedTransfers]
46
+ .some((count) => !Number.isSafeInteger(count) || count < 0) ||
47
+ state.drained !== (state.liveRuns + state.liveSessions + state.pendingMessages + state.unfinishedTransfers === 0)) {
48
+ throw deploymentFailure("EVE_AFFINITY_DRAIN_UNPROVEN", `The authenticated World returned an invalid drain state for ${id}.`);
49
+ }
50
+ }
51
+ return states;
52
+ }
53
+ const defaultDeploymentRpc = async (request) => {
54
+ // A successful Cloudflare secret write creates an active deployment, but
55
+ // existing stateless Worker isolates may briefly serve the preceding secret.
56
+ // Only the first read-only drain call following our own rotation may wait
57
+ // for that exact in-memory credential. All other failures remain terminal.
58
+ const activationRead = request.operation === "deployments.drainState";
59
+ const deadline = Date.now() + 60_000;
60
+ for (;;) {
61
+ const remaining = deadline - Date.now();
62
+ if (remaining <= 0) {
63
+ throw deploymentFailure("EVE_AFFINITY_CONTROL_ACTIVATION_TIMEOUT", "The freshly rotated deployment-control credential did not activate within 60 seconds; publication was refused.");
64
+ }
65
+ try {
66
+ return await callDeploymentControlRpc({
67
+ origin: request.origin,
68
+ token: request.controlToken,
69
+ operation: request.operation,
70
+ args: [{ deploymentIds: request.deploymentIds }],
71
+ signal: AbortSignal.timeout(remaining),
72
+ });
73
+ }
74
+ catch (error) {
75
+ if (!activationRead || typeof error !== "object" || error === null ||
76
+ !("status" in error) || error.status !== 401)
77
+ throw error;
78
+ const delay = Math.min(1_000, deadline - Date.now());
79
+ if (delay > 0)
80
+ await waitForActivation(delay);
81
+ }
82
+ }
83
+ };
35
84
  function safeText(value) {
36
85
  return redactEveRuntimeOutput(value)
37
86
  .replace(/(?:^|[\s"'=])(?:Bearer|Token)\s+\S+/giu, "$1[redacted]")
@@ -214,6 +263,56 @@ function pooledPostgresReason(connectionString) {
214
263
  }
215
264
  return undefined;
216
265
  }
266
+ const EVE_WORLD_LOCAL_REMEDIATION = 'For durable state without a database, add @moinulmoin/eden-world-cloudflare and set experimental: { workflow: { world: "@moinulmoin/eden-world-cloudflare" } } in agent.ts; sessions survive Eve upgrades. Alternatively, add @workflow/world-postgres, select it as the workflow world, and put WORKFLOW_POSTGRES_URL with a direct, unpooled Postgres URL in --env-file.';
267
+ /** Static, dynamic, side-effect, or require imports of @vercel/connect or a subpath. */
268
+ function importsVercelConnect(source) {
269
+ return /\b(?:from|import|require)\s*\(?\s*["']@vercel\/connect(?:\/[^"']*)?["']/u
270
+ .test(source.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/gu, ""));
271
+ }
272
+ async function vercelConnectSourcePaths(projectRoot) {
273
+ const paths = [];
274
+ const visit = async (path) => {
275
+ const entries = await readdir(join(projectRoot, path), { withFileTypes: true })
276
+ .catch(() => []);
277
+ for (const entry of entries) {
278
+ if (entry.name === "node_modules" || entry.isSymbolicLink())
279
+ continue;
280
+ const child = `${path}/${entry.name}`;
281
+ if (entry.isDirectory())
282
+ await visit(child);
283
+ else if (entry.isFile() && /\.[cm]?[jt]sx?$/u.test(entry.name)) {
284
+ const source = await readFile(join(projectRoot, child), "utf8").catch(() => "");
285
+ if (importsVercelConnect(source))
286
+ paths.push(child);
287
+ }
288
+ }
289
+ };
290
+ await visit("agent");
291
+ await visit("agents");
292
+ return paths.sort();
293
+ }
294
+ /** Eve persists its compile-time routing decision, including its default model. */
295
+ async function collectEveGatewayWarnings(candidate, runtimeConfig) {
296
+ if (runtimeConfig?.withProtectedValues((values) => (values.AI_GATEWAY_API_KEY?.trim().length ?? 0) > 0) === true)
297
+ return [];
298
+ try {
299
+ const manifest = JSON.parse(await readFile(join(candidate.snapshotRoot, EVE_COMPILED_MANIFEST_PATH), "utf8"));
300
+ if (manifest?.config?.model?.routing?.kind !== "gateway")
301
+ return [];
302
+ const source = typeof manifest.config.source?.logicalPath === "string"
303
+ ? manifest.config.source.logicalPath
304
+ : "agent/agent.ts";
305
+ return [{
306
+ id: "EVE_AI_GATEWAY_KEY_MISSING",
307
+ message: `The model in ${source} routes through Vercel AI Gateway, but --env-file has no non-blank AI_GATEWAY_API_KEY. Cloudflare has no Vercel OIDC project identity, so model calls fail.`,
308
+ remediation: 'Add AI_GATEWAY_API_KEY from https://vercel.com/dashboard/ai/api-keys to your --env-file, or use a direct provider such as openai("gpt-5") from @ai-sdk/openai with OPENAI_API_KEY in that file.',
309
+ }];
310
+ }
311
+ catch {
312
+ // Unknown or older compiled formats are not evidence of Gateway routing.
313
+ return [];
314
+ }
315
+ }
217
316
  /**
218
317
  * Recognizes the generated channel, not arbitrary user authentication policies.
219
318
  * The calls below match the Eve 0.82 initializer's agent/channels/eve.ts.
@@ -228,6 +327,14 @@ async function collectEveSetupWarnings(projectRoot) {
228
327
  return "";
229
328
  }
230
329
  };
330
+ const connectPaths = await vercelConnectSourcePaths(projectRoot);
331
+ if (connectPaths.length > 0) {
332
+ warnings.push({
333
+ id: "EVE_VERCEL_CONNECT",
334
+ message: `Vercel Connect imports in ${connectPaths.join(", ")} get credentials using a Vercel project identity (OIDC) that only Vercel injects; these connections fail on Cloudflare.`,
335
+ remediation: "Put each service's own API key or bot token in --env-file and replace connect(...) in the connection with auth: { getToken: async () => ({ token: process.env.SERVICE_TOKEN! }) }. For per-user sign-in, use defineInteractiveAuthorization from eve/connections with your own OAuth app.",
336
+ });
337
+ }
231
338
  const channel = (await source("agent/channels/eve.ts"))
232
339
  .replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/gu, "")
233
340
  .replace(/\s+/gu, "");
@@ -246,7 +353,7 @@ async function collectEveSetupWarnings(projectRoot) {
246
353
  warnings.push({
247
354
  id: "EVE_WORLD_LOCAL",
248
355
  message: "Local World stores memory on container disk; updates or a lost sleep snapshot reset sessions and approvals.",
249
- remediation: "Configure @workflow/world-postgres; see docs/deploy.md#durable-state-postgres-world.",
356
+ remediation: EVE_WORLD_LOCAL_REMEDIATION,
250
357
  });
251
358
  }
252
359
  if (manifest.dependencies?.["just-bash"] === undefined &&
@@ -301,7 +408,7 @@ async function collectEveWorldWarnings(candidate, runtimeConfig) {
301
408
  warnings.push({
302
409
  id: "EVE_WORLD_LOCAL",
303
410
  message: "Local World stores memory on container disk; updates or a lost sleep snapshot reset sessions and approvals.",
304
- remediation: "Configure a durable Workflow World such as @workflow/world-postgres; see docs/deploy.md#durable-state-postgres-world.",
411
+ remediation: EVE_WORLD_LOCAL_REMEDIATION,
305
412
  });
306
413
  }
307
414
  }
@@ -471,13 +578,13 @@ function safeDockerEnvironment() {
471
578
  DOCKER_CONTEXT: process.env.DOCKER_CONTEXT,
472
579
  };
473
580
  }
474
- async function readWranglerJson(args) {
581
+ async function readWranglerJson(args, timeoutMs = 30_000) {
475
582
  try {
476
583
  const result = await execFileAsync(process.execPath, [wranglerEntrypoint, ...args], {
477
584
  env: safeWranglerEnvironment(),
478
585
  cwd: wranglerCwd,
479
586
  maxBuffer: 2 * 1024 * 1024,
480
- timeout: 30_000,
587
+ timeout: timeoutMs,
481
588
  });
482
589
  const parsed = JSON.parse(result.stdout.trim());
483
590
  return { value: parsed, failed: false };
@@ -813,11 +920,33 @@ async function resolveEveTargetOwnership(request, result) {
813
920
  evidence.containerNamespaceId === evidence.workerContainerNamespaceId);
814
921
  const remoteProven = evidence !== undefined && workerProven && containerProven;
815
922
  if (!remoteProven) {
923
+ const failures = [];
924
+ if (evidence === undefined)
925
+ failures.push("provider evidence unavailable");
926
+ if (result.target.observed?.worker === true) {
927
+ if (evidence?.settingsReadable !== true)
928
+ failures.push("Worker settings unreadable");
929
+ else if (evidence.workerDeploymentId === undefined)
930
+ failures.push("Worker deployment stamp absent");
931
+ else if (!recordedDeploymentIds.has(evidence.workerDeploymentId))
932
+ failures.push("Worker deployment stamp differs from recorded identities");
933
+ }
934
+ if (result.target.observed?.container === true) {
935
+ if (evidence?.containerSchedulingPolicy !== "durable_object")
936
+ failures.push("Container scheduling policy unproven");
937
+ if (evidence?.containerNamespaceId === undefined)
938
+ failures.push("Container namespace unreadable");
939
+ if (evidence?.workerContainerNamespaceId === undefined)
940
+ failures.push("Worker Container binding namespace unreadable");
941
+ if (evidence?.containerNamespaceId !== undefined && evidence.workerContainerNamespaceId !== undefined &&
942
+ evidence.containerNamespaceId !== evidence.workerContainerNamespaceId)
943
+ failures.push("Container and Worker binding namespaces differ");
944
+ }
816
945
  return {
817
946
  ...result,
818
947
  target: {
819
948
  ...result.target,
820
- message: "The exact Worker or Container target exists, and although a local Eden record matches, provider state cannot prove it is the one Eden deployed — the target may have been recreated outside Eden.",
949
+ message: `The exact Worker or Container target exists, and although a local Eden record matches, provider state cannot prove it is the one Eden deployed — the target may have been recreated outside Eden. Unproven: ${failures.join("; ")}.`,
821
950
  remediation: "Inspect the Worker at its workers.dev origin and the recorded deployment history before redeploying; if it is not Eden's, deploy under a different --name or delete it yourself.",
822
951
  },
823
952
  };
@@ -995,11 +1124,17 @@ function transportIdentityMatches(expected, observed) {
995
1124
  "generationId",
996
1125
  "stableWorkersDevOrigin",
997
1126
  "workerName",
998
- "stableContainerInstanceName",
999
1127
  ];
1000
1128
  if (!required.every((key) => observed[key] === expected[key])) {
1001
1129
  return false;
1002
1130
  }
1131
+ const expectedInstance = expected.retainedDeployments === undefined ||
1132
+ expected.deploymentId === "eden-cloudflare"
1133
+ ? expected.stableContainerInstanceName
1134
+ : `${expected.stableContainerInstanceName}:${expected.deploymentId}`;
1135
+ if (observed.stableContainerInstanceName !== expectedInstance ||
1136
+ observed.releaseId !== expected.releaseId)
1137
+ return false;
1003
1138
  return expected.runtimeRevisionHandle === undefined
1004
1139
  ? observed.runtimeRevisionHandle === undefined
1005
1140
  : observed.runtimeRevisionHandle === expected.runtimeRevisionHandle;
@@ -1012,6 +1147,7 @@ function identityMatches(expected, observed) {
1012
1147
  "sourceDigest",
1013
1148
  "generationId",
1014
1149
  "deploymentId",
1150
+ "releaseId",
1015
1151
  "environment",
1016
1152
  "name",
1017
1153
  "accountId",
@@ -1039,6 +1175,11 @@ function safeIdentity(identity) {
1039
1175
  sourceDigest: identity.sourceDigest,
1040
1176
  generationId: identity.generationId,
1041
1177
  deploymentId: identity.deploymentId,
1178
+ releaseId: identity.releaseId,
1179
+ eveVersion: identity.eveVersion,
1180
+ affinityContract: identity.affinityContract,
1181
+ retainedDeployments: identity.retainedDeployments,
1182
+ pendingRetirements: identity.pendingRetirements,
1042
1183
  environment: identity.environment,
1043
1184
  name: identity.name,
1044
1185
  accountId: identity.accountId,
@@ -1281,6 +1422,7 @@ function responseIdentity(response, body) {
1281
1422
  read("sourceDigest", "x-eden-eve-source-digest");
1282
1423
  read("generationId", "x-eden-eve-generation-id");
1283
1424
  read("deploymentId", "x-eden-eve-deployment-id");
1425
+ read("releaseId", "x-eden-eve-release-id");
1284
1426
  read("environment", "x-eden-eve-environment");
1285
1427
  read("name", "x-eden-eve-name");
1286
1428
  read("accountId", "x-eden-eve-account-id");
@@ -1341,7 +1483,7 @@ function defaultHealthRunner() {
1341
1483
  // must wait until the *served container* verifiably runs this
1342
1484
  // generation's image (the host stamps the header only after
1343
1485
  // inspecting the running instance).
1344
- if (observedIdentity.startedDeploymentId !== request.identity.deploymentId) {
1486
+ if (observedIdentity.startedDeploymentId !== (request.identity.releaseId ?? request.identity.deploymentId)) {
1345
1487
  lastReason =
1346
1488
  "The served Container has not verifiably started this deployment's image yet; waiting for the rollout to land.";
1347
1489
  await wait();
@@ -1460,8 +1602,8 @@ async function promoteEveTargetPointer(deploymentRoot, pointerKey, generationRoo
1460
1602
  }
1461
1603
  return currentPath;
1462
1604
  }
1463
- async function writeEveDeploymentRecord(candidate, identity, hostConfig, status, extra = {}) {
1464
- await writeFile(join(candidate.generationRoot, "deployment.json"), `${JSON.stringify({
1605
+ async function writeEveDeploymentRecord(candidate, identity, hostConfig, status, extra = {}, filename = "deployment.json") {
1606
+ await writeFile(join(candidate.generationRoot, filename), `${JSON.stringify({
1465
1607
  version: 1,
1466
1608
  status,
1467
1609
  identity: safeIdentity(identity),
@@ -1474,6 +1616,10 @@ async function writeEveDeploymentRecord(candidate, identity, hostConfig, status,
1474
1616
  flag: "wx",
1475
1617
  });
1476
1618
  }
1619
+ async function deploymentRecordPath(generationRoot) {
1620
+ const path = join(generationRoot, "deployment.json");
1621
+ return await lstat(path).then(() => path, () => join(generationRoot, "deployment-intent.json"));
1622
+ }
1477
1623
  async function writeEveIndeterminateEvidence(candidate, evidence, extra = {}) {
1478
1624
  await writeFile(join(candidate.generationRoot, "deployment-attempt.json"), `${JSON.stringify({
1479
1625
  version: 1,
@@ -1538,6 +1684,14 @@ async function runEveDeployment(request, options, collected) {
1538
1684
  }
1539
1685
  const priorIdentity = prior?.identity;
1540
1686
  const priorDurableObjects = prior?.durableObjects;
1687
+ const worldManifest = collected.workflowWorld === "@moinulmoin/eden-world-cloudflare"
1688
+ ? await readInstalledManifest(candidate.snapshotRoot, collected.workflowWorld)
1689
+ : undefined;
1690
+ const affinityEnabled = worldManifest?.eden?.capabilities?.deploymentAffinity === true;
1691
+ const worldRpcAuth = worldManifest?.eden?.capabilities?.worldRpcAuth === true;
1692
+ if (!affinityEnabled && priorIdentity?.retainedDeployments !== undefined) {
1693
+ throw deploymentFailure("EVE_AFFINITY_REQUIRED", "This target retains affinity deployments; install a World package declaring deploymentAffinity before updating it.");
1694
+ }
1541
1695
  const superseded = isUpdate
1542
1696
  ? {
1543
1697
  generationId: priorIdentity?.generationId,
@@ -1553,8 +1707,12 @@ async function runEveDeployment(request, options, collected) {
1553
1707
  if (durableWorld) {
1554
1708
  resultWarnings.push({
1555
1709
  id: "EVE_UPDATE_IN_FLIGHT",
1556
- message: "This update replaces the running Container; durable World runs in flight resume on the new code — Cloudflare has no deployment pinning, so keep step names and payload shapes backward compatible.",
1557
- remediation: "Let pending approvals and long runs settle before updating, or keep step names, queue names, and serialized payloads compatible across the change.",
1710
+ message: affinityEnabled
1711
+ ? "This update retains pinned deployments until their sessions, queue messages, and transfers drain."
1712
+ : "This update replaces the running Container; an Eve version change can strand existing sessions because the installed World does not declare deployment affinity.",
1713
+ remediation: affinityEnabled
1714
+ ? "End old sessions to allow their deployment images to retire."
1715
+ : "Let sessions settle before changing Eve versions, or install an affinity-capable World package.",
1558
1716
  });
1559
1717
  }
1560
1718
  if (prior?.worldCloudflare !==
@@ -1595,6 +1753,80 @@ async function runEveDeployment(request, options, collected) {
1595
1753
  throw deploymentFailure("EVE_IMAGE_IDENTITY_UNAVAILABLE", "The deploy candidate did not provide an immutable runtime image digest.");
1596
1754
  }
1597
1755
  const targetKey = stableDeploymentKey(collected.projectId, accountId, request.environment, request.name);
1756
+ const releaseId = `eve-deploy-${randomUUID()}`;
1757
+ let previousEveVersion = priorIdentity?.eveVersion;
1758
+ if (previousEveVersion === undefined && prior !== undefined) {
1759
+ previousEveVersion = (await readInstalledManifest(join(prior.generationRoot, "container/snapshot"), "eve"))?.version;
1760
+ }
1761
+ const retainedDeployments = {
1762
+ ...priorIdentity?.retainedDeployments,
1763
+ };
1764
+ if (affinityEnabled && priorIdentity?.retainedDeployments === undefined &&
1765
+ priorIdentity?.containerImage !== undefined && priorIdentity.generationId !== undefined) {
1766
+ retainedDeployments["eden-cloudflare"] = {
1767
+ image: priorIdentity.containerImage,
1768
+ generationId: priorIdentity.generationId,
1769
+ ...(previousEveVersion === undefined ? {} : { eveVersion: previousEveVersion }),
1770
+ affinityContract: EDEN_AFFINITY_CONTRACT,
1771
+ ...(priorIdentity.runtimeRevisionHandle === undefined ? {} : { runtimeRevisionHandle: priorIdentity.runtimeRevisionHandle }),
1772
+ };
1773
+ }
1774
+ const matchingDeployment = Object.entries(retainedDeployments).find(([, entry]) => entry.eveVersion === candidate.eveVersion && entry.affinityContract === EDEN_AFFINITY_CONTRACT)?.[0];
1775
+ const deploymentId = !affinityEnabled ? releaseId : matchingDeployment ??
1776
+ `eden-affinity-${createHash("sha256").update(`${candidate.eveVersion}:${EDEN_AFFINITY_CONTRACT}`).digest("hex").slice(0, 24)}`;
1777
+ const retiringDeployments = { ...priorIdentity?.pendingRetirements };
1778
+ delete retiringDeployments[deploymentId];
1779
+ if (priorIdentity?.deploymentId !== undefined)
1780
+ delete retiringDeployments[priorIdentity.deploymentId];
1781
+ for (const id of prior?.protectedDeploymentIds ?? [])
1782
+ delete retiringDeployments[id];
1783
+ const hasRetainedNoncurrentDeployments = priorIdentity?.retainedDeployments !== undefined &&
1784
+ Object.keys(retainedDeployments).some((id) => id !== priorIdentity.deploymentId);
1785
+ const controlToken = `${randomUUID()}${randomUUID()}`;
1786
+ if (affinityEnabled && (hasRetainedNoncurrentDeployments || Object.keys(retiringDeployments).length > 0)) {
1787
+ await (options.protectedStore ?? defaultProtectedStore(request.name)).put({
1788
+ targetId: targetKey, revision: `eve-affinity-control-${randomUUID()}`,
1789
+ inputIdentity: {
1790
+ token: `eve-affinity-control-input-${randomUUID()}`,
1791
+ byteLength: Buffer.byteLength(controlToken),
1792
+ digest: createHash("sha256").update(controlToken).digest("hex"),
1793
+ },
1794
+ variableNames: ["EDEN_EVE_CONTROL_TOKEN"], values: { EDEN_EVE_CONTROL_TOKEN: controlToken },
1795
+ });
1796
+ }
1797
+ if (affinityEnabled && hasRetainedNoncurrentDeployments) {
1798
+ const ids = Object.keys(retainedDeployments);
1799
+ const raw = await (options.deploymentRpc ?? defaultDeploymentRpc)({
1800
+ origin: stableWorkersDevOrigin, operation: "deployments.drainState", deploymentIds: ids,
1801
+ controlToken,
1802
+ });
1803
+ const states = validateDeploymentDrainStates(raw, ids);
1804
+ const eligible = ids.filter((id) => id !== deploymentId && id !== priorIdentity.deploymentId &&
1805
+ !prior?.protectedDeploymentIds?.includes(id) && states[id]?.drained === true);
1806
+ const remainingCount = ids.length - eligible.length + (deploymentId in retainedDeployments ? 0 : 1);
1807
+ if (remainingCount <= 10 && eligible.length > 0) {
1808
+ // Drain snapshots are not fences. Retire only noncurrent targets, and
1809
+ // obtain the World's durable admission fence before dropping routes.
1810
+ const retired = await (options.deploymentRpc ?? defaultDeploymentRpc)({
1811
+ origin: stableWorkersDevOrigin, operation: "deployments.retire",
1812
+ deploymentIds: eligible, controlToken,
1813
+ });
1814
+ if (typeof retired !== "object" || retired === null || !("retired" in retired) ||
1815
+ !Array.isArray(retired.retired) ||
1816
+ eligible.some((id) => !retired.retired.includes(id))) {
1817
+ throw deploymentFailure("EVE_AFFINITY_RETIRE_UNPROVEN", "The World did not prove the noncurrent deployments durably fenced for retirement.");
1818
+ }
1819
+ for (const id of eligible) {
1820
+ retiringDeployments[id] = retainedDeployments[id];
1821
+ delete retainedDeployments[id];
1822
+ }
1823
+ }
1824
+ if (Object.keys(retainedDeployments).length + (deploymentId in retainedDeployments ? 0 : 1) > 10) {
1825
+ const blockers = Object.entries(states).filter(([id]) => id in retainedDeployments)
1826
+ .map(([id, state]) => `${id}: ${state.liveSessions} live sessions, ${state.liveRuns} live runs, ${state.pendingMessages} pending messages, ${state.unfinishedTransfers} unfinished transfers`);
1827
+ throw deploymentFailure("EVE_AFFINITY_LIMIT", `Deployment refused before image/code publication: at most 10 affinity deployments may be retained. Blocking deployments: ${blockers.join("; ")}`);
1828
+ }
1829
+ }
1598
1830
  let imageReference = options.containerImageReference;
1599
1831
  if (imageReference !== undefined) {
1600
1832
  if (imageDigestFromReference(imageReference) !== imageDigest) {
@@ -1607,7 +1839,7 @@ async function runEveDeployment(request, options, collected) {
1607
1839
  if (localImageReference === undefined || localImageReference.length === 0) {
1608
1840
  throw deploymentFailure("EVE_IMAGE_IDENTITY_UNAVAILABLE", "The deploy candidate did not return an exact local image handle for publication.");
1609
1841
  }
1610
- const imageRepository = `eden-eve-${targetKey}-${candidate.generationId}`;
1842
+ const imageRepository = `eden-eve-${targetKey}-${affinityEnabled ? deploymentId : candidate.generationId}`;
1611
1843
  const imageTag = `${imageRepository}:${EVE_REGISTRY_IMAGE_TAG}`;
1612
1844
  const targetImageReference = `registry.cloudflare.com/${accountId}/${imageRepository}@${imageDigest}`;
1613
1845
  const imagePublication = await (options.publishImage ??
@@ -1669,7 +1901,6 @@ async function runEveDeployment(request, options, collected) {
1669
1901
  throw deploymentFailure("DEPLOY_INDETERMINATE", reason);
1670
1902
  }
1671
1903
  }
1672
- const deploymentId = `eve-deploy-${randomUUID()}`;
1673
1904
  const containerApplicationName = boundedResourceName(request.name, "container");
1674
1905
  const stableContainerInstanceName = boundedResourceName(request.name, "instance");
1675
1906
  const runtimeVariableNames = candidate.runtimeVariableNames;
@@ -1678,6 +1909,24 @@ async function runEveDeployment(request, options, collected) {
1678
1909
  sourceDigest: candidate.sourceDigest,
1679
1910
  generationId: candidate.generationId,
1680
1911
  deploymentId,
1912
+ releaseId,
1913
+ eveVersion: candidate.eveVersion,
1914
+ ...(affinityEnabled ? {
1915
+ affinityContract: EDEN_AFFINITY_CONTRACT,
1916
+ retainedDeployments: {
1917
+ ...retainedDeployments,
1918
+ [deploymentId]: {
1919
+ image: imageReference,
1920
+ generationId: candidate.generationId,
1921
+ eveVersion: candidate.eveVersion,
1922
+ affinityContract: EDEN_AFFINITY_CONTRACT,
1923
+ releaseId,
1924
+ worldRpcAuth,
1925
+ ...(runtimeInjection?.protectedHandle === undefined ? {} : { runtimeRevisionHandle: runtimeInjection.protectedHandle }),
1926
+ },
1927
+ },
1928
+ pendingRetirements: retiringDeployments,
1929
+ } : {}),
1681
1930
  environment: request.environment,
1682
1931
  name: request.name,
1683
1932
  accountId,
@@ -1698,6 +1947,11 @@ async function runEveDeployment(request, options, collected) {
1698
1947
  containerApplicationName: identity.containerApplicationName,
1699
1948
  stableContainerInstanceName: identity.stableContainerInstanceName,
1700
1949
  deploymentId: identity.deploymentId,
1950
+ worldRpcAuth,
1951
+ ...(identity.releaseId === undefined ? {} : { releaseId: identity.releaseId }),
1952
+ ...(identity.eveVersion === undefined ? {} : { eveVersion: identity.eveVersion }),
1953
+ ...(identity.affinityContract === undefined ? {} : { affinityContract: identity.affinityContract }),
1954
+ ...(identity.retainedDeployments === undefined ? {} : { retainedDeployments: identity.retainedDeployments }),
1701
1955
  generationId: identity.generationId,
1702
1956
  stableWorkersDevOrigin: identity.stableWorkersDevOrigin,
1703
1957
  containerImage: identity.containerImage,
@@ -1753,6 +2007,7 @@ async function runEveDeployment(request, options, collected) {
1753
2007
  throw error;
1754
2008
  }
1755
2009
  };
2010
+ await writeEveDeploymentRecord(candidate, identity, hostConfig, "indeterminate", { evidence: "publication-intent" }, "deployment-intent.json");
1756
2011
  try {
1757
2012
  eveProgress(options, "publishing Worker and Container application");
1758
2013
  publication = await publishOne(publicationRequest);
@@ -1955,7 +2210,40 @@ async function runEveDeployment(request, options, collected) {
1955
2210
  await promoteEveTargetPointer(join(request.projectRoot, ".eden", "eve-deploy"), `${request.environment}-${request.name}`, candidate.generationRoot);
1956
2211
  await options.afterPromotion?.();
1957
2212
  let supersededImageCleanup;
1958
- if (isUpdate) {
2213
+ if (affinityEnabled && Object.keys(retiringDeployments).length > 0) {
2214
+ const ids = Object.keys(retiringDeployments);
2215
+ const completedRetirements = [];
2216
+ try {
2217
+ const result = await (options.deploymentRpc ?? defaultDeploymentRpc)({
2218
+ origin: stableWorkersDevOrigin, operation: "deployments.retire", deploymentIds: ids,
2219
+ controlToken,
2220
+ });
2221
+ if (typeof result !== "object" || result === null ||
2222
+ !("retired" in result) || !Array.isArray(result.retired) ||
2223
+ ids.some((id) => !result.retired.includes(id)))
2224
+ throw new Error("Retirement was not proven.");
2225
+ for (const [id, retained] of Object.entries(retiringDeployments)) {
2226
+ const repository = ownedRegistryRepository({
2227
+ ...identity, deploymentId: id, containerImage: retained.image, generationId: retained.generationId,
2228
+ }, request.environment, request.name, accountId);
2229
+ if (repository !== undefined && await (options.deleteRegistryRepository ??
2230
+ defaultRegistryRepositoryDelete)({ repository }) === "indeterminate") {
2231
+ throw new Error(`Repository deletion was not proven: ${repository}`);
2232
+ }
2233
+ completedRetirements.push(id);
2234
+ }
2235
+ supersededImageCleanup = check("VAL-LIFE-007", "passed", "Drained deployment instances and their owned registry repositories were retired.");
2236
+ }
2237
+ catch {
2238
+ supersededImageCleanup = check("VAL-LIFE-007", "failed", "The deployment is healthy, but drained resource retirement was not proven; exact pending retirement records were retained for retry.", "Retry deploy to finish only recorded drained resource cleanup.");
2239
+ }
2240
+ if (completedRetirements.length > 0) {
2241
+ // Keep the immutable deployment record as evidence, but exclude proven
2242
+ // cleanup from the next deploy's active retry set.
2243
+ await writeFile(join(candidate.generationRoot, "completed-retirements.json"), `${JSON.stringify(completedRetirements)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
2244
+ }
2245
+ }
2246
+ if (isUpdate && !affinityEnabled) {
1959
2247
  // The superseded generation's registry repository is generation-scoped
1960
2248
  // and no longer referenced once CURRENT promotes this generation; only
1961
2249
  // then is it safe to delete. Every tag in the repository goes with it —
@@ -2182,6 +2470,7 @@ async function collectEvePreflight(request, options) {
2182
2470
  if (localHint !== -1)
2183
2471
  warnings.splice(localHint, 1);
2184
2472
  warnings.push(...(await collectEveWorldWarnings(candidate, undefined)));
2473
+ warnings.push(...await collectEveGatewayWarnings(candidate, runtimeConfig));
2185
2474
  const schedules = await readCompiledSchedules(candidate);
2186
2475
  const expressible = schedules.filter((schedule) => parseEveScheduleCron(schedule.cron) !== undefined);
2187
2476
  const unexpressible = schedules.filter((schedule) => parseEveScheduleCron(schedule.cron) === undefined);
@@ -2512,7 +2801,7 @@ async function readEveDestroyRecord(projectRoot, environment, name) {
2512
2801
  if (generationRoot === undefined) {
2513
2802
  return readEveDestroyRecordFromGenerations(projectRoot, environment, name);
2514
2803
  }
2515
- const parsed = await parseEveDestroyRecord(join(generationRoot, "deployment.json"), environment, name);
2804
+ const parsed = await parseEveDestroyRecord(await deploymentRecordPath(generationRoot), environment, name);
2516
2805
  return parsed === undefined
2517
2806
  ? undefined
2518
2807
  : { ...parsed, generationRoot };
@@ -2526,7 +2815,7 @@ async function readEveDestroyRecordFromGenerations(projectRoot, environment, nam
2526
2815
  const details = await lstat(generationRoot).catch(() => undefined);
2527
2816
  if (details === undefined || !details.isDirectory())
2528
2817
  continue;
2529
- const recordDetails = await lstat(join(generationRoot, "deployment.json")).catch(() => undefined);
2818
+ const recordDetails = await lstat(await deploymentRecordPath(generationRoot)).catch(() => undefined);
2530
2819
  if (recordDetails !== undefined && recordDetails.isFile()) {
2531
2820
  withRecords.push({
2532
2821
  generationRoot,
@@ -2536,7 +2825,7 @@ async function readEveDestroyRecordFromGenerations(projectRoot, environment, nam
2536
2825
  }
2537
2826
  withRecords.sort((left, right) => right.modifiedAt - left.modifiedAt);
2538
2827
  for (const { generationRoot } of withRecords) {
2539
- const parsed = await parseEveDestroyRecord(join(generationRoot, "deployment.json"), environment, name);
2828
+ const parsed = await parseEveDestroyRecord(await deploymentRecordPath(generationRoot), environment, name);
2540
2829
  if (parsed !== undefined) {
2541
2830
  return { ...parsed, generationRoot };
2542
2831
  }
@@ -2581,7 +2870,35 @@ async function parseEveDestroyRecord(recordPath, environment, name) {
2581
2870
  if (identity.environment !== environment || identity.name !== name) {
2582
2871
  return undefined;
2583
2872
  }
2873
+ const metadata = {};
2874
+ for (const key of ["deploymentId", "eveVersion", "releaseId", "runtimeRevisionHandle"]) {
2875
+ if (typeof identity[key] === "string")
2876
+ Object.assign(metadata, { [key]: identity[key] });
2877
+ }
2878
+ if (typeof identity.affinityContract === "number")
2879
+ Object.assign(metadata, { affinityContract: identity.affinityContract });
2880
+ for (const key of ["retainedDeployments", "pendingRetirements"]) {
2881
+ const value = identity[key];
2882
+ if (value === undefined)
2883
+ continue;
2884
+ if (typeof value !== "object" || value === null || Array.isArray(value))
2885
+ return undefined;
2886
+ for (const [id, deployment] of Object.entries(value)) {
2887
+ if (!/^(?:eden-cloudflare|eden-affinity-[a-f0-9]{24})$/u.test(id) ||
2888
+ typeof deployment !== "object" || deployment === null ||
2889
+ typeof deployment.image !== "string" || imageDigestFromReference(deployment.image) === undefined ||
2890
+ typeof deployment.generationId !== "string" ||
2891
+ (deployment.eveVersion !== undefined && typeof deployment.eveVersion !== "string") ||
2892
+ (deployment.affinityContract !== undefined && typeof deployment.affinityContract !== "number") ||
2893
+ (deployment.releaseId !== undefined && typeof deployment.releaseId !== "string") ||
2894
+ (deployment.worldRpcAuth !== undefined && typeof deployment.worldRpcAuth !== "boolean") ||
2895
+ (deployment.runtimeRevisionHandle !== undefined && typeof deployment.runtimeRevisionHandle !== "string"))
2896
+ return undefined;
2897
+ }
2898
+ Object.assign(metadata, { [key]: value });
2899
+ }
2584
2900
  const verified = {
2901
+ ...metadata,
2585
2902
  workerName: identity.workerName,
2586
2903
  containerApplicationName: identity.containerApplicationName,
2587
2904
  stableContainerInstanceName: identity.stableContainerInstanceName,
@@ -2620,7 +2937,7 @@ async function readEveDeploymentRecord(projectRoot, environment, name, accountId
2620
2937
  const deploymentIds = new Set();
2621
2938
  for (const entry of entries) {
2622
2939
  const generationRoot = join(generationsRoot, entry);
2623
- const recordPath = join(generationRoot, "deployment.json");
2940
+ const recordPath = await deploymentRecordPath(generationRoot);
2624
2941
  const details = await lstat(recordPath).catch(() => undefined);
2625
2942
  if (details === undefined || !details.isFile())
2626
2943
  continue;
@@ -2653,7 +2970,7 @@ async function readEveDeploymentRecord(projectRoot, environment, name, accountId
2653
2970
  live = pinned;
2654
2971
  }
2655
2972
  else {
2656
- const parsed = await parseEveDeploymentRecord(join(generationRoot, "deployment.json"), environment, name);
2973
+ const parsed = await parseEveDeploymentRecord(await deploymentRecordPath(generationRoot), environment, name);
2657
2974
  if (parsed !== undefined &&
2658
2975
  !((accountId !== undefined &&
2659
2976
  parsed.identity.accountId !== accountId) ||
@@ -2669,10 +2986,31 @@ async function readEveDeploymentRecord(projectRoot, environment, name, accountId
2669
2986
  // Newest record carrying a usable DO history wins, regardless of its
2670
2987
  // deployment outcome: a tag it published must never be replayed.
2671
2988
  const historySource = scanned.find((entry) => entry.record.durableObjects !== undefined);
2989
+ // CURRENT is only a health/promotion pointer. A newer uncertain publication
2990
+ // may already serve sessions; preserve every such image until a later deploy
2991
+ // can prove it drained, never infer absence from a failed health check.
2992
+ const unresolved = scanned.filter((entry) => entry.modifiedAt > live.modifiedAt);
2993
+ const retainedDeployments = { ...live.record.identity.retainedDeployments };
2994
+ const pendingRetirements = { ...live.record.identity.pendingRetirements };
2995
+ const protectedDeploymentIds = new Set();
2996
+ if (live.record.identity.retainedDeployments === undefined) {
2997
+ protectedDeploymentIds.add("eden-cloudflare");
2998
+ }
2999
+ for (const entry of unresolved.slice().reverse()) {
3000
+ Object.assign(retainedDeployments, entry.record.identity.retainedDeployments);
3001
+ Object.assign(pendingRetirements, entry.record.identity.pendingRetirements);
3002
+ if (entry.record.deploymentId !== undefined)
3003
+ protectedDeploymentIds.add(entry.record.deploymentId);
3004
+ }
2672
3005
  return {
2673
3006
  recordPath: live.record.recordPath,
2674
3007
  generationRoot: live.generationRoot,
2675
- identity: live.record.identity,
3008
+ identity: {
3009
+ ...live.record.identity,
3010
+ ...(Object.keys(retainedDeployments).length === 0 ? {} : { retainedDeployments }),
3011
+ ...(Object.keys(pendingRetirements).length === 0 ? {} : { pendingRetirements }),
3012
+ },
3013
+ protectedDeploymentIds: [...protectedDeploymentIds],
2676
3014
  status: live.record.status,
2677
3015
  deploymentIds: [...deploymentIds],
2678
3016
  worldCloudflare: live.record.worldCloudflare,
@@ -2683,6 +3021,19 @@ async function parseEveDeploymentRecord(recordPath, environment, name) {
2683
3021
  const base = await parseEveDestroyRecord(recordPath, environment, name);
2684
3022
  if (base === undefined)
2685
3023
  return undefined;
3024
+ let identity = base.identity;
3025
+ try {
3026
+ const completed = JSON.parse(await readFile(join(dirname(recordPath), "completed-retirements.json"), "utf8"));
3027
+ if (Array.isArray(completed) && completed.every((id) => typeof id === "string")) {
3028
+ const pendingRetirements = { ...identity.pendingRetirements };
3029
+ for (const id of completed)
3030
+ delete pendingRetirements[id];
3031
+ identity = { ...identity, pendingRetirements };
3032
+ }
3033
+ }
3034
+ catch {
3035
+ // Missing or unreadable cleanup evidence must not suppress a retry.
3036
+ }
2686
3037
  let parsed;
2687
3038
  try {
2688
3039
  parsed = JSON.parse(await readFile(recordPath, "utf8"));
@@ -2726,7 +3077,7 @@ async function parseEveDeploymentRecord(recordPath, environment, name) {
2726
3077
  Array.isArray(rawMigrations);
2727
3078
  const invalidHistory = () => ({
2728
3079
  recordPath,
2729
- identity: base.identity,
3080
+ identity,
2730
3081
  status,
2731
3082
  deploymentId,
2732
3083
  worldCloudflare: rawVars?.EDEN_EVE_WORLD_CLOUDFLARE === true,
@@ -2753,7 +3104,7 @@ async function parseEveDeploymentRecord(recordPath, environment, name) {
2753
3104
  }
2754
3105
  return {
2755
3106
  recordPath,
2756
- identity: base.identity,
3107
+ identity,
2757
3108
  status,
2758
3109
  deploymentId,
2759
3110
  worldCloudflare: rawVars?.EDEN_EVE_WORLD_CLOUDFLARE === true,
@@ -2775,7 +3126,7 @@ async function defaultDestroyTargetRead(request) {
2775
3126
  "images",
2776
3127
  "list",
2777
3128
  "--json",
2778
- ]);
3129
+ ], WRANGLER_OPERATION_TIMEOUT_MS);
2779
3130
  const tagsByRepository = registryTagsByRepository(images.value);
2780
3131
  if (!images.failed && tagsByRepository !== undefined) {
2781
3132
  const present = [];
@@ -2854,12 +3205,13 @@ async function defaultContainerDelete(request) {
2854
3205
  * repository is deleted individually.
2855
3206
  */
2856
3207
  async function defaultRegistryRepositoryDelete(request) {
3208
+ // Catalog reads include Cloudflare snapshot tags and can exceed 30 seconds.
2857
3209
  const images = await readWranglerJson([
2858
3210
  "containers",
2859
3211
  "images",
2860
3212
  "list",
2861
3213
  "--json",
2862
- ]);
3214
+ ], WRANGLER_OPERATION_TIMEOUT_MS);
2863
3215
  const collection = jsonCollection(images.value);
2864
3216
  if (images.failed || collection === undefined)
2865
3217
  return "indeterminate";
@@ -2885,9 +3237,11 @@ async function defaultRegistryRepositoryDelete(request) {
2885
3237
  deletedAny = true;
2886
3238
  continue;
2887
3239
  }
2888
- if (!/(?:does not exist|not found|404)/iu.test(result.stderr)) {
2889
- return "indeterminate";
2890
- }
3240
+ }
3241
+ const after = await readWranglerJson(["containers", "images", "list", "--json"], WRANGLER_OPERATION_TIMEOUT_MS);
3242
+ const remaining = after.failed ? undefined : registryTagsByRepository(after.value);
3243
+ if (remaining === undefined || (remaining.get(request.repository)?.length ?? 0) > 0) {
3244
+ return "indeterminate";
2891
3245
  }
2892
3246
  return deletedAny ? "deleted" : "absent";
2893
3247
  }
@@ -2905,13 +3259,13 @@ function ownedRegistryRepository(identity, environment, name, accountId) {
2905
3259
  identity.accountId !== accountId) {
2906
3260
  return undefined;
2907
3261
  }
2908
- const repository = `eden-eve-${stableDeploymentKey(identity.projectId, identity.accountId, environment, name)}-${identity.generationId}`;
2909
- const expected = `registry.cloudflare.com/${identity.accountId}/${repository}@`;
2910
- if (!identity.containerImage.startsWith(expected) ||
2911
- imageDigestFromReference(identity.containerImage) === undefined) {
3262
+ const prefix = `eden-eve-${stableDeploymentKey(identity.projectId, identity.accountId, environment, name)}-`;
3263
+ const suffixes = [identity.generationId];
3264
+ if (identity.retainedDeployments !== undefined && identity.deploymentId !== undefined)
3265
+ suffixes.push(identity.deploymentId);
3266
+ if (imageDigestFromReference(identity.containerImage) === undefined)
2912
3267
  return undefined;
2913
- }
2914
- return repository;
3268
+ return suffixes.map((suffix) => `${prefix}${suffix}`).find((repository) => identity.containerImage.startsWith(`registry.cloudflare.com/${identity.accountId}/${repository}@`));
2915
3269
  }
2916
3270
  /**
2917
3271
  * The registry repository an indeterminate deploy attempt provably pushed.
@@ -2964,17 +3318,31 @@ async function collectEveDestroyRegistryRepositories(projectRoot, environment, n
2964
3318
  const generationsRoot = join(projectRoot, ".eden", "eve-deploy", "generations");
2965
3319
  const entries = await readdir(generationsRoot).catch(() => []);
2966
3320
  const repositories = new Set();
3321
+ const addRetained = (identity) => {
3322
+ for (const [id, retained] of Object.entries({
3323
+ ...identity.retainedDeployments, ...identity.pendingRetirements,
3324
+ })) {
3325
+ const repository = ownedRegistryRepository({
3326
+ ...identity, deploymentId: id, containerImage: retained.image, generationId: retained.generationId,
3327
+ }, environment, name, record.accountId);
3328
+ if (repository !== undefined)
3329
+ repositories.add(repository);
3330
+ }
3331
+ };
3332
+ addRetained(record);
2967
3333
  const targetKey = record.projectId === undefined
2968
3334
  ? undefined
2969
3335
  : stableDeploymentKey(record.projectId, record.accountId, environment, name);
2970
3336
  for (const entry of entries) {
2971
3337
  const generationRoot = join(generationsRoot, entry);
2972
- const parsed = await parseEveDestroyRecord(join(generationRoot, "deployment.json"), environment, name);
3338
+ const parsed = await parseEveDestroyRecord(await deploymentRecordPath(generationRoot), environment, name);
2973
3339
  const deployedRepository = parsed === undefined
2974
3340
  ? undefined
2975
3341
  : ownedRegistryRepository(parsed.identity, environment, name, record.accountId);
2976
3342
  if (deployedRepository !== undefined)
2977
3343
  repositories.add(deployedRepository);
3344
+ if (parsed !== undefined)
3345
+ addRetained(parsed.identity);
2978
3346
  if (targetKey === undefined)
2979
3347
  continue;
2980
3348
  const attemptRepository = await attemptRegistryRepository(join(generationRoot, "deployment-attempt.json"), record.accountId, targetKey);
@@ -1,4 +1,4 @@
1
- export declare const EVE_RESERVED_HOST_VARIABLES: readonly ["HOST", "NITRO_HOST", "PORT", "NITRO_PORT", "NODE_ENV", "NODE_EXTRA_CA_CERTS", "WORKFLOW_LOCAL_BASE_URL", "EDEN_WORLD_URL", "EDEN_EVE_DEPLOYMENT_ID", "EDEN_EVE_GENERATION_ID"];
1
+ export declare const EVE_RESERVED_HOST_VARIABLES: readonly ["HOST", "NITRO_HOST", "PORT", "NITRO_PORT", "NODE_ENV", "NODE_EXTRA_CA_CERTS", "WORKFLOW_LOCAL_BASE_URL", "EDEN_WORLD_URL", "EDEN_WORLD_TOKEN", "EDEN_EVE_WORLD_RPC_AUTH", "EDEN_EVE_DEPLOYMENT_ID", "EDEN_EVE_GENERATION_ID", "EDEN_EVE_RELEASE_ID", "EDEN_EVE_DEPLOYMENTS", "EDEN_AFFINITY_CONTRACT", "EVE_SCHEDULES_DISABLED", "EDEN_EVE_CONTROL_TOKEN"];
2
2
  export type EveReservedHostVariable = (typeof EVE_RESERVED_HOST_VARIABLES)[number];
3
3
  export declare const EVE_START_COMMAND: readonly ["./node_modules/.bin/eve", "start", "--host", "0.0.0.0", "--port", "8080"];
4
4
  export declare const EVE_HOST_ENVIRONMENT: Readonly<{
@@ -10,8 +10,15 @@ export const EVE_RESERVED_HOST_VARIABLES = Object.freeze([
10
10
  "NODE_EXTRA_CA_CERTS",
11
11
  "WORKFLOW_LOCAL_BASE_URL",
12
12
  "EDEN_WORLD_URL",
13
+ "EDEN_WORLD_TOKEN",
14
+ "EDEN_EVE_WORLD_RPC_AUTH",
13
15
  "EDEN_EVE_DEPLOYMENT_ID",
14
16
  "EDEN_EVE_GENERATION_ID",
17
+ "EDEN_EVE_RELEASE_ID",
18
+ "EDEN_EVE_DEPLOYMENTS",
19
+ "EDEN_AFFINITY_CONTRACT",
20
+ "EVE_SCHEDULES_DISABLED",
21
+ "EDEN_EVE_CONTROL_TOKEN",
15
22
  ]);
16
23
  export const EVE_START_COMMAND = Object.freeze([
17
24
  "./node_modules/.bin/eve",
@@ -458,6 +458,7 @@ COPY node_modules /candidate/node_modules
458
458
  ${sandboxContextCopy}${sourceEntries.map((entry) => `COPY ${JSON.stringify([entry, `/candidate/${entry}`])}`).join("\n")}
459
459
 
460
460
  FROM --platform=linux/amd64 ${imageReference(nodeImage)} AS runtime
461
+ RUN apt-get update && apt-get install -y --no-install-recommends python3 && rm -rf /var/lib/apt/lists/*
461
462
  WORKDIR /workspace
462
463
  ENV HOST=0.0.0.0 \\
463
464
  NITRO_HOST=0.0.0.0 \\
@@ -582,6 +583,8 @@ function runtimeContainerEnvironment(request) {
582
583
  ...(request.workflowWorld === "@moinulmoin/eden-world-cloudflare"
583
584
  ? {
584
585
  EDEN_WORLD_URL: `${request.publicOrigin ?? "http://localhost:8080"}/__eden/world/rpc`,
586
+ EDEN_EVE_DEPLOYMENT_ID: `eden-smoke-${request.candidate.generationId}`,
587
+ EDEN_EVE_RELEASE_ID: `eden-smoke-${request.candidate.generationId}`,
585
588
  CBOR_NATIVE_ACCELERATION_DISABLED: "true",
586
589
  }
587
590
  : {}),
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ Commands:
17
17
  preflight Build and inspect a local Eve candidate without remote mutation
18
18
  deploy Deploy the selected Eve project to the exact named target
19
19
  destroy Remove the exact owned Eve target
20
+ skill Print the bundled Eden skill for coding agents
20
21
 
21
22
  preflight and deploy default to the current directory, the preview
22
23
  environment, and a target name derived from package.json. destroy and
@@ -177,6 +178,24 @@ export async function runEdenCli(args, options = {}) {
177
178
  stdout(edenVersion());
178
179
  return 0;
179
180
  }
181
+ if (args[0] === "skill") {
182
+ if (args.length === 2 && (args[1] === "--help" || args[1] === "-h")) {
183
+ stdout("Usage: eden skill");
184
+ return 0;
185
+ }
186
+ if (args.length !== 1) {
187
+ throw new EveCliError({
188
+ code: "EVE_ARGUMENT_UNKNOWN",
189
+ message: "eden skill takes no arguments or options.",
190
+ });
191
+ }
192
+ const skill = readFileSync(new URL("./SKILL.md", import.meta.url), "utf8");
193
+ if (options.stdout === undefined)
194
+ process.stdout.write(skill);
195
+ else
196
+ options.stdout(skill);
197
+ return 0;
198
+ }
180
199
  if (!EVE_CLI_COMMANDS.includes(args[0] ?? "")) {
181
200
  throw new EveCliError({
182
201
  code: "COMMAND_UNKNOWN",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moinulmoin/eden",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Deploy existing Eve projects to your own Cloudflare account.",
5
5
  "keywords": [
6
6
  "ai",
@@ -27,11 +27,12 @@
27
27
  "dist/**/*.js",
28
28
  "dist/**/*.mjs",
29
29
  "dist/**/*.d.ts",
30
+ "dist/SKILL.md",
30
31
  "LICENSE",
31
32
  "NOTICE"
32
33
  ],
33
34
  "dependencies": {
34
- "@moinulmoin/eden-runtime-cloudflare": "0.9.0",
35
+ "@moinulmoin/eden-runtime-cloudflare": "0.11.0",
35
36
  "wrangler": "4.147.0"
36
37
  },
37
38
  "devDependencies": {
@@ -54,7 +55,7 @@
54
55
  },
55
56
  "scripts": {
56
57
  "build": "tsc -b",
57
- "postbuild": "chmod 755 dist/index.js",
58
+ "postbuild": "chmod 755 dist/index.js && cp ../../skills/eden/SKILL.md dist/SKILL.md",
58
59
  "typecheck": "tsc -b --pretty false",
59
60
  "lint": "eslint src --max-warnings 0",
60
61
  "test": "pnpm --dir ../.. exec vitest run packages/cli/test --passWithNoTests --maxWorkers=1"