Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/pages-delegation-branch-flags.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"wrangler": minor
---

Delegate agent Pages deploys that target a production branch to Workers

When run by an AI agent, `wrangler pages deploy --branch <name>` and `wrangler pages project create --production-branch <name>` are now eligible for delegation to a Workers static-assets deploy. Previously any `--branch` or `--production-branch` flag disqualified the command, which meant the most common agent invocation — deploying the main branch of a brand-new static project — fell through to a direct Pages deploy instead of being delegated.

Delegation only ever fires for a brand-new project, and on a new project a branch flag simply names the production branch, which is exactly what a Workers deploy targets, so there are no preview-deployment semantics to preserve. Genuinely Pages-only flags (`--commit-hash`, `--commit-message`, `--commit-dirty`, `--skip-caching`) still disqualify a deploy from delegation.
7 changes: 7 additions & 0 deletions .changeset/pages-delegation-per-project-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"wrangler": minor
---

Widen agent Pages-to-Workers delegation to new projects on accounts that already use Pages

When run by an AI agent, `wrangler pages deploy` and `wrangler pages project create` now delegate a brand-new static Pages project to a Workers static-assets deploy even when the account already has other Pages projects. The gate is now per-project rather than per-account: a command targeting a project that already exists stays on Pages, but a new project is delegated regardless of the account's other Pages projects.
76 changes: 45 additions & 31 deletions packages/wrangler/src/__tests__/pages/delegate-to-workers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,28 +47,31 @@ describe("maybeDelegatePagesToWorkers", () => {
expect(sendMetricsEvent).not.toHaveBeenCalled();
});

