Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
d4e37d5
feat(cli): prompt for worker name if not provided
johnstonmatt Aug 29, 2026
edc7199
fix(cli): gate the workers new prompts on stdin too
johnstonmatt Aug 29, 2026
93c7aa6
feat(cli): prompt for worker name if not provided
johnstonmatt Aug 29, 2026
5c84df0
fix(cli): gate the workers new prompts on stdin too
johnstonmatt Aug 29, 2026
0af0f0a
chore(workers): describe behaviour rather than its history in comments
johnstonmatt Aug 31, 2026
a74e3f9
feat(workers): bring the command family's output onto one shape
johnstonmatt Aug 29, 2026
548a185
test(cli): guard every legacy boolean flag against a required default
johnstonmatt Aug 29, 2026
1d7e06b
chore(workers): describe behaviour rather than its history in comments
johnstonmatt Aug 31, 2026
1a2236d
feat(workers logs): add `supabase workers logs`
johnstonmatt Aug 31, 2026
c4080de
feat(workers logs): print timestamps in local time
johnstonmatt Aug 31, 2026
b7c9864
feat(workers logs): add `--follow` to keep printing new lines
johnstonmatt Aug 31, 2026
2f68ad6
feat: show stream tags in worker logs when multiple sources
johnstonmatt Aug 31, 2026
c66065d
chore(workers): describe behaviour rather than its history in comments
johnstonmatt Aug 31, 2026
acbd6ff
feat(workers push): make build wait opt-in with `--wait` flag
johnstonmatt Aug 29, 2026
88e4832
Merge branch 'FUNC-840/select-workers-new-name' of https://github.com…
johnstonmatt Sep 1, 2026
9ae1f38
fix(cli): point the workers new retry at the experimental path
johnstonmatt Sep 1, 2026
12c4d1c
fix(cli): stop telling users to run a command that does not exist
johnstonmatt Sep 1, 2026
94d1e89
Merge FUNC-840/select-workers-new-name into FUNC-851/general-output-p…
johnstonmatt Sep 1, 2026
4f05bc5
Merge FUNC-851/general-output-polish into FUNC-853/workers-logs-command
johnstonmatt Sep 1, 2026
0bfaca8
Merge FUNC-853/workers-logs-command into FUNC-848/workers-deploy-wait…
johnstonmatt Sep 1, 2026
e024627
test(cli): name the real push invocation in the flag-default guard
johnstonmatt Sep 1, 2026
1f103f9
fix(workers): restore the experimental segment in worker span names
johnstonmatt Sep 1, 2026
de59392
fix(workers): stop echoing an empty --project-ref into retry suggestions
johnstonmatt Sep 1, 2026
d99e6b5
fix(workers): keep push's per-worker progress out of structured formats
johnstonmatt Sep 1, 2026
71af01d
docs(workers): mark the status and delete trailers as text-only
johnstonmatt Sep 1, 2026
11395a3
refactor(workers): rename workers logs --source to --kind
johnstonmatt Sep 1, 2026
6f8770d
docs(workers): mark the logs no-logs hint as text-only
johnstonmatt Sep 1, 2026
46321b4
fix(workers logs): tie the --kind choices to the stream map
johnstonmatt Sep 1, 2026
396498b
fix(workers logs): give -o priority over --output-format
johnstonmatt Sep 1, 2026
d949afd
fix(workers logs): flush telemetry when the project ref cannot resolve
johnstonmatt Sep 1, 2026
335b62a
fix(workers logs): let a Ctrl+C'd tail run its finalizers
johnstonmatt Sep 1, 2026
2597a39
fix(workers logs): stop --tail capping the follow poll
johnstonmatt Sep 1, 2026
f8575ee
fix(workers logs): make --tail 0 --follow mean what it says
johnstonmatt Sep 1, 2026
a00a02c
fix(workers logs): sanitise every rendered field, not just the guest …
johnstonmatt Sep 1, 2026
d06c876
refactor(workers logs): prefix the exported log-level type
johnstonmatt Sep 1, 2026
7009bd6
docs(workers logs): document the SIGINT exit code
johnstonmatt Sep 1, 2026
af1f813
fix(workers logs): bound ts_ms to a representable instant
johnstonmatt Sep 1, 2026
fa318ee
fix(workers logs): retry only the poll failures worth another request
johnstonmatt Sep 1, 2026
468d9e2
fix(workers logs): carry the composed line into stream-json
johnstonmatt Sep 1, 2026
008f160
Merge branch 'develop' of https://github.com/supabase/cli into FUNC-8…
johnstonmatt Sep 1, 2026
b3ea7df
Merge branch 'FUNC-840/select-workers-new-name' of https://github.com…
johnstonmatt Sep 1, 2026
c8977d0
fix(workers logs): green the checks the logs command turned red
johnstonmatt Sep 1, 2026
e0cd17c
Merge branch 'FUNC-853/workers-logs-command' into FUNC-848/workers-de…
johnstonmatt Sep 1, 2026
993fa7e
Merge remote-tracking branch 'origin/FUNC-851/general-output-polish' …
johnstonmatt Sep 1, 2026
be45513
Merge branch 'FUNC-853/workers-logs-command' into FUNC-848/workers-de…
johnstonmatt Sep 1, 2026
b772491
test(config): stop a broken pipe preempting the exit-code diagnosis
johnstonmatt Sep 1, 2026
6a0bab9
Merge branch 'FUNC-853/workers-logs-command' into FUNC-848/workers-de…
johnstonmatt Sep 1, 2026
b9c58bf
refactor(workers push): flip the build wait to an opt-out `--no-wait`
johnstonmatt Sep 3, 2026
486a47b
fix(workers push): honour a terminal deploy response instead of polling
johnstonmatt Sep 3, 2026
26722c0
Merge remote-tracking branch 'origin/develop' into FUNC-853/workers-l…
johnstonmatt Sep 3, 2026
603d8ed
Merge branch 'FUNC-853/workers-logs-command' into FUNC-848/workers-de…
johnstonmatt Sep 3, 2026
21b144d
refactor(workers logs): drop the unused window seams and test followW…
johnstonmatt Sep 3, 2026
57f6249
fix(workers logs): report a burst the follow drain could not reach
johnstonmatt Sep 3, 2026
f98b1e0
Merge branch 'FUNC-853/workers-logs-command' into FUNC-848/workers-de…
johnstonmatt Sep 3, 2026
213f804
Revert "test(config): stop a broken pipe preempting the exit-code dia…
johnstonmatt Sep 3, 2026
a19dc4d
Merge branch 'FUNC-853/workers-logs-command' into FUNC-848/workers-de…
johnstonmatt Sep 3, 2026
f9647a0
fix(workers push): report in-flight builds and drop a stale image
johnstonmatt Sep 3, 2026
539986a
Merge remote-tracking branch 'origin/develop' into FUNC-848/workers-d…
johnstonmatt Sep 3, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ import { legacyRoot } from "./root.ts";
* `boolean`. The flag only misbehaves when a real invocation omits it, which is
* precisely the invocation no handler test makes — so the guard walks the
* command tree instead of waiting for a command to be exercised end to end.
*
* `experimental workers push --no-wait` is the flag that prompted it: it first
* shipped with neither closer, which made a plain
* `supabase experimental workers push` fail to parse at all.
*/

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,24 @@
| `GET` | `/v2/projects/{ref}/workers/{name}` | Bearer token | none | `build_state`, `state_reason`, `image_version`, `spec` |
| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked-project cache miss only — name, org, region |

