Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/pages-delegation-force-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"wrangler": patch
---

Improve the agent-facing `--force` guidance for Pages-to-Workers delegation

When an AI agent opts out of the Pages-to-Workers delegation by passing `--force` to `wrangler pages deploy` or `wrangler pages project create`, Wrangler now prints a notice at the end of a successful command explaining that `--force` only needs to be passed once: the project then exists, so subsequent commands are no longer delegated and do not need the flag.
97 changes: 36 additions & 61 deletions packages/wrangler/src/__tests__/pages/delegate-to-workers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { runInTempDir } from "@cloudflare/workers-utils/test-helpers";
import { beforeEach, describe, it, vi } from "vitest";
import { sendMetricsEvent } from "../../metrics";
import {
logPagesToWorkersForceOptOutNotice,
maybeDelegatePagesToWorkers,
recordPagesToWorkersDelegateFailure,
} from "../../pages/delegate-to-workers";
Expand Down Expand Up @@ -47,7 +48,7 @@ describe("maybeDelegatePagesToWorkers", () => {
});

for (const command of ["deploy", "create"] as const) {
it(`does not delegate when the account already has Pages projects (${command}), and records why`, async ({
it(`does not delegate (or emit telemetry) when the account already has Pages projects (${command})`, async ({
expect,
}) => {
const result = await maybeDelegatePagesToWorkers({
Expand All @@ -57,14 +58,9 @@ describe("maybeDelegatePagesToWorkers", () => {
});

expect(result).toEqual({ delegate: false });
expect(sendMetricsEvent).toHaveBeenCalledWith(
"delegate pages to workers",
expect.objectContaining({
result: "skipped",
reason: "account has pages projects",
}),
expect.anything()
);
// Skips are deterministic, expected non-cases, so they are not sent to
// telemetry.
expect(sendMetricsEvent).not.toHaveBeenCalled();
});
}

Expand All @@ -83,7 +79,7 @@ describe("maybeDelegatePagesToWorkers", () => {
});
});

it("skips delegation (and records why) when the account Pages projects lookup fails", async ({
it("skips delegation (without emitting telemetry) when the account Pages projects lookup fails", async ({
expect,
}) => {
const result = await maybeDelegatePagesToWorkers({
Expand All @@ -95,14 +91,7 @@ describe("maybeDelegatePagesToWorkers", () => {
});

expect(result).toEqual({ delegate: false });
expect(sendMetricsEvent).toHaveBeenCalledWith(
"delegate pages to workers",
expect.objectContaining({
result: "skipped",
reason: "account pages projects lookup failed",
}),
expect.anything()
);
expect(sendMetricsEvent).not.toHaveBeenCalled();
});

it("does not query account Pages projects when a cheaper, local check already skips", async ({
Expand All @@ -121,14 +110,7 @@ describe("maybeDelegatePagesToWorkers", () => {
// 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).toHaveBeenCalledWith(
"delegate pages to workers",
expect.objectContaining({
result: "skipped",
reason: "pages functions directory",
}),
expect.anything()
);
expect(sendMetricsEvent).not.toHaveBeenCalled();
});

it("does not delegate when project has a functions directory", async ({
Expand All @@ -142,25 +124,16 @@ describe("maybeDelegatePagesToWorkers", () => {
});

expect(result).toEqual({ delegate: false });
expect(sendMetricsEvent).toHaveBeenCalledWith(
"delegate pages to workers",
expect.objectContaining({
result: "skipped",
reason: "pages functions directory",
}),
expect.anything()
);
expect(sendMetricsEvent).not.toHaveBeenCalled();
});

const unsupportedFileMarkers: [marker: string, reason: string][] = [
["_worker.js", "advanced-mode _worker.js"],
["_routes.json", "_routes.json file"],
];

for (const [marker, reason] of unsupportedFileMarkers) {
it(`does not delegate when project has a ${marker}, and records why`, async ({
expect,
}) => {
for (const [marker] of unsupportedFileMarkers) {
it(`does not delegate when project has a ${marker}`, async ({ expect }) => {
createFile(process.cwd(), marker);

const result = await maybeDelegatePagesToWorkers({
Expand All @@ -169,11 +142,7 @@ describe("maybeDelegatePagesToWorkers", () => {
});

expect(result).toEqual({ delegate: false });
expect(sendMetricsEvent).toHaveBeenCalledWith(
"delegate pages to workers",
expect.objectContaining({ result: "skipped", reason }),
expect.anything()
);
expect(sendMetricsEvent).not.toHaveBeenCalled();
});
}

Expand All @@ -191,14 +160,7 @@ describe("maybeDelegatePagesToWorkers", () => {
});

expect(result).toEqual({ delegate: false });
expect(sendMetricsEvent).toHaveBeenCalledWith(
"delegate pages to workers",
expect.objectContaining({
result: "skipped",
reason: "_routes.json file",
}),
expect.anything()
);
expect(sendMetricsEvent).not.toHaveBeenCalled();
});

for (const marker of ["_redirects", "_headers"]) {
Expand Down Expand Up @@ -252,14 +214,7 @@ describe("maybeDelegatePagesToWorkers", () => {
});

expect(result).toEqual({ delegate: false });
expect(sendMetricsEvent).toHaveBeenCalledWith(
"delegate pages to workers",
expect.objectContaining({
result: "skipped",
reason: "unsupported args: --branch",
}),
expect.anything()
);
expect(sendMetricsEvent).not.toHaveBeenCalled();
});

it("delegates a brand-new static deploy to Workers", async ({ expect }) => {
Expand Down Expand Up @@ -354,7 +309,7 @@ describe("maybeDelegatePagesToWorkers", () => {
});
});

it("does not delegate when --force is set, and records the opt-out", async ({
it("does not delegate when --force is set, records the opt-out, and flags forcedOptOut", async ({
expect,
}) => {
const result = await maybeDelegatePagesToWorkers({
Expand All @@ -363,14 +318,34 @@ describe("maybeDelegatePagesToWorkers", () => {
force: true,
});

expect(result).toEqual({ delegate: false });
expect(result).toEqual({ delegate: false, forcedOptOut: true });
expect(sendMetricsEvent).toHaveBeenCalledWith(
"delegate pages to workers",
expect.objectContaining({ command: "deploy", result: "forced" }),
expect.anything()
);
});

it("emits a one-time, deploy-specific --force notice to stdout", ({
expect,
}) => {
logPagesToWorkersForceOptOutNotice("deploy");

expect(std.out).toContain("deployed directly on Cloudflare Pages");
expect(std.out).toContain("This is the only time you need --force");
expect(std.out).toContain("this project now exists");
expect(std.out).toContain("Do not pass --force on future commands");
});

it("emits a one-time, create-specific --force notice to stdout", ({
expect,
}) => {
logPagesToWorkersForceOptOutNotice("create");

expect(std.out).toContain("created directly on Cloudflare Pages");
expect(std.out).toContain("This is the only time you need --force");
});

it("records failure and gives explicit, loop-safe --force guidance", async ({
expect,
}) => {
Expand Down
84 changes: 58 additions & 26 deletions packages/wrangler/src/pages/delegate-to-workers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,15 @@ export interface PagesToWorkersDeployArgs {
}

export type PagesToWorkersDelegateResult =
| { delegate: false }
| {
delegate: false;
/**
* True when the caller passed `--force` to opt this command out of
* delegation. The caller uses it to emit the one-time `--force` notice
* on the command's success path.
*/
forcedOptOut?: boolean;
}
| {
delegate: true;
command: PagesDelegateCommand;
Expand All @@ -71,12 +79,7 @@ export type PagesToWorkersDelegateResult =
};

/** The outcome recorded against the `delegate pages to workers` metrics event. */
type DelegateResult =
| "delegated"
| "success"
| "failure"
| "skipped"
| "forced";
type DelegateResult = "delegated" | "success" | "failure" | "forced";

/**
* Status line emitted at the top of the deploy flow, before the Workers deploy
Expand Down Expand Up @@ -117,6 +120,37 @@ function buildWorkersDeployFailedMessage(
);
}

/**
* Agent-facing notice emitted at the end of a successful `--force` command.
*
* `--force` opts this one command out of delegation and runs it directly against
* Cloudflare Pages. Once it succeeds the project exists on the account, so every
* subsequent Pages command is no longer eligible for delegation and runs
* directly without `--force`. We say this explicitly so the agent stops
* appending `--force` to future commands.
*/
function buildForceOptOutNoticeMessage(command: PagesDelegateCommand): string {
const action = command === "deploy" ? "deployed" : "created";
return (
`Notice to agents: this project was ${action} directly on Cloudflare Pages because --force was set, bypassing delegation to Cloudflare Workers. ` +
"This is the only time you need --force: this project now exists, so subsequent commands will run directly against Cloudflare Pages and will not be delegated to Cloudflare Workers. " +
"Do not pass --force on future commands."
);
}

/**
* Emits the one-time `--force` opt-out notice to stdout so the agent sees it at
* the end of the command.
*
* Call this only from the command's success path: the "this project now exists"
* guidance is only true once the direct Pages command has actually succeeded.
*/
export function logPagesToWorkersForceOptOutNotice(
command: PagesDelegateCommand
): void {
logger.log(buildForceOptOutNoticeMessage(command));
}

/**
* Decides whether to delegate a Pages command to a Workers static-assets deploy
* and, if so, returns the args for the caller to run as a Workers deploy.
Expand All @@ -143,30 +177,28 @@ export async function maybeDelegatePagesToWorkers(

// The agent explicitly opted out with `--force`. The only callers who should
// reach for `--force` are agents we previously delegated, so this is a
// strong signal of dissatisfaction with the delegation — record it.
// strong signal of dissatisfaction with the delegation — record it. We flag
// `forcedOptOut` so the caller emits the one-time `--force` notice once the
// direct Pages command succeeds (see `logPagesToWorkersForceOptOutNotice`).
if (options.force) {
recordDelegate("forced", options, agent.id);
logger.debug("Pages-to-Workers delegation skipped: --force opt-out");
return { delegate: false };
return { delegate: false, forcedOptOut: true };
}

if (options.unsupportedArgs && options.unsupportedArgs.length > 0) {
skipDelegate(
`unsupported args: ${options.unsupportedArgs.join(", ")}`,
options,
agent.id
);
skipDelegate(`unsupported args: ${options.unsupportedArgs.join(", ")}`);
return { delegate: false };
}

// Bail (and record why) if the project uses any Pages feature we can't carry
// Bail (and log why) if the project uses any Pages feature we can't carry
// across to a Workers static-assets deploy.
const unsupportedFeature = findUnsupportedPagesFeature(
options.projectPath,
options.assetsDirectory
);
if (unsupportedFeature) {
skipDelegate(unsupportedFeature, options, agent.id);
skipDelegate(unsupportedFeature);
return { delegate: false };
}

Expand All @@ -185,11 +217,11 @@ export async function maybeDelegatePagesToWorkers(
e instanceof Error ? e.message : String(e)
})`
);
skipDelegate("account pages projects lookup failed", options, agent.id);
skipDelegate("account pages projects lookup failed");
return { delegate: false };
}
if (hasPagesProjects) {
skipDelegate("account has pages projects", options, agent.id);
skipDelegate("account has pages projects");
return { delegate: false };
}
}
Expand Down Expand Up @@ -264,16 +296,16 @@ function buildWorkersDeployArgs(
}

/**
* Records a `skipped` delegation: logs the reason (so we can see why delegations
* don't happen) and sends a metrics event carrying that reason.
* 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
* 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.
*/
function skipDelegate(
reason: string,
options: MaybeDelegatePagesToWorkersOptions,
agentId: string | null
): void {
function skipDelegate(reason: string): void {
logger.debug(`Pages-to-Workers delegation skipped: ${reason}`);
recordDelegate("skipped", options, agentId, { reason });
}
Comment thread
MattieTK marked this conversation as resolved.

/** Sends a `delegate pages to workers` metrics event for the given outcome. */
Expand Down
11 changes: 10 additions & 1 deletion packages/wrangler/src/pages/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ import {
MAX_DEPLOYMENT_STATUS_ATTEMPTS,
PAGES_CONFIG_CACHE_FILENAME,
} from "./constants";
import { maybeDelegatePagesToWorkers } from "./delegate-to-workers";
import {
logPagesToWorkersForceOptOutNotice,
maybeDelegatePagesToWorkers,
} from "./delegate-to-workers";
import { EXIT_CODE_INVALID_PAGES_CONFIG } from "./errors";
import { listProjects } from "./projects";
import { promptSelectProject } from "./prompt-select-project";
Expand Down Expand Up @@ -634,6 +637,12 @@ export const pagesDeployCommand = createCommand({
});

metrics.sendMetricsEvent("create pages deployment");

// If the agent opted this deploy out of delegation with `--force`, tell it
// (at the end, on success) that `--force` is a one-time action.
if (delegation.forcedOptOut) {
logPagesToWorkersForceOptOutNotice("deploy");
}
},
});

Expand Down
11 changes: 10 additions & 1 deletion packages/wrangler/src/pages/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import { logger } from "../logger";
import * as metrics from "../metrics";
import { requireAuth } from "../user";
import { PAGES_CONFIG_CACHE_FILENAME } from "./constants";
import { maybeDelegatePagesToWorkers } from "./delegate-to-workers";
import {
logPagesToWorkersForceOptOutNotice,
maybeDelegatePagesToWorkers,
} from "./delegate-to-workers";
import { runPagesToWorkersDeploy } from "./run-workers-deploy";
import type { PagesConfigCache, Project } from "./types";

Expand Down Expand Up @@ -264,6 +267,12 @@ export const pagesProjectCreateCommand = createCommand({
`To deploy a folder of assets, run 'wrangler pages deploy [directory]'.`
);
metrics.sendMetricsEvent("create pages project");

// If the agent opted this create out of delegation with `--force`, tell it
// (at the end, on success) that `--force` is a one-time action.
if (delegation.forcedOptOut) {
logPagesToWorkersForceOptOutNotice("create");
}
},
});

Expand Down
Loading