@pithy-sh/cloudflare 0.1.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/LICENSE +21 -0
- package/README.md +87 -0
- package/package.json +48 -0
- package/src/ai/aiManager.ts +227 -0
- package/src/ai/vectorizeManager.ts +161 -0
- package/src/ai/vectorizeProvisioner.ts +266 -0
- package/src/client/accounts.ts +80 -0
- package/src/client/clients.ts +244 -0
- package/src/client/errors.ts +143 -0
- package/src/client/manager.ts +85 -0
- package/src/d1/d1Manager.ts +171 -0
- package/src/d1/d1PreparedStatement.ts +114 -0
- package/src/d1/d1Provisioner.ts +75 -0
- package/src/email/emailRoutingManager.ts +143 -0
- package/src/email/emailSendManager.ts +81 -0
- package/src/env/devVars.ts +90 -0
- package/src/hostnames/customHostnamesManager.ts +134 -0
- package/src/kv/kvManager.ts +202 -0
- package/src/kv/kvProvisioner.ts +80 -0
- package/src/media/assetSeeder.ts +87 -0
- package/src/media/imageManager.ts +125 -0
- package/src/media/ownership.ts +59 -0
- package/src/media/streamManager.ts +198 -0
- package/src/queue/queueManager.ts +185 -0
- package/src/r2/r2Credentials.ts +17 -0
- package/src/r2/r2Manager.ts +548 -0
- package/src/r2/r2Provisioner.ts +99 -0
- package/src/secrets/secretsStoreManager.ts +177 -0
- package/src/secrets/secretsStores.ts +75 -0
- package/src/test-utils/emailRoutingRules.ts +122 -0
- package/src/test-utils/fixtureReportSetup.ts +31 -0
- package/src/test-utils/fixtures.ts +372 -0
- package/src/test-utils/harness.ts +413 -0
- package/src/test-utils/inboundRecorder.ts +189 -0
- package/src/test-utils/integrationSetup.ts +46 -0
- package/src/test-utils/reap.ts +297 -0
- package/src/tokens/accountTokensManager.ts +334 -0
- package/src/tokens/permissions.ts +67 -0
- package/src/tokens/profiles.ts +238 -0
- package/src/turnstile/turnstileManager.ts +177 -0
- package/src/user/userManager.ts +73 -0
- package/src/workers/buildsManager.ts +348 -0
- package/src/workers/buildsTypes.ts +122 -0
- package/src/workers/workersBuildEvent.ts +48 -0
- package/src/workers/workersManager.ts +423 -0
- package/src/workers/workersProvisioner.ts +167 -0
- package/src/workflows/stepFailure.ts +280 -0
- package/src/workflows/workflowsClient.ts +213 -0
- package/src/zones/zonesManager.ts +92 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { ErrorPayload, kitErrorStatus } from "@pithy-sh/core/src/error/payload";
|
|
5
|
+
import { PithyError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import {
|
|
7
|
+
decodeWorkflowStepMessage,
|
|
8
|
+
MAX_WORKFLOW_STEP_TEXT,
|
|
9
|
+
splitWorkflowStepCode,
|
|
10
|
+
} from "@pithy-sh/core/src/workflow/stepMessage";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* **The step's sentence, recovered from an instance the engine has already written its own over.**
|
|
15
|
+
*
|
|
16
|
+
* A Workflow instance carries two failure texts. The instance-level `error` is the *platform's*: when a
|
|
17
|
+
* step raises `NonRetryableError`, the engine replaces whatever was thrown with
|
|
18
|
+
* `"The execution of the Workflow instance was terminated, as a step threw an NonRetryableError…"`.
|
|
19
|
+
* The step's own entry in `steps[]` still holds the text the kit raised. Reporting the first is how
|
|
20
|
+
* `pithy secrets create` on a name that already exists started answering with a sentence about durable
|
|
21
|
+
* execution instead of one about the secret (pithy-sh/pithy#349), the day #338 made that refusal terminal.
|
|
22
|
+
*
|
|
23
|
+
* So this reads the step, and it is read *here* rather than at a call site: every dispatch that polls a
|
|
24
|
+
* Workflow — secrets write and probe, payments reconcile, vector reprocess — goes through one
|
|
25
|
+
* `dispatchAndPoll`, and a rule that lives in the primitive cannot be missed by the next caller.
|
|
26
|
+
*
|
|
27
|
+
* ## What may cross into an operator's `message`, and what may not
|
|
28
|
+
*
|
|
29
|
+
* A `PithyError`'s `message` is public and its `detail` is stripped at the HTTP boundary — that is the
|
|
30
|
+
* security boundary, and this function is on the wrong side of it: the text arrives from a Worker whose
|
|
31
|
+
* code we did not write, over an API, and it could say anything. So a step's text is promoted into the
|
|
32
|
+
* operator's `message` **only when it is demonstrably a sentence the kit itself authored as public**:
|
|
33
|
+
*
|
|
34
|
+
* - `PithyError: <text>` — the throw's own name is the proof. `PithyError.message` *is*
|
|
35
|
+
* `payload.message`, the field already written to be safe for a client.
|
|
36
|
+
* - The encoding core's `classifiedSteps` writes, inside the platform's terminal envelope. **This file
|
|
37
|
+
* does not restate that encoding**; it calls `decodeWorkflowStepMessage`, which is the same module
|
|
38
|
+
* the writer calls, for the reason in its own doc comment: two packages agreeing about a string by
|
|
39
|
+
* each writing it down is a coincidence, not a contract (pithy-sh/pithy#353).
|
|
40
|
+
*
|
|
41
|
+
* Anything else — a foreign throw, a bare string, the engine's own prose — stays in `detail`, where it
|
|
42
|
+
* always was. Nothing that was not already public becomes public here.
|
|
43
|
+
*
|
|
44
|
+
* `action` rides that encoding and is promoted with the sentence, because it is already the operator's
|
|
45
|
+
* field: the CLI prints it under the problem line and `operatorError` includes it. `detail` does not
|
|
46
|
+
* cross, has no encoding, and is not read here.
|
|
47
|
+
*
|
|
48
|
+
* ## The literals are captured, not guessed
|
|
49
|
+
*
|
|
50
|
+
* Every platform string below was read off a real Workflows engine — the shipped Workflow class driven
|
|
51
|
+
* under `wrangler dev` (wrangler 4.123.0, 2026-08-14), instance detail fetched from the local dev
|
|
52
|
+
* session's own instance endpoint. They are frozen so the gate in `stepFailure.test.ts` polices a fixed
|
|
53
|
+
* set rather than whatever this file happens to believe today.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/** A failure as the Workflows API records one: a class name and its text. Instance-level and per-step alike. */
|
|
57
|
+
export const WorkflowFailureDetail = z
|
|
58
|
+
.object({
|
|
59
|
+
name: z.string().optional().describe("The thrown value's class name, as the engine recorded it."),
|
|
60
|
+
message: z
|
|
61
|
+
.string()
|
|
62
|
+
.optional()
|
|
63
|
+
.describe("The failure text — the engine's own for a terminal step, else the throw's."),
|
|
64
|
+
})
|
|
65
|
+
.describe("One failure the Workflows API reports, on an instance or on a step attempt.");
|
|
66
|
+
export type WorkflowFailureDetail = z.output<typeof WorkflowFailureDetail>;
|
|
67
|
+
|
|
68
|
+
/** One attempt at a step. The engine appends one per retry, so the last failed one is the answer. */
|
|
69
|
+
export const WorkflowStepAttempt = z
|
|
70
|
+
.object({
|
|
71
|
+
success: z.boolean().nullish().describe("Whether this attempt returned; `false` once it threw."),
|
|
72
|
+
error: WorkflowFailureDetail.nullish().describe("What this attempt threw, absent when it returned."),
|
|
73
|
+
})
|
|
74
|
+
.describe("One attempt at a Workflow step, as the instance-detail endpoint reports it.");
|
|
75
|
+
export type WorkflowStepAttempt = z.output<typeof WorkflowStepAttempt>;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* One entry in an instance's `steps[]`.
|
|
79
|
+
*
|
|
80
|
+
* The API's four step shapes (`step`, `sleep`, `waitForEvent`, `termination`) differ in where the
|
|
81
|
+
* failure lives — a `step` keeps one per attempt, the others keep one on the entry — so both are
|
|
82
|
+
* optional here and the reader takes whichever is present. Unknown fields (`config`, `output`,
|
|
83
|
+
* timestamps) are stripped rather than refused: this is a diagnostic read, and a step shape nobody
|
|
84
|
+
* anticipated must not turn a failed Workflow into a parse error about the failure report.
|
|
85
|
+
*/
|
|
86
|
+
export const WorkflowStepRecord = z
|
|
87
|
+
.object({
|
|
88
|
+
name: z.string().optional().describe("The step's name, as `step.do` was called with it."),
|
|
89
|
+
type: z.string().optional().describe("Which of the API's step shapes this is — `step`, `sleep`, `waitForEvent`."),
|
|
90
|
+
success: z.boolean().nullish().describe("Whether the step completed; `false` once every attempt failed."),
|
|
91
|
+
error: WorkflowFailureDetail.nullish().describe("The failure, for the shapes that record one on the entry."),
|
|
92
|
+
attempts: z.array(WorkflowStepAttempt).optional().describe("Each attempt at the step, oldest first."),
|
|
93
|
+
})
|
|
94
|
+
.describe("One step of a Workflow instance, narrowed to the fields a failure report reads.");
|
|
95
|
+
export type WorkflowStepRecord = z.output<typeof WorkflowStepRecord>;
|
|
96
|
+
|
|
97
|
+
/** What a failed instance's steps say about why, once the platform's own prose is set aside. */
|
|
98
|
+
export interface WorkflowStepFailure {
|
|
99
|
+
/** The failed step's name, when the engine reported one. */
|
|
100
|
+
step?: string;
|
|
101
|
+
/** The step's recorded text, verbatim, platform envelope and all. For `detail` — never for `message`. */
|
|
102
|
+
raw: string;
|
|
103
|
+
/** The `PithyError` code the step raised, when its text carries one. */
|
|
104
|
+
code?: string;
|
|
105
|
+
/** The step's own public sentence — present only when the text is one the kit authored. */
|
|
106
|
+
sentence?: string;
|
|
107
|
+
/** The remedy the raising error stated, when it stated one. The CLI's action line. */
|
|
108
|
+
action?: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The envelope the engine wraps a terminal step throw in, captured verbatim. The inner text is *not*
|
|
113
|
+
* JSON-quoted — a message containing a `"` is embedded raw — so this is a prefix/suffix strip and never
|
|
114
|
+
* a `JSON.parse`.
|
|
115
|
+
*/
|
|
116
|
+
const TERMINAL_STEP_ENVELOPE = Object.freeze({
|
|
117
|
+
prefix: 'Step threw a NonRetryableError with message "',
|
|
118
|
+
suffix: '"',
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The throw names a kit step error can arrive under. `PithyError` is core's one throw vehicle;
|
|
123
|
+
* `NonRetryableError` is what `classifiedSteps` re-throws a terminal fault as. A name outside this set
|
|
124
|
+
* is somebody else's error, and its text stays in `detail`.
|
|
125
|
+
*/
|
|
126
|
+
const KIT_THROW_NAMES: readonly string[] = Object.freeze(["PithyError", "NonRetryableError"]);
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The kit-authored public sentence inside a step's recorded text, and the remedy riding with it —
|
|
130
|
+
* `null` when there is none.
|
|
131
|
+
*
|
|
132
|
+
* Exported for the gate, which drives captured engine output through it: a platform sentence reaching an
|
|
133
|
+
* operator is a test failure, and the test has to be able to ask this question directly.
|
|
134
|
+
*/
|
|
135
|
+
export function kitSentence(raw: string): { code?: string; message: string; action?: string } | null {
|
|
136
|
+
let text = raw.trim();
|
|
137
|
+
if (text.startsWith(TERMINAL_STEP_ENVELOPE.prefix) && text.endsWith(TERMINAL_STEP_ENVELOPE.suffix)) {
|
|
138
|
+
text = text.slice(TERMINAL_STEP_ENVELOPE.prefix.length, -TERMINAL_STEP_ENVELOPE.suffix.length);
|
|
139
|
+
}
|
|
140
|
+
const thrown = KIT_THROW_NAMES.map((name) => `${name}: `).find((prefix) => text.startsWith(prefix));
|
|
141
|
+
if (thrown === undefined) return null;
|
|
142
|
+
text = text.slice(thrown.length);
|
|
143
|
+
|
|
144
|
+
// A `NonRetryableError` is `classifiedSteps`' vehicle, so its text is core's encoding or it is
|
|
145
|
+
// somebody else's — anyone may throw one with anything inside, and the code prefix is the only proof.
|
|
146
|
+
if (thrown === "NonRetryableError: ") return decodeWorkflowStepMessage(text);
|
|
147
|
+
|
|
148
|
+
// A `PithyError` name is proof on its own. Its recorded text is `payload.message` and carries no
|
|
149
|
+
// encoding at all — no action ever rode here — so the sentence is taken as it stands, and a newline
|
|
150
|
+
// in it is still declined rather than reshaped: it would forge the CLI's action line.
|
|
151
|
+
const { code, rest } = splitWorkflowStepCode(text);
|
|
152
|
+
const message = rest.trim();
|
|
153
|
+
if (message === "" || message.length > MAX_WORKFLOW_STEP_TEXT) return null;
|
|
154
|
+
if (/[\n\r]/.test(message)) return null;
|
|
155
|
+
return code === undefined ? { message } : { code, message };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The recorded failure text of one step entry, from its last failed attempt or its own error. */
|
|
159
|
+
function failureTextOf(step: WorkflowStepRecord): string | undefined {
|
|
160
|
+
for (let index = (step.attempts?.length ?? 0) - 1; index >= 0; index -= 1) {
|
|
161
|
+
const attempt = step.attempts?.[index];
|
|
162
|
+
if (attempt?.success === true) continue;
|
|
163
|
+
const message = attempt?.error?.message;
|
|
164
|
+
if (message !== undefined && message !== "") return message;
|
|
165
|
+
}
|
|
166
|
+
const own = step.error?.message;
|
|
167
|
+
return own === undefined || own === "" ? undefined : own;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The last step that failed, and what it said. `null` when no step reports a failure — an instance the
|
|
172
|
+
* platform failed on its own (a timeout, a terminate) has steps that all succeeded, or none at all.
|
|
173
|
+
*
|
|
174
|
+
* Read from the end: the failing step is the last one the instance reached, and an earlier step that
|
|
175
|
+
* failed and was retried into success has nothing to say about why the instance ended.
|
|
176
|
+
*
|
|
177
|
+
* Each entry is validated on its way in and an entry that will not parse is skipped rather than thrown
|
|
178
|
+
* on. The caller is already reporting a failure; refusing to describe it because one step of the report
|
|
179
|
+
* had an unfamiliar shape trades a useful sentence for a useless one.
|
|
180
|
+
*/
|
|
181
|
+
export function stepFailure(steps: readonly unknown[] | undefined): WorkflowStepFailure | null {
|
|
182
|
+
if (steps === undefined) return null;
|
|
183
|
+
for (let index = steps.length - 1; index >= 0; index -= 1) {
|
|
184
|
+
const parsed = WorkflowStepRecord.safeParse(steps[index]);
|
|
185
|
+
if (!parsed.success) continue;
|
|
186
|
+
if (parsed.data.success === true) continue;
|
|
187
|
+
const raw = failureTextOf(parsed.data);
|
|
188
|
+
if (raw === undefined) continue;
|
|
189
|
+
const kit = kitSentence(raw);
|
|
190
|
+
return {
|
|
191
|
+
...(parsed.data.name === undefined ? {} : { step: parsed.data.name }),
|
|
192
|
+
raw,
|
|
193
|
+
...(kit?.code === undefined ? {} : { code: kit.code }),
|
|
194
|
+
...(kit === null ? {} : { sentence: kit.message }),
|
|
195
|
+
// Absent, not `undefined`: a step that stated no remedy must leave nothing for the CLI to print
|
|
196
|
+
// under the problem line — no empty line, no trailing separator, no `undefined`.
|
|
197
|
+
...(kit?.action === undefined ? {} : { action: kit.action }),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The code a terminal Workflow failure falls back to when the fault that ended the run cannot be
|
|
205
|
+
* attributed. Stated here because two things must agree on it: the thrower below, and the gate that
|
|
206
|
+
* proves this code is not one a transport failure can arrive under.
|
|
207
|
+
*/
|
|
208
|
+
export const WORKFLOW_FAILED_CODE = "core/workflow_failed" as const;
|
|
209
|
+
|
|
210
|
+
/** The status pinned to {@link WORKFLOW_FAILED_CODE}, restated so the throw cannot drift off it. */
|
|
211
|
+
export const WORKFLOW_FAILED_STATUS = 500 as const;
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* **The error an operator is handed for a Workflow that ran and did not complete** (pithy-sh/pithy#365).
|
|
215
|
+
*
|
|
216
|
+
* `#353` fixed the sentence and the remedy. The `code` and the `status` were still the *transport's*:
|
|
217
|
+
* `dispatchAndPoll` threw `CloudflareRequestError`, which fixes `cloudflare/request_failed` and 502 by
|
|
218
|
+
* construction, so a step that raised `secrets/already_exists` with 409 reached the CLI as a 502 —
|
|
219
|
+
* "the far side is broken, try later" for a request that was delivered, ran, and was permanently
|
|
220
|
+
* refused. Anything branching on the pair rather than reading the prose was told the opposite of what
|
|
221
|
+
* happened.
|
|
222
|
+
*
|
|
223
|
+
* ## The distinction that has to survive, and where it lives
|
|
224
|
+
*
|
|
225
|
+
* A Workflow whose *step raised* is not the same event as a dispatch that could not be delivered, and
|
|
226
|
+
* the difference is now in the two machine-readable fields rather than in the message:
|
|
227
|
+
*
|
|
228
|
+
* - **The step raised, and the kit pins a status for its code** → that code, that status. Terminal by
|
|
229
|
+
* the code's own definition; `secrets/already_exists` is a 409 wherever it is raised.
|
|
230
|
+
* - **The run ended and nothing is attributable** — a foreign throw, the platform's own prose, an
|
|
231
|
+
* `unclassified` fault, an adopter code the kit pins no status for → {@link WORKFLOW_FAILED_CODE},
|
|
232
|
+
* 500. Terminal, and deliberately not 502.
|
|
233
|
+
* - **The dispatch itself failed** — the REST call was refused, timed out, or answered with a shape
|
|
234
|
+
* nobody expected → `cloudflare/request_failed` / `cloudflare/invalid_response`, 502, thrown by
|
|
235
|
+
* `cloudflareRequest` exactly as before. This function is never reached for one.
|
|
236
|
+
*
|
|
237
|
+
* So the reader tells them apart on `code` alone, and the three sets are disjoint.
|
|
238
|
+
*
|
|
239
|
+
* ## Why the status is recovered from the code rather than carried on the wire
|
|
240
|
+
*
|
|
241
|
+
* Nothing but `code`, `message` and `action` crosses a durable step boundary — the engine records the
|
|
242
|
+
* throw's text and discards the throw. A fourth field could have been encoded, at the cost of
|
|
243
|
+
* reopening a format `#353` froze against a measurement. It is not needed: **every kit member pins
|
|
244
|
+
* `status` to one literal**, so the code *is* the status (`kitErrorStatus`). A code the kit does not
|
|
245
|
+
* define has no pinned status, and rather than invent one this says so with its own code.
|
|
246
|
+
*
|
|
247
|
+
* ## `detail` still does not cross
|
|
248
|
+
*
|
|
249
|
+
* `detail` is composed here from the platform's raw text and the instance id — the operator's side of
|
|
250
|
+
* the boundary — and nothing derived from the step's own `detail` is in it, because the step's
|
|
251
|
+
* `detail` never left the step. The only fields promoted from the far side are `message` and `action`,
|
|
252
|
+
* both already public, both already proved kit-authored by `kitSentence`.
|
|
253
|
+
*/
|
|
254
|
+
export function terminalWorkflowError(args: {
|
|
255
|
+
/** What the instance's steps said, or `null` when no step reported a failure. */
|
|
256
|
+
failure: WorkflowStepFailure | null;
|
|
257
|
+
/** The sentence to use when the step authored none — the caller's own, about the instance. */
|
|
258
|
+
fallbackMessage: string;
|
|
259
|
+
/** The operator's context line. Raw platform text and the instance id; never the dispatched params. */
|
|
260
|
+
detail: string;
|
|
261
|
+
}): PithyError {
|
|
262
|
+
const { failure, fallbackMessage, detail } = args;
|
|
263
|
+
// A remedy travels only alongside the sentence it is a remedy for. An action line under a general
|
|
264
|
+
// fallback about durable execution is a fix for a problem nobody was told about.
|
|
265
|
+
const message = failure?.sentence ?? fallbackMessage;
|
|
266
|
+
const action = failure?.sentence === undefined ? undefined : failure.action;
|
|
267
|
+
|
|
268
|
+
const status = failure?.code === undefined ? undefined : kitErrorStatus(failure.code);
|
|
269
|
+
if (failure?.sentence !== undefined && failure.code !== undefined && status !== undefined) {
|
|
270
|
+
const candidate = { code: failure.code, status, message, action, detail };
|
|
271
|
+
// Parsed rather than trusted. A recovered code is a string from a Worker we did not write, and one
|
|
272
|
+
// kit member (`validation/invalid_input`) requires a field this boundary has no way to supply — so
|
|
273
|
+
// a payload that would not validate must not be thrown from the error path. It falls through to
|
|
274
|
+
// the general terminal code below, keeping the step's own sentence.
|
|
275
|
+
const parsed = ErrorPayload.safeParse(candidate);
|
|
276
|
+
if (parsed.success) return new PithyError(parsed.data);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return new PithyError({ code: WORKFLOW_FAILED_CODE, status: WORKFLOW_FAILED_STATUS, message, action, detail });
|
|
280
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { UpstreamTimeoutError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { CloudflareInvalidResponseError, cloudflareRequest, isNotFoundError } from "../client/errors";
|
|
7
|
+
import { CloudflareManager, type CloudflareManagerConfig } from "../client/manager";
|
|
8
|
+
import { stepFailure, terminalWorkflowError, type WorkflowStepFailure } from "./stepFailure";
|
|
9
|
+
|
|
10
|
+
/** Pause `ms` milliseconds between status polls. Injectable so tests run with no real delay. */
|
|
11
|
+
export type Sleeper = (ms: number) => Promise<void>;
|
|
12
|
+
|
|
13
|
+
export interface CloudflareWorkflowsClientConfig extends CloudflareManagerConfig {
|
|
14
|
+
/** Override the poll delay (tests). Defaults to a real `setTimeout`. */
|
|
15
|
+
sleeper?: Sleeper;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const WorkflowInstanceStatus = z
|
|
19
|
+
.enum([
|
|
20
|
+
"queued",
|
|
21
|
+
"running",
|
|
22
|
+
"paused",
|
|
23
|
+
"errored",
|
|
24
|
+
"terminated",
|
|
25
|
+
"complete",
|
|
26
|
+
"waitingForPause",
|
|
27
|
+
"waiting",
|
|
28
|
+
"rollingBack",
|
|
29
|
+
"unknown",
|
|
30
|
+
])
|
|
31
|
+
.describe("Lifecycle state of a Cloudflare Workflow instance, from the status endpoint.");
|
|
32
|
+
export type WorkflowInstanceStatus = z.output<typeof WorkflowInstanceStatus>;
|
|
33
|
+
|
|
34
|
+
export const WorkflowInstance = z
|
|
35
|
+
.object({
|
|
36
|
+
status: WorkflowInstanceStatus.describe("The instance's current lifecycle state."),
|
|
37
|
+
output: z.unknown().optional().describe("The Workflow's return value, present once `status` is `complete`."),
|
|
38
|
+
error: z
|
|
39
|
+
.union([
|
|
40
|
+
z.string(),
|
|
41
|
+
z
|
|
42
|
+
.object({ message: z.string().optional().describe("Failure message.") })
|
|
43
|
+
.describe("The structured form of the failure, when the API sends an object rather than a string."),
|
|
44
|
+
z.null(),
|
|
45
|
+
])
|
|
46
|
+
.optional()
|
|
47
|
+
.describe("The instance-level failure, present when `status` is `errored` or `terminated`."),
|
|
48
|
+
steps: z
|
|
49
|
+
.array(z.unknown())
|
|
50
|
+
.optional()
|
|
51
|
+
.describe(
|
|
52
|
+
"The instance's steps, each validated where it is read (`stepFailure`). Held as `unknown` here on purpose: the API has four step shapes, this client reads one field of one of them, and a shape nobody anticipated must not turn a failed Workflow into a parse error about the failure report.",
|
|
53
|
+
),
|
|
54
|
+
})
|
|
55
|
+
.describe("A Cloudflare Workflow instance's status, as returned by the instance-detail endpoint.");
|
|
56
|
+
export type WorkflowInstance = z.output<typeof WorkflowInstance>;
|
|
57
|
+
|
|
58
|
+
/** Terminal states a poll stops on. */
|
|
59
|
+
const TERMINAL: ReadonlySet<WorkflowInstanceStatus> = new Set(["complete", "errored", "terminated"]);
|
|
60
|
+
|
|
61
|
+
const defaultSleeper: Sleeper = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A client over the Cloudflare Workflows API — the only sanctioned way to reach CF Workflows from
|
|
65
|
+
* outside a Worker (CLAUDE.md: no hand-rolled CF `fetch` outside `@pithy-sh/cloudflare`). The
|
|
66
|
+
* `pithy secrets` CLI uses it to trigger a per-env manager's Workflow and poll to completion: the
|
|
67
|
+
* CLI never runs secret logic locally, since the master key is worker-only.
|
|
68
|
+
*
|
|
69
|
+
* The SDK owns transport and envelope handling. Instance status is still re-validated through
|
|
70
|
+
* {@link WorkflowInstance} rather than taken from the SDK's types: the SDK narrows `output` to
|
|
71
|
+
* `string | number`, but a Workflow's return value is arbitrary JSON, and the status enum is a wire
|
|
72
|
+
* value we decode at the boundary like every other external input.
|
|
73
|
+
*/
|
|
74
|
+
export class CloudflareWorkflowsClient extends CloudflareManager {
|
|
75
|
+
readonly #sleeper: Sleeper;
|
|
76
|
+
|
|
77
|
+
constructor(config: CloudflareWorkflowsClientConfig) {
|
|
78
|
+
super(config);
|
|
79
|
+
this.#sleeper = config.sleeper ?? defaultSleeper;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
getServiceType(): string {
|
|
83
|
+
return "Workflows";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Prove reach by listing the account's Workflows; never throws. */
|
|
87
|
+
async validateServiceAccess(): Promise<boolean> {
|
|
88
|
+
try {
|
|
89
|
+
for await (const _workflow of this.getClient().workflows.list({ account_id: this.accountId })) break;
|
|
90
|
+
return true;
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Create (trigger) a Workflow instance and return its id. */
|
|
97
|
+
async createInstance(workflowName: string, params: unknown, instanceId?: string): Promise<string> {
|
|
98
|
+
return cloudflareRequest(`create instance of ${workflowName}`, async () => {
|
|
99
|
+
const created = await this.getClient().workflows.instances.create(workflowName, {
|
|
100
|
+
account_id: this.accountId,
|
|
101
|
+
params,
|
|
102
|
+
...(instanceId ? { instance_id: instanceId } : {}),
|
|
103
|
+
});
|
|
104
|
+
if (!created.id) {
|
|
105
|
+
throw new CloudflareInvalidResponseError({ detail: `create instance of ${workflowName}: missing instance id` });
|
|
106
|
+
}
|
|
107
|
+
return created.id;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Fetch an instance's status. Returns `null` on a 404 (a just-created instance can briefly lag). */
|
|
112
|
+
async getInstanceStatus(workflowName: string, instanceId: string): Promise<WorkflowInstance | null> {
|
|
113
|
+
const operation = `get status of ${workflowName}/${instanceId}`;
|
|
114
|
+
return cloudflareRequest(operation, async () => {
|
|
115
|
+
let response: unknown;
|
|
116
|
+
try {
|
|
117
|
+
response = await this.getClient().workflows.instances.get(instanceId, {
|
|
118
|
+
account_id: this.accountId,
|
|
119
|
+
workflow_name: workflowName,
|
|
120
|
+
});
|
|
121
|
+
} catch (error) {
|
|
122
|
+
// The instance is not queryable yet — the caller keeps polling. Any other failure is real.
|
|
123
|
+
if (isNotFoundError(error)) return null;
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
const parsed = WorkflowInstance.safeParse(response);
|
|
127
|
+
if (!parsed.success) {
|
|
128
|
+
throw new CloudflareInvalidResponseError({ detail: `${operation}: unexpected shape` });
|
|
129
|
+
}
|
|
130
|
+
return parsed.data;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Trigger a Workflow and poll until it reaches a terminal state. Resolves with the instance
|
|
136
|
+
* `output` on `complete`. The error never carries the dispatched params (which may be secret).
|
|
137
|
+
*
|
|
138
|
+
* **Three failures, three codes, and a caller can tell them apart without reading a word of prose**
|
|
139
|
+
* (pithy-sh/pithy#365):
|
|
140
|
+
*
|
|
141
|
+
* | What happened | Code | Status |
|
|
142
|
+
* |---|---|---|
|
|
143
|
+
* | A step raised, and the kit pins a status for its code | that code | that status |
|
|
144
|
+
* | The run ended terminally, nothing attributable | `core/workflow_failed` | 500 |
|
|
145
|
+
* | The dispatch or a poll could not be delivered | `cloudflare/request_failed` | 502 |
|
|
146
|
+
* | We stopped waiting; the instance may still finish | `core/upstream_timeout` | 504 |
|
|
147
|
+
*
|
|
148
|
+
* The first two are terminal — the run is over and re-driving it reaches the same end. The last two
|
|
149
|
+
* are not. Reporting all of them as 502 told an operator to wait for a recovery that would never
|
|
150
|
+
* come; see `terminalWorkflowError` for the argument in full.
|
|
151
|
+
*/
|
|
152
|
+
async dispatchAndPoll(
|
|
153
|
+
workflowName: string,
|
|
154
|
+
params: unknown,
|
|
155
|
+
options: { pollIntervalMs?: number; maxPolls?: number; instanceId?: string } = {},
|
|
156
|
+
): Promise<unknown> {
|
|
157
|
+
const pollIntervalMs = options.pollIntervalMs ?? 1_000;
|
|
158
|
+
const maxPolls = options.maxPolls ?? 120;
|
|
159
|
+
const id = await this.createInstance(workflowName, params, options.instanceId);
|
|
160
|
+
|
|
161
|
+
for (let poll = 0; poll < maxPolls; poll++) {
|
|
162
|
+
await this.#sleeper(pollIntervalMs);
|
|
163
|
+
const instance = await this.getInstanceStatus(workflowName, id);
|
|
164
|
+
if (!instance) continue; // not queryable yet — keep polling
|
|
165
|
+
if (!TERMINAL.has(instance.status)) continue;
|
|
166
|
+
if (instance.status === "complete") return instance.output;
|
|
167
|
+
// The step's sentence, not the instance's. The engine writes its own prose over a terminal step's
|
|
168
|
+
// error, so the instance says "a step threw an NonRetryableError" where the step says what was
|
|
169
|
+
// actually wrong (pithy-sh/pithy#349). Only a sentence the kit demonstrably authored is promoted
|
|
170
|
+
// into `message`; everything else stays in `detail`, which the HTTP codec strips.
|
|
171
|
+
// …and under the step's own code and status, not the transport's (pithy-sh/pithy#365). The
|
|
172
|
+
// sentence, the remedy, the code and the status are all the raising error's; the fallbacks and
|
|
173
|
+
// `detail` are this client's. `terminalWorkflowError` owns which of the two answers, so there is
|
|
174
|
+
// one statement of it rather than a rule this call site remembers.
|
|
175
|
+
const failure = stepFailure(instance.steps);
|
|
176
|
+
throw terminalWorkflowError({
|
|
177
|
+
failure,
|
|
178
|
+
fallbackMessage: `Workflow ${workflowName} did not complete (${instance.status}).`,
|
|
179
|
+
detail: `instance ${id} ended ${instance.status}: ${describeFailure(failure, instance.error)}`,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
// Not terminal, and it must not read as one: the budget is *ours*, and the instance is still
|
|
183
|
+
// running on the far side. `core/upstream_timeout` (504) is the kit's stated code for a dependency
|
|
184
|
+
// that did not answer inside a deadline and may yet apply the work — which is exactly this — and it
|
|
185
|
+
// is neither the transport's 502 nor a terminal Workflow's code.
|
|
186
|
+
throw new UpstreamTimeoutError({
|
|
187
|
+
message: `Workflow ${workflowName} did not finish in time.`,
|
|
188
|
+
action: `Check the instance in the Cloudflare dashboard; it may still complete.`,
|
|
189
|
+
detail: `instance ${id} still running after ${maxPolls} polls`,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The most specific instance-error text, for a failure `detail`. Never the dispatched params. */
|
|
195
|
+
function describeError(error: WorkflowInstance["error"]): string {
|
|
196
|
+
if (!error) return "no error detail";
|
|
197
|
+
if (typeof error === "string") return error;
|
|
198
|
+
return error.message ?? "no error detail";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Everything known about why, for the `detail` line: the failed step's name and its recorded text
|
|
203
|
+
* verbatim, then the instance's own. Both, because they differ — that difference is the bug this
|
|
204
|
+
* function exists because of — and `detail` is the operator's side of the boundary, where the raw
|
|
205
|
+
* platform text belongs. Never the dispatched params.
|
|
206
|
+
*/
|
|
207
|
+
function describeFailure(failure: WorkflowStepFailure | null, error: WorkflowInstance["error"]): string {
|
|
208
|
+
const instance = describeError(error);
|
|
209
|
+
if (failure === null) return instance;
|
|
210
|
+
const step = failure.step === undefined ? "step" : `step '${failure.step}'`;
|
|
211
|
+
const code = failure.code === undefined ? "" : ` [${failure.code}]`;
|
|
212
|
+
return `${step} raised${code} ${failure.raw} — instance error: ${instance}`;
|
|
213
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { cloudflareRequest } from "../client/errors";
|
|
6
|
+
import { CloudflareManager } from "../client/manager";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The account's zones — the registrable domains a custom domain can attach to.
|
|
10
|
+
*
|
|
11
|
+
* **Nothing here listed zones before.** Every zone-scoped operation in this package takes a `zoneId` the
|
|
12
|
+
* caller already knows: `CloudflareCustomHostnamesManager` takes it as constructor config,
|
|
13
|
+
* `CloudflareEmailRoutingManager` takes it per method, `CloudflareWorkersManager.addRoute` takes it
|
|
14
|
+
* positionally — and `pithy email provision` makes a human paste one, telling them to "find it on the
|
|
15
|
+
* zone's Overview page". So the CLI never *discovered* a zone; it demanded one.
|
|
16
|
+
*
|
|
17
|
+
* That is what this exists to change. When `pithy init` and `pithy worker add` ask where a Worker will
|
|
18
|
+
* answer, offering the account's real zones means a typo fails at `init` with a list of what exists,
|
|
19
|
+
* rather than at `deploy` with a Cloudflare error to decode.
|
|
20
|
+
*
|
|
21
|
+
* Read-only, deliberately. Pithy attaches routes to zones and never creates, transfers, or deletes one —
|
|
22
|
+
* a zone is the adopter's relationship with their registrar, not a resource this toolset provisions. The
|
|
23
|
+
* scoped token needs only `Zone:Read`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** One zone on the account. */
|
|
27
|
+
export const ZoneInfo = z
|
|
28
|
+
.object({
|
|
29
|
+
id: z.string().describe("The CF-assigned zone id, which every zone-scoped API call is addressed by."),
|
|
30
|
+
name: z
|
|
31
|
+
.string()
|
|
32
|
+
.describe(
|
|
33
|
+
"The registrable domain, e.g. `example.com`. This is the value a Worker's `domains` declaration names as its `zone`, and what wrangler writes as `zone_name`.",
|
|
34
|
+
),
|
|
35
|
+
status: z
|
|
36
|
+
.string()
|
|
37
|
+
.describe(
|
|
38
|
+
"The zone's lifecycle status — `active` once Cloudflare is serving it, otherwise `pending`, `initializing`, or `moved`. A non-active zone cannot carry a custom domain yet, so a picker shows it and says so rather than hiding it.",
|
|
39
|
+
),
|
|
40
|
+
})
|
|
41
|
+
.describe("One Cloudflare zone: the registrable domain a custom domain can attach to, and whether it is live.");
|
|
42
|
+
export type ZoneInfo = z.output<typeof ZoneInfo>;
|
|
43
|
+
|
|
44
|
+
/** Read the account's zones. Never creates or deletes — a zone is the adopter's, not ours to provision. */
|
|
45
|
+
export class CloudflareZonesManager extends CloudflareManager {
|
|
46
|
+
getServiceType(): string {
|
|
47
|
+
return "Cloudflare Zones";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Prove access by listing. A read, and never throws — the caller decides what an inaccessible account means. */
|
|
51
|
+
async validateServiceAccess(): Promise<boolean> {
|
|
52
|
+
try {
|
|
53
|
+
await this.listZones();
|
|
54
|
+
return true;
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Every zone on this account, name-sorted so a picker is stable between runs.
|
|
62
|
+
*
|
|
63
|
+
* Filtered to the account, because a user-bound token can see zones on accounts this project has
|
|
64
|
+
* nothing to do with — offering those would invite someone to attach a Worker to a zone that another
|
|
65
|
+
* account owns, which fails at deploy with an error that names neither problem.
|
|
66
|
+
*/
|
|
67
|
+
async listZones(): Promise<ZoneInfo[]> {
|
|
68
|
+
return cloudflareRequest("list zones", async () => {
|
|
69
|
+
const zones: ZoneInfo[] = [];
|
|
70
|
+
for await (const zone of this.getClient().zones.list({ account: { id: this.accountId } })) {
|
|
71
|
+
const parsed = ZoneInfo.safeParse(zone);
|
|
72
|
+
if (parsed.success) zones.push(parsed.data);
|
|
73
|
+
}
|
|
74
|
+
return zones.sort((a, b) => a.name.localeCompare(b.name));
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The zone a hostname belongs to, or `null`.
|
|
80
|
+
*
|
|
81
|
+
* Matches the **longest** zone name that the hostname sits under, which is the only correct rule when
|
|
82
|
+
* an account holds both `example.com` and `eu.example.com`: `api.eu.example.com` belongs to the latter,
|
|
83
|
+
* and picking the first match would attach it to the wrong zone. A public-suffix guess is not used at
|
|
84
|
+
* all — a zone can itself be a subdomain, and the account's own list is the authority.
|
|
85
|
+
*/
|
|
86
|
+
async findZoneForHostname(hostname: string): Promise<ZoneInfo | null> {
|
|
87
|
+
const zones = await this.listZones();
|
|
88
|
+
const candidates = zones.filter((zone) => hostname === zone.name || hostname.endsWith(`.${zone.name}`));
|
|
89
|
+
if (candidates.length === 0) return null;
|
|
90
|
+
return candidates.reduce((longest, zone) => (zone.name.length > longest.name.length ? zone : longest));
|
|
91
|
+
}
|
|
92
|
+
}
|