`GET` is polled until `build_state` leaves `building`.
`GET /v2/projects/{ref}/workers/{name}` is polled until `build_state` leaves
`building`. It is skipped entirely in two cases: under `--no-wait`, and when
the deploy response already carried a terminal `build_state`. Either way the
run reports the accepted spec the deploy response returned.

## Exit Codes

| Code | Condition |
| ---- | ------------------------------------------------------- |
| `0` | success |
| `1` | no workers named and none found in the project |
| `1` | a worker's source is missing, not a directory, or empty |
| `1` | a worker's source directory cannot be read |
| `1` | a worker's source links to a path outside itself |
| `1` | build context upload failed |
| `1` | the build reached `failed`, or never left `building` |
| `1` | API error, or project not enrolled in the alpha |
| Code | Condition |
| ---- | -------------------------------------------------------------------- |
| `0` | success |
| `1` | no workers named and none found in the project |
| `1` | a worker's source is missing, not a directory, or empty |
| `1` | a worker's source directory cannot be read |
| `1` | a worker's source links to a path outside itself |
| `1` | build context upload failed |
| `1` | the build reached `failed`, or never left `building` |
| `1` | with `--no-wait`: the deploy was answered with `build_state: failed` |
| `1` | API error, or project not enrolled in the alpha |

