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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
29 changes: 23 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,27 @@ export const pagesDeployCommand = createCommand({
},
});

function getUnsupportedDeployDelegateArgs(
/**
* Collects the Pages-only `pages deploy` flags that are set on this command, so
* their presence can disqualify it from delegation.
*
* @param args The parsed `pages deploy` command arguments.
* @returns The names of any set flags that cannot be represented by a Workers
* static-assets deploy — git-integration metadata (`--commit-*`) and a Pages
* build option (`--skip-caching`). Empty when none are set.
*
* `--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