for (const command of ["deploy", "create"] as const) {
it(`does not delegate (or emit telemetry) when the account already has Pages projects (${command})`, async ({
expect,
}) => {
const result = await maybeDelegatePagesToWorkers({
command,
projectPath: process.cwd(),
accountHasPagesProjects: async () => true,
});

expect(result).toEqual({ delegate: false });
// Skips are deterministic, expected non-cases, so they are not sent to
// telemetry.
expect(sendMetricsEvent).not.toHaveBeenCalled();
it("does not delegate (or emit telemetry) when the deploy targets an existing Pages project", async ({
expect,
}) => {
const result = await maybeDelegatePagesToWorkers({
command: "deploy",
projectPath: process.cwd(),
projectExists: true,
});
}

it("delegates when the account has no Pages projects", async ({ expect }) => {
expect(result).toEqual({ delegate: false });
// Skips are deterministic, expected non-cases, so they are not sent to
// telemetry.
expect(sendMetricsEvent).not.toHaveBeenCalled();
});

it("delegates a new project even when the account already has other Pages projects", async ({
expect,
}) => {
// The gate is per-project, not per-account: `projectExists: false` means
// this specific project is new, so we delegate regardless of what else the
// account has.
const result = await maybeDelegatePagesToWorkers({
command: "deploy",
projectPath: process.cwd(),
accountHasPagesProjects: async () => false,
projectExists: false,
});

expect(result).toEqual({
Expand All @@ -79,37 +82,48 @@ describe("maybeDelegatePagesToWorkers", () => {
});
});

it("skips delegation (without emitting telemetry) when the account Pages projects lookup fails", async ({
it("does not delegate when a lazy projectExists resolver reports the project already exists", async ({
expect,
}) => {
const result = await maybeDelegatePagesToWorkers({
command: "deploy",
command: "create",
projectPath: process.cwd(),
accountHasPagesProjects: async () => {
throw new Error("boom");
},
projectExists: async () => true,
});

expect(result).toEqual({ delegate: false });
expect(sendMetricsEvent).not.toHaveBeenCalled();
});

it("does not query account Pages projects when a cheaper, local check already skips", async ({
it("delegates when a lazy projectExists resolver reports the project is new", async ({
expect,
}) => {
createFunctionsDir(process.cwd());
const accountHasPagesProjects = vi.fn(async () => true);
const result = await maybeDelegatePagesToWorkers({
command: "create",
projectPath: process.cwd(),
projectExists: async () => false,
});

expect(result).toEqual({
delegate: true,
command: "create",
agentId: "test-agent",
deployArgs: {},
});
});

it("skips delegation when the projectExists lookup throws, leaving the command on Pages", async ({
expect,
}) => {
const result = await maybeDelegatePagesToWorkers({
command: "deploy",
command: "create",
projectPath: process.cwd(),
accountHasPagesProjects,
projectExists: async () => {
throw new Error("boom");
},
});

expect(result).toEqual({ delegate: false });
// The functions/ directory is a local, no-cost skip reason, so the
// account-listing API call must never be made.
expect(accountHasPagesProjects).not.toHaveBeenCalled();
expect(sendMetricsEvent).not.toHaveBeenCalled();
});

Expand Down Expand Up @@ -210,7 +224,7 @@ describe("maybeDelegatePagesToWorkers", () => {
const result = await maybeDelegatePagesToWorkers({
command: "deploy",
projectPath: process.cwd(),
unsupportedArgs: ["--branch"],
unsupportedArgs: ["--commit-hash"],
});

expect(result).toEqual({ delegate: false });
Expand Down
45 changes: 45 additions & 0 deletions packages/wrangler/src/__tests__/pages/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
PAGES_CONFIG_CACHE_FILENAME,
ROUTES_SPEC_VERSION,
} from "../../pages/constants";
import { getUnsupportedDeployDelegateArgs } from "../../pages/deploy";
import { ApiErrorCodes } from "../../pages/errors";
import { isRoutesJSONSpec } from "../../pages/functions/routes-validation";
import { endEventLoop } from "../helpers/end-event-loop";
Expand Down Expand Up @@ -6650,3 +6651,47 @@ function mockGetProjectHandler(
{ once: true }
);
}

describe("getUnsupportedDeployDelegateArgs", () => {
type DeployArgs = Parameters<typeof getUnsupportedDeployDelegateArgs>[0];

it("does not treat --branch as unsupported, so a branch deploy stays eligible for delegation", ({
expect,
}) => {
const args = { branch: "main" } as DeployArgs;

expect(getUnsupportedDeployDelegateArgs(args)).toEqual([]);
});

it("still reports git-integration metadata and --skip-caching as unsupported", ({
expect,
}) => {
const args = {
commitHash: "abc123",
commitMessage: "a message",
commitDirty: true,
skipCaching: true,
} as DeployArgs;

expect(getUnsupportedDeployDelegateArgs(args)).toEqual([
"--commit-hash",
"--commit-message",
"--commit-dirty",
"--skip-caching",
]);
});

it("ignores boolean flags left at false", ({ expect }) => {
const args = { commitDirty: false, skipCaching: false } as DeployArgs;

expect(getUnsupportedDeployDelegateArgs(args)).toEqual([]);
});

it("reports only the genuinely unsupported flags when a branch is combined with commit metadata", ({
expect,
}) => {
const args = { branch: "main", commitHash: "abc123" } as DeployArgs;

expect(getUnsupportedDeployDelegateArgs(args)).toEqual(["--commit-hash"]);
});
});
64 changes: 38 additions & 26 deletions packages/wrangler/src/pages/delegate-to-workers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
* platform) without disrupting humans or existing Pages projects.
*
* The delegation is intentionally conservative: it only triggers for agents,
* never for accounts that already have Pages projects, and never for projects
* only for a new Pages project (never an existing project's deploy — but the
* account is free to already have other Pages projects), and never for projects
* that use any Pages feature we can't carry across to Workers (Pages Functions,
* advanced-mode `_worker.js`, or `_routes.json`).
*
Expand All @@ -32,17 +33,23 @@ export interface MaybeDelegatePagesToWorkersOptions {
/** The static-assets directory the user asked to deploy (pages deploy only) */
assetsDirectory?: string;
/**
* Resolves whether the account already has any Cloudflare Pages projects.
* When it resolves true we never delegate: an account that already uses Pages
* keeps using Pages, whatever command or project name was targeted.
* Whether the specific Pages project this command targets already exists.
* When it resolves true we never delegate: the command is updating an
* existing Pages project (or, for `pages project create`, clashing with an
* existing name), not creating a new one, so we leave it on Pages.
*
* A lazy callback rather than a boolean so the (paginated) list-projects API
* call only runs for agent sessions that have already passed every cheaper,
* local skip check. Non-agents, `--force` opt-outs, unsupported args, and
* unsupported Pages features all short-circuit before it is invoked, so they
* never pay for the extra request.
* This is deliberately per-project, not per-account: an account that already
* has other Pages projects is still delegated when the targeted project is
* new.
*
* A boolean when the caller already knows (e.g. `pages deploy` looks the
* project up for its own reasons), or a lazy resolver when it does not (e.g.
* `pages project create`), so the lookup only runs for agent sessions that
* have passed every cheaper, local skip check — humans and opted-out agents
* never pay for it. If the resolver throws we leave the command on Pages
* rather than risk delegating a project that may already exist.
*/
accountHasPagesProjects?: () => Promise<boolean>;
projectExists?: boolean | (() => Promise<boolean>);
/** When true, the user explicitly forced a direct Pages deployment (`--force`), so we never delegate. */
force?: boolean;
/** Project/worker name to carry across to the Workers deploy. */
Expand Down Expand Up @@ -157,8 +164,8 @@ export function logPagesToWorkersForceOptOutNotice(
*
* Returns `{ delegate: true }` once we commit to the delegation and the caller
* should NOT run the original Pages command. Returns `{ delegate: false }` when
* we deliberately did not delegate (not an agent, `--force`, an account that
* already has Pages projects, Pages-only CLI args, or an unsupported Pages
* we deliberately did not delegate (not an agent, `--force`, a Pages project
* that already exists, Pages-only CLI args, or an unsupported Pages
* feature) so the caller proceeds with the original Pages command. If the
* Workers deploy fails after the caller runs it, the caller must re-throw
* rather than falling back to Pages.
Expand Down Expand Up @@ -202,26 +209,31 @@ export async function maybeDelegatePagesToWorkers(
return { delegate: false };
}

// An account that already has Pages projects keeps using Pages — we only
// steer brand-new accounts onto Workers. Checked last because it is the only
// network call here: every cheaper, local skip reason above avoids it. If the
// lookup itself fails we skip delegation rather than risk disrupting a Pages
// user.
if (options.accountHasPagesProjects) {
let hasPagesProjects: boolean;
// A Pages project that already exists is an update (or, for `pages project
// create`, a clash with an existing name), not a new project, so we leave it
// on Pages. This is per-project, not per-account: an account with other Pages
// projects is still delegated when this project is new. Resolved last because
// the resolver may make a network call: every cheaper, local skip reason above
// avoids it. If the lookup fails we skip delegation rather than risk
// delegating a project that may already exist.
if (options.projectExists !== undefined) {
let projectExists: boolean;
try {
hasPagesProjects = await options.accountHasPagesProjects();
projectExists =
typeof options.projectExists === "function"
? await options.projectExists()
: options.projectExists;
} catch (e) {
logger.debug(
`Pages-to-Workers delegation: could not list account Pages projects (${
`Pages-to-Workers delegation: could not determine whether the target Pages project exists (${
e instanceof Error ? e.message : String(e)
})`
);
skipDelegate("account pages projects lookup failed");
skipDelegate("target project existence lookup failed");
return { delegate: false };
}
if (hasPagesProjects) {
skipDelegate("account has pages projects");
if (projectExists) {
skipDelegate("target pages project already exists");
return { delegate: false };
}
}
Expand Down Expand Up @@ -299,8 +311,8 @@ function buildWorkersDeployArgs(
* Logs (at debug level, for local visibility) why a delegation was skipped.
*
* Skips are deliberately not sent to telemetry: they are deterministic, expected
* non-cases (not an agent's brand-new static project — e.g. the account already
* has Pages projects, or the project uses an unsupported Pages feature), so the
* non-cases (not an agent's brand-new static project — e.g. the target project
* already exists, or the project uses an unsupported Pages feature), so the
* volume carries no signal. The number of skipped commands is derivable from the
* Pages command's own telemetry, so a dedicated event is not needed.
*/
Expand Down
26 changes: 20 additions & 6 deletions packages/wrangler/src/pages/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,14 +215,15 @@ export const pagesDeployCommand = createCommand({
}

// When run by an AI agent, delegate brand-new static Pages deploys to a
// Workers static-assets deploy. Existing projects, projects using
// unsupported Pages features, and `--force` are never delegated.
// Workers static-assets deploy. Deploys to an existing project, projects
// using unsupported Pages features, and `--force` are never delegated. The
// account is free to already have other Pages projects — only the specific
// project being targeted must be new.
const delegation = await maybeDelegatePagesToWorkers({
command: "deploy",
projectPath: process.cwd(),
assetsDirectory: directory,
accountHasPagesProjects: async () =>
(await listProjects({ accountId })).length > 0,
projectExists: Boolean(projectName) && isExistingProject,
force: args.force,
projectName,
unsupportedArgs: getUnsupportedDeployDelegateArgs(args),
Expand Down Expand Up @@ -646,11 +647,24 @@ export const pagesDeployCommand = createCommand({
},
});

function getUnsupportedDeployDelegateArgs(
/**
* The Pages-only `pages deploy` flags that cannot be represented by a Workers
* static-assets deploy, so their presence disqualifies the command from
* delegation: git-integration metadata (`--commit-*`) and a Pages build option
* (`--skip-caching`), none of which have a Workers equivalent.
*
* `--branch` is deliberately absent. It exists to target a Pages preview
* deployment, which only has meaning relative to an existing project's
* production. Delegation only ever fires for a brand-new project (the
* `projectExists` gate in `maybeDelegatePagesToWorkers`), and on a new project
* `--branch` merely names the production branch — exactly what a Workers deploy
* targets — so there are no preview semantics to preserve. If delegation is ever
* widened to existing projects, re-examine this omission.
*/
export function getUnsupportedDeployDelegateArgs(
args: (typeof pagesDeployCommand)["args"]
): string[] {
return [
["--branch", args.branch],
["--commit-hash", args.commitHash],
["--commit-message", args.commitMessage],
["--commit-dirty", args.commitDirty],
Expand Down
Loading
Loading