## Environment Variables

Expand All @@ -72,16 +76,28 @@ payload always carries a `workers` array, which a flat `KEY=value` list cannot
express, and discovering that at the end would fail the command with the remote
project already changed.

A multi-worker run stops at the first failure, and names the workers it never
attempted on stderr in **every** format, machine ones included: that run is a
CI run, where nobody watched the loop and "what still needs deploying" is the
question the failure raises. The per-worker `Deploying Worker n/N:` announcement
is text-only by contrast, since it is progress rather than an outcome.

Both retry suggestions — the one on a failed build and the one on a build that
never settled — carry an explicit `--project-ref` when the flag supplied the
ref, since they are copy-pasted verbatim. A suggestion that dropped it would
re-resolve against whatever this checkout happens to be linked to.
Under `--no-wait` the deploy returns with the build still running, so the
follow-up hint (`workers status`) is emitted as a success trailer: stderr,
once, at the end of the run rather than between workers. **Text output
only** — like the rest of the human deploy report it sits behind
`output.format === "text"` and the `-o` check, so `--output-format json`,
`stream-json` and every legacy `-o` mode emit no hint. Machine callers read
`build_state` from the payload instead. The hint carries an explicit
`--project-ref` when the flag supplied one, since it is copy-pasted verbatim.

A multi-worker run stops at the first failure, and names on stderr in **every**
format, machine ones included, both the workers it never attempted and — under
`--no-wait` — the accepted workers whose builds it left running. Neither report
has the trailer's format guard: that run is a CI run, where nobody watched the
loop, and "what still needs deploying" and "what is still in flight" are both
part of the question the failure raises. The second report also covers a real
gap, since `runCli` drains success trailers only on exit code 0, so a failing
run discards every follow-up hint it had queued.

Under `--no-wait` the `Image` row and the payload's `image_version` are omitted
while `build_state` is `building`. The deploy response may carry an
`image_version` — a re-push of a worker that is already serving echoes the image
it is serving now — and that is the previous build's, not this one's.

The presigned `PUT` above is the one request whose URL is itself a credential.
`--debug` logs every request URL, so `legacyHttpClientLayer` redacts query
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ const config = {
),
Flag.optional,
),
noWait: Flag.boolean("no-wait").pipe(
// The deploy POST is answered once the platform has accepted the spec and
// the uploaded context, and the server-side container build that follows
// routinely runs for minutes. Waiting stays the default so a plain push
// still reports the build's verdict, and `--no-wait` is the opt-out for the
// callers — an inner-loop redeploy, a fire-and-forget CI step — that only
// need the deploy accepted.
Flag.withDescription(
"Return once the deploy is accepted, without waiting for the server-side build to finish.",
),
Flag.withDefault(false),
),
projectRef: Flag.string("project-ref").pipe(
Flag.withDescription("Project ref of the Supabase project."),
Flag.optional,
Expand Down Expand Up @@ -51,6 +63,10 @@ export const legacyWorkersPushCommand = Command.make("push", config).pipe(
command: "supabase experimental workers push api web",
description: "Deploy several workers by name",
},
{
command: "supabase experimental workers push api --no-wait",
description: "Deploy without blocking on the build",
},
]),
Command.withHandler((flags) =>
legacyWorkersPush(flags).pipe(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Effect, FileSystem, Option, Predicate, type Schedule } from "effect";
import type { PlatformError } from "effect/PlatformError";
import { Output } from "../../../../../shared/output/output.service.ts";
import { emitSuccessTrailer } from "../../../../../shared/cli/success-trailer.ts";
import { legacyRenderWorkerDetails } from "../workers.format.ts";
import {
legacyEmitWorkersMachineOutput,
Expand Down Expand Up @@ -70,6 +71,13 @@ import type { LegacyWorkersPushFlags } from "./push.command.ts";
* code takes the same path, with the base image and a copy synthesized in place
* of your Dockerfile. Every runtime this CLI offers has code to package, so
* there is no path here that skips the upload.
*
* The command waits for that server-side build by default, so a plain push
* reports the build's verdict rather than only that the deploy was accepted.
* The build routinely runs for minutes, though, which makes every successful
* deploy as slow as the slowest one — so `--no-wait` returns as soon as the
* platform accepts the deploy, for an inner-loop redeploy or a CI step that
* only needs the spec on file.
*/

const resolveRuntime = Effect.fnUntraced(function* (options: {
Expand Down Expand Up @@ -186,6 +194,8 @@ const deployOneWorker = Effect.fnUntraced(function* (input: {
*/
readonly refSuffix: string;
readonly instances: Option.Option<number>;
/** `--no-wait`: return once the deploy is accepted instead of blocking on the build. */
readonly noWait: boolean;
readonly pollSchedule?: Schedule.Schedule<unknown>;
readonly pollRetrySchedule?: Schedule.Schedule<unknown>;
/** Suppresses this step's human output when `-o` owns stdout. */
Expand Down Expand Up @@ -325,18 +335,38 @@ const deployOneWorker = Effect.fnUntraced(function* (input: {
};

const deploying = yield* output.task("Deploying worker...");
yield* deployWorker(api, projectRef, name, { spec, contextUploadId }).pipe(
// The response to the deploy itself is the last thing this command can learn
// without waiting: the platform answers it only after accepting the spec and
// the uploaded context, and it carries the accepted spec back. Everything
// after this point is the server-side container build.
const accepted = yield* deployWorker(api, projectRef, name, { spec, contextUploadId }).pipe(
Effect.tapError(() => deploying.fail()),
);

const settled = yield* awaitWorkerBuild(api, projectRef, name, {
schedule: input.pollSchedule,
retrySchedule: input.pollRetrySchedule,
refSuffix: input.refSuffix,
onPoll: (polled) =>
polled.buildState === "building" ? deploying.message("Building worker...") : Effect.void,
}).pipe(Effect.tapError(() => deploying.fail()));

// Polled only when the deploy response left the build unresolved.
// `V2DeployAWorkerOutput` permits a terminal `active` or `failed` on the
// deploy itself, and that verdict is this deploy's — a fresh `GET` can only
// contradict it: `awaitWorkerBuild` reads a post-deploy 404 as "still
// building", so an already-`failed` deploy could burn the whole poll budget
// and surface as a timeout, and a concurrent deployment could answer with a
// state that belongs to someone else's build.
const settled =
input.noWait || accepted.buildState !== "building"
? accepted
: yield* awaitWorkerBuild(api, projectRef, name, {
schedule: input.pollSchedule,
retrySchedule: input.pollRetrySchedule,
refSuffix: input.refSuffix,
onPoll: (polled) =>
polled.buildState === "building"
? deploying.message("Building worker...")
: Effect.void,
}).pipe(Effect.tapError(() => deploying.fail()));

// Checked whether or not the build was waited on: the verdict can arrive on
// the deploy response as readily as on a poll. A spec already in `failed` is
// a refusal the command should report as one, rather than exiting zero on a
// worker that will never come up.
if (settled.buildState === "failed") {
yield* deploying.clear();
return yield* Effect.fail(
Expand All @@ -356,6 +386,16 @@ const deployOneWorker = Effect.fnUntraced(function* (input: {
? workerUrl(projectRef, settings.projectHost, name)
: undefined;

// Dropped while the build is still running, rather than passed through.
// `image_version` is optional-but-permitted on the deploy response, so a
// re-push of a worker that is already serving can echo the image it is
// serving *now* — the previous build's, not this one's. Rendered beside
// `State building` that names an image this deploy did not produce, and a
// script reading `image_version` next to `build_state: "building"` would take
// it for the new one. Only reachable under `--no-wait`; the default polls
// until the build leaves `building`, so `settled` carries the real image.
const imageVersion = settled.buildState === "building" ? undefined : settled.imageVersion;

// Suppressed when `-o` is in play: the payload owns stdout, and these lines
// would land in the middle of it.
if (output.format === "text" && !input.machineOutput) {
Expand All @@ -367,13 +407,42 @@ const deployOneWorker = Effect.fnUntraced(function* (input: {
);
yield* output.raw(
legacyRenderWorkerDetails([
// Labelled `State`, and placed first, the way `workers status` renders
// the same field: under `--no-wait` it is the one row that says the
// worker is not serving yet, so it should not be hunted for at the
// bottom of the block.
["State", settled.buildState],
["Runtime", runtime],
["Size", formatApiSize(settled.spec.size)],
["Image", settled.imageVersion ?? ""],
// Empty under `--no-wait`: this deploy's image does not exist until the
// build produces one, and `legacyRenderWorkerDetails` drops an
// empty-valued row.
["Image", imageVersion ?? ""],
["Access", settled.spec.exposure],
["URL", url ?? ""],
]),
);
if (settled.buildState === "building") {
// A success trailer rather than an inline stderr line: this is a "what to
// run next" hint, which `stop`, `bootstrap`, `migration repair` and
// `gen signing-key` all route through `emitSuccessTrailer` so it prints
// once at the end of the run instead of scrolling away. It matters here
// more than for those: pushing several workers would otherwise bury each
// worker's hint under the next worker's packaging and deploy output.
//
// One short sentence per line, with the command aqua'd the way every
// other follow-up hint in this shell writes them. The single wrapped
// paragraph this replaced re-flowed differently at every terminal width
// and buried the command mid-sentence.
//
// No "drop `--no-wait` next time" line to go with it: reaching here means
// the caller asked not to wait, so the only thing left to tell them is
// where the build's verdict will show up.
yield* emitSuccessTrailer(
Comment thread
johnstonmatt marked this conversation as resolved.
`\nYour build was submitted successfully.\n` +
`Run ${legacyAqua(`supabase experimental workers status ${name}${input.refSuffix}`)} to check on it.\n`,
);
}
}

return {
Expand All @@ -385,7 +454,7 @@ const deployOneWorker = Effect.fnUntraced(function* (input: {
// Omitted rather than present-and-undefined: `-o toml` hands the payload to
// smol-toml, which cannot represent undefined and would throw *after* the
// upload and deploy had completed. Same reason `url` is spread below.
...(settled.imageVersion === undefined ? {} : { image_version: settled.imageVersion }),
...(imageVersion === undefined ? {} : { image_version: imageVersion }),
build_state: settled.buildState,
...(url === undefined ? {} : { url }),
};
Expand Down Expand Up @@ -413,6 +482,31 @@ const reportUnattempted = Effect.fnUntraced(function* (skipped: ReadonlyArray<st
yield* output.raw(`Not attempted: ${skipped.join(", ")}\n`, "stderr");
});

/**
* Names the workers whose builds the run left running.
*
* Under `--no-wait` a worker is accepted while its build is still in flight, and
* its follow-up hint goes out as a success trailer. `runCli` drains trailers
* only on exit code 0 (`shared/cli/run.ts`, `afterSuccess`), so a later worker
* failing discards every hint the run had queued — including for builds that are
* still running on the platform, which the failure does nothing to stop.
*
* Reported here instead, on the path that actually runs. Same stderr-in-every-
* format rule as {@link reportUnattempted} and the same reason: a machine-format
* run is a CI run, and "what is still in flight" is as much a part of the
* failure's answer as "what never started".
*
* Empty on a waiting run, without needing to check the flag: a worker the run
* waited for has left `building` by the time it returns.
*/
const reportStillBuilding = Effect.fnUntraced(function* (building: ReadonlyArray<string>) {
if (building.length === 0) {
return;
}
const output = yield* Output;
yield* output.raw(`Still building: ${building.join(", ")}\n`, "stderr");
});

/**
* `supabase experimental workers push [name...]` — deploy the named workers, or every worker
* in the project when none are named, mirroring `supabase functions deploy`.
Expand All @@ -422,6 +516,10 @@ const reportUnattempted = Effect.fnUntraced(function* (skipped: ReadonlyArray<st
* per-project capacity and shred the progress output. The first failure stops
* the run, because a build that failed is usually the thing to fix before
* spending minutes on the rest.
*
* Under `--no-wait` that serialization only covers the package/upload/deploy
* legs; the builds themselves then run concurrently on the platform, which is
* what the caller asked for by opting out of the wait.
*/
export const legacyWorkersPush = Effect.fn("legacy.experimental.workers.push")(function* (
flags: LegacyWorkersPushFlags,
Expand Down Expand Up @@ -473,6 +571,9 @@ export const legacyWorkersPush = Effect.fn("legacy.experimental.workers.push")(f
// explicit `--project-ref` has to survive into every hint this push emits.
const refSuffix = legacyWorkersProjectRefSuffix(flags.projectRef);
const deployed: Array<Record<string, unknown>> = [];
// Accepted, but not finished: their builds outlive a failure further down
// the loop, so the failure path has to name them. See `reportStillBuilding`.
const stillBuilding: Array<string> = [];
for (const [index, name] of names.entries()) {
if (names.length > 1 && !machineOutput && output.format === "text") {
// stderr, unblanked and labelled, the way `functions deploy` announces
Expand All @@ -492,20 +593,31 @@ export const legacyWorkersPush = Effect.fn("legacy.experimental.workers.push")(f
"stderr",
);
}
deployed.push(
yield* deployOneWorker({
project,
name,
projectRef,
refSuffix,
instances: flags.instances,
machineOutput,
...(options.pollSchedule === undefined ? {} : { pollSchedule: options.pollSchedule }),
...(options.pollRetrySchedule === undefined
? {}
: { pollRetrySchedule: options.pollRetrySchedule }),
}).pipe(Effect.tapError(() => reportUnattempted(names.slice(index + 1)))),
const worker = yield* deployOneWorker({
project,
name,
projectRef,
refSuffix,
instances: flags.instances,
noWait: flags.noWait,
machineOutput,
...(options.pollSchedule === undefined ? {} : { pollSchedule: options.pollSchedule }),
...(options.pollRetrySchedule === undefined
? {}
: { pollRetrySchedule: options.pollRetrySchedule }),
}).pipe(
// In flight before what never started: one is a thing the user now has
// to follow, the other a thing they have to re-run.
Effect.tapError(() =>
reportStillBuilding(stillBuilding).pipe(
Effect.andThen(reportUnattempted(names.slice(index + 1))),
),
),
);
deployed.push(worker);
if (worker.build_state === "building") {
stillBuilding.push(name);
}
}

// Only for a run that deployed several: one worker already said so itself,
Expand Down
Loading
Loading