@pithy-sh/core 0.3.0 → 0.3.1
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/dist/error/messageParams.d.ts +28 -0
- package/dist/error/messageParams.d.ts.map +1 -0
- package/dist/error/messageParams.js +31 -0
- package/dist/error/terminal.d.ts +16 -5
- package/dist/error/terminal.d.ts.map +1 -1
- package/dist/error/terminal.js +16 -5
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/workflow/stepMessage.d.ts +28 -3
- package/dist/workflow/stepMessage.d.ts.map +1 -1
- package/dist/workflow/stepMessage.js +48 -6
- package/package.json +1 -1
- package/src/error/messageParams.ts +38 -0
- package/src/error/terminal.ts +16 -5
- package/src/version.generated.ts +1 -1
- package/src/workflow/stepMessage.ts +52 -6
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import type { KitErrorCode } from "./payload";
|
|
4
|
+
/**
|
|
5
|
+
* The `params` a throw site **guarantees** for a kit error code — and therefore the only names a
|
|
6
|
+
* locale may write as a `{placeholder}` in that code's sentence.
|
|
7
|
+
*
|
|
8
|
+
* **Why a declaration exists at all.** `params` crosses the wire with `message` so a translating
|
|
9
|
+
* client can render its own wording for a code (CLAUDE.md §Errors), and `interpolate` leaves a
|
|
10
|
+
* placeholder nobody supplied written out as `{apiAnswer}`. So a locale writing a placeholder is
|
|
11
|
+
* making a claim about a *call site*, and a call is not a declaration — there was nothing in this
|
|
12
|
+
* repository for a catalog test to read, which is why `@pithy-sh/i18n` could only pin the absence of
|
|
13
|
+
* placeholders. This is that missing half: one table, in `core`, that both sides import.
|
|
14
|
+
*
|
|
15
|
+
* **Guaranteed means every throw, not most.** A name belongs here only when the code's throw sites
|
|
16
|
+
* pass it on **every** path, including the degraded one — a Cloudflare call that never reached
|
|
17
|
+
* Cloudflare still supplies `apiAnswer`, as the empty string. A param a throw site passes only
|
|
18
|
+
* sometimes is still useful to a client reading `payload.params` directly, and is still stripped from
|
|
19
|
+
* nothing; it simply may not be written into a sentence, because the sentence has to render on the
|
|
20
|
+
* path where it is missing.
|
|
21
|
+
*
|
|
22
|
+
* A code absent from this table guarantees nothing, which is the honest default and the state of all
|
|
23
|
+
* but one of them.
|
|
24
|
+
*/
|
|
25
|
+
export declare const GUARANTEED_ERROR_PARAMS: Partial<Record<KitErrorCode, readonly string[]>>;
|
|
26
|
+
/** The names a locale may interpolate into `code`'s sentence. Empty when the code guarantees none. */
|
|
27
|
+
export declare function guaranteedErrorParams(code: string): readonly string[];
|
|
28
|
+
//# sourceMappingURL=messageParams.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"messageParams.d.ts","sourceRoot":"","sources":["../../src/error/messageParams.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAE9C;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,uBAAuB,EAAE,OAAO,CAAC,MAAM,CAAC,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC,CAMpF,CAAC;AAEF,sGAAsG;AACtG,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,CAErE"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
//#region src/error/messageParams.ts
|
|
4
|
+
/**
|
|
5
|
+
* The `params` a throw site **guarantees** for a kit error code — and therefore the only names a
|
|
6
|
+
* locale may write as a `{placeholder}` in that code's sentence.
|
|
7
|
+
*
|
|
8
|
+
* **Why a declaration exists at all.** `params` crosses the wire with `message` so a translating
|
|
9
|
+
* client can render its own wording for a code (CLAUDE.md §Errors), and `interpolate` leaves a
|
|
10
|
+
* placeholder nobody supplied written out as `{apiAnswer}`. So a locale writing a placeholder is
|
|
11
|
+
* making a claim about a *call site*, and a call is not a declaration — there was nothing in this
|
|
12
|
+
* repository for a catalog test to read, which is why `@pithy-sh/i18n` could only pin the absence of
|
|
13
|
+
* placeholders. This is that missing half: one table, in `core`, that both sides import.
|
|
14
|
+
*
|
|
15
|
+
* **Guaranteed means every throw, not most.** A name belongs here only when the code's throw sites
|
|
16
|
+
* pass it on **every** path, including the degraded one — a Cloudflare call that never reached
|
|
17
|
+
* Cloudflare still supplies `apiAnswer`, as the empty string. A param a throw site passes only
|
|
18
|
+
* sometimes is still useful to a client reading `payload.params` directly, and is still stripped from
|
|
19
|
+
* nothing; it simply may not be written into a sentence, because the sentence has to render on the
|
|
20
|
+
* path where it is missing.
|
|
21
|
+
*
|
|
22
|
+
* A code absent from this table guarantees nothing, which is the honest default and the state of all
|
|
23
|
+
* but one of them.
|
|
24
|
+
*/
|
|
25
|
+
const GUARANTEED_ERROR_PARAMS = { "cloudflare/request_failed": ["apiAnswer"] };
|
|
26
|
+
/** The names a locale may interpolate into `code`'s sentence. Empty when the code guarantees none. */
|
|
27
|
+
function guaranteedErrorParams(code) {
|
|
28
|
+
return GUARANTEED_ERROR_PARAMS[code] ?? [];
|
|
29
|
+
}
|
|
30
|
+
//#endregion
|
|
31
|
+
export { GUARANTEED_ERROR_PARAMS, guaranteedErrorParams };
|
package/dist/error/terminal.d.ts
CHANGED
|
@@ -3,11 +3,22 @@
|
|
|
3
3
|
import type { ErrorPayload } from "./payload";
|
|
4
4
|
import { PublicErrorPayload } from "./payload";
|
|
5
5
|
/**
|
|
6
|
-
* Render an error for the terminal: the public `message`
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* `
|
|
10
|
-
*
|
|
6
|
+
* Render an error for the terminal: the public `message` on top and the optional `action` last — the
|
|
7
|
+
* brand-voice shape `docs/CLI.md` §3.3 specifies. This unifies the CLI's error output into the one
|
|
8
|
+
* `PithyError` family: the CLI catches a `PithyError`, colorizes via its `style.ts`, and prints this.
|
|
9
|
+
* `detail` is internal and never rendered. The same payload that encodes to HTTP renders here — one
|
|
10
|
+
* error, two surfaces.
|
|
11
|
+
*
|
|
12
|
+
* **`message` is one line, and the newline this adds is the only one in the rendering.** #534 tried
|
|
13
|
+
* the other way — a problem line with an upstream's `errors[]` indented beneath it, which reads
|
|
14
|
+
* beautifully in a terminal and is unencodable everywhere else. The newline is a *field separator* in
|
|
15
|
+
* two places that matter more than the indent does. `../workflow/stepMessage` uses it to carry a
|
|
16
|
+
* terminal step's remedy across a durable boundary, so a multi-line `message` raised inside a Workflow
|
|
17
|
+
* step was declined whole and the operator got the platform's prose about durable execution instead of
|
|
18
|
+
* the refusal. And here, the action line is the one line after the message, so a `message` carrying a
|
|
19
|
+
* break puts upstream text where the remedy goes — a forged action line, from text a Worker we did not
|
|
20
|
+
* write can choose. Upstream words are marked with a word (`Cloudflare said:`), never with a line: a
|
|
21
|
+
* word survives a step record, a `--json` line and a browser.
|
|
11
22
|
*/
|
|
12
23
|
export declare function renderTerminal(payload: ErrorPayload): string;
|
|
13
24
|
/** An error as an operator's machine surface states it: the wire fields, plus the remedy. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"terminal.d.ts","sourceRoot":"","sources":["../../src/error/terminal.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAC9C,OAAO,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAC;AAE/C
|
|
1
|
+
{"version":3,"file":"terminal.d.ts","sourceRoot":"","sources":["../../src/error/terminal.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAC9C,OAAO,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAC;AAE/C;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,YAAY,GAAG,MAAM,CAE5D;AAED,6FAA6F;AAC7F,MAAM,MAAM,aAAa,GAAG,kBAAkB,GAAG;IAAE,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAErE;;;;;;;;;;;GAWG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,YAAY,GAAG,aAAa,CAGlE"}
|
package/dist/error/terminal.js
CHANGED
|
@@ -3,11 +3,22 @@
|
|
|
3
3
|
import { PublicErrorPayload } from "./payload.js";
|
|
4
4
|
//#region src/error/terminal.ts
|
|
5
5
|
/**
|
|
6
|
-
* Render an error for the terminal: the public `message`
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* `
|
|
10
|
-
*
|
|
6
|
+
* Render an error for the terminal: the public `message` on top and the optional `action` last — the
|
|
7
|
+
* brand-voice shape `docs/CLI.md` §3.3 specifies. This unifies the CLI's error output into the one
|
|
8
|
+
* `PithyError` family: the CLI catches a `PithyError`, colorizes via its `style.ts`, and prints this.
|
|
9
|
+
* `detail` is internal and never rendered. The same payload that encodes to HTTP renders here — one
|
|
10
|
+
* error, two surfaces.
|
|
11
|
+
*
|
|
12
|
+
* **`message` is one line, and the newline this adds is the only one in the rendering.** #534 tried
|
|
13
|
+
* the other way — a problem line with an upstream's `errors[]` indented beneath it, which reads
|
|
14
|
+
* beautifully in a terminal and is unencodable everywhere else. The newline is a *field separator* in
|
|
15
|
+
* two places that matter more than the indent does. `../workflow/stepMessage` uses it to carry a
|
|
16
|
+
* terminal step's remedy across a durable boundary, so a multi-line `message` raised inside a Workflow
|
|
17
|
+
* step was declined whole and the operator got the platform's prose about durable execution instead of
|
|
18
|
+
* the refusal. And here, the action line is the one line after the message, so a `message` carrying a
|
|
19
|
+
* break puts upstream text where the remedy goes — a forged action line, from text a Worker we did not
|
|
20
|
+
* write can choose. Upstream words are marked with a word (`Cloudflare said:`), never with a line: a
|
|
21
|
+
* word survives a step record, a `--json` line and a browser.
|
|
11
22
|
*/
|
|
12
23
|
function renderTerminal(payload) {
|
|
13
24
|
return payload.action ? `${payload.message}\n${payload.action}` : payload.message;
|
|
@@ -3,5 +3,5 @@
|
|
|
3
3
|
/** This package's npm name — the join key against a release feed. */
|
|
4
4
|
export declare const PACKAGE_NAME = "@pithy-sh/core";
|
|
5
5
|
/** This package's version, stamped from its own package.json at generation time. */
|
|
6
|
-
export declare const PACKAGE_VERSION = "0.3.
|
|
6
|
+
export declare const PACKAGE_VERSION = "0.3.1";
|
|
7
7
|
//# sourceMappingURL=version.generated.d.ts.map
|
|
@@ -4,6 +4,6 @@
|
|
|
4
4
|
/** This package's npm name — the join key against a release feed. */
|
|
5
5
|
const PACKAGE_NAME = "@pithy-sh/core";
|
|
6
6
|
/** This package's version, stamped from its own package.json at generation time. */
|
|
7
|
-
const PACKAGE_VERSION = "0.3.
|
|
7
|
+
const PACKAGE_VERSION = "0.3.1";
|
|
8
8
|
//#endregion
|
|
9
9
|
export { PACKAGE_NAME, PACKAGE_VERSION };
|
|
@@ -68,6 +68,21 @@
|
|
|
68
68
|
* `message`, throw-site context in `detail`, and a durable boundary is not a reason to move the line.
|
|
69
69
|
* `action` crosses because `action` is already operator-facing — the CLI prints it and `operatorError`
|
|
70
70
|
* includes it. This stops losing a field that was always meant for this reader.
|
|
71
|
+
*
|
|
72
|
+
* ## The encoder owns producing something the reader accepts
|
|
73
|
+
*
|
|
74
|
+
* The reader is strict, and it has to be: text the kit did not write must not become an operator's
|
|
75
|
+
* sentence. The consequence is that a *writer* handing it a shape it declines loses the whole channel —
|
|
76
|
+
* the code, the sentence and the remedy at once — and the operator is handed `core/workflow_failed` 500
|
|
77
|
+
* with the platform's prose about durable execution. That is not a hypothetical. #534 put an upstream's
|
|
78
|
+
* `errors[]` into a multi-line `message`, and every Cloudflare refusal raised inside a `classifiedSteps`
|
|
79
|
+
* step stopped crossing this boundary at all: the first newline read as the code/action separator and
|
|
80
|
+
* the second as a shape this never writes, so the read returned `null` and the refusal died at the step.
|
|
81
|
+
*
|
|
82
|
+
* So {@link encodeWorkflowStepMessage} is **total**: whatever fields it is handed, what it writes decodes
|
|
83
|
+
* back. Each half is flattened to one line and, past the bound, truncated with an ellipsis rather than
|
|
84
|
+
* dropped. The asymmetry with the reader is deliberate and is the whole point — the writer owns text the
|
|
85
|
+
* kit authored and may reshape it; the reader is handed text from a Worker we did not write and may not.
|
|
71
86
|
*/
|
|
72
87
|
/**
|
|
73
88
|
* The separator between the step's sentence and its action. One newline, stated once.
|
|
@@ -107,9 +122,19 @@ export declare function splitWorkflowStepCode(text: string): {
|
|
|
107
122
|
/**
|
|
108
123
|
* The text a terminal step throws. The inverse of {@link decodeWorkflowStepMessage}, and tested as one.
|
|
109
124
|
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
* is
|
|
125
|
+
* **What this writes, that reads back — for a well-formed code and a sentence there is one.** Each half
|
|
126
|
+
* goes through {@link encodable}, so a multi-line sentence is flattened rather than fatal and an overlong
|
|
127
|
+
* one is truncated rather than declined. An action is appended only when there is one left after that —
|
|
128
|
+
* a remedy that was empty or only whitespace leaves no trailing separator, because a dangling separator
|
|
129
|
+
* is a field the reader would have to decline.
|
|
130
|
+
*
|
|
131
|
+
* **Two inputs it deliberately cannot represent, so "total" is not the whole word for it.** An empty
|
|
132
|
+
* `message` encodes to text {@link decodeWorkflowStepMessage} declines, and so does a `code` that does
|
|
133
|
+
* not match `CODE_PREFIX`. Both refusals are the boundary working: a step that failed without a sentence
|
|
134
|
+
* has none to promote — the caller's own `fallbackMessage` is what the operator should see — and a code
|
|
135
|
+
* the reader cannot recognize is exactly the foreign text this encoding exists to keep out of an
|
|
136
|
+
* operator's sentence. Neither is reachable from a kit throw site; an adopter code declared through
|
|
137
|
+
* `defineErrorPayload` is checked against the same shape.
|
|
113
138
|
*/
|
|
114
139
|
export declare function encodeWorkflowStepMessage(fields: WorkflowStepMessage): string;
|
|
115
140
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"stepMessage.d.ts","sourceRoot":"","sources":["../../src/workflow/stepMessage.ts"],"names":[],"mappings":"AAGA
|
|
1
|
+
{"version":3,"file":"stepMessage.d.ts","sourceRoot":"","sources":["../../src/workflow/stepMessage.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmFG;AAEH;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,OAAO,CAAC;AAE5C;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAc1C,uGAAuG;AACvG,MAAM,WAAW,mBAAmB;IAClC,wEAAwE;IACxE,IAAI,EAAE,MAAM,CAAC;IACb,0FAA0F;IAC1F,OAAO,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAInF;AAuBD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,mBAAmB,GAAG,MAAM,CAK7E;AAED;;;;;;;GAOG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,mBAAmB,GAAG,IAAI,CAYlF"}
|
|
@@ -69,6 +69,21 @@
|
|
|
69
69
|
* `message`, throw-site context in `detail`, and a durable boundary is not a reason to move the line.
|
|
70
70
|
* `action` crosses because `action` is already operator-facing — the CLI prints it and `operatorError`
|
|
71
71
|
* includes it. This stops losing a field that was always meant for this reader.
|
|
72
|
+
*
|
|
73
|
+
* ## The encoder owns producing something the reader accepts
|
|
74
|
+
*
|
|
75
|
+
* The reader is strict, and it has to be: text the kit did not write must not become an operator's
|
|
76
|
+
* sentence. The consequence is that a *writer* handing it a shape it declines loses the whole channel —
|
|
77
|
+
* the code, the sentence and the remedy at once — and the operator is handed `core/workflow_failed` 500
|
|
78
|
+
* with the platform's prose about durable execution. That is not a hypothetical. #534 put an upstream's
|
|
79
|
+
* `errors[]` into a multi-line `message`, and every Cloudflare refusal raised inside a `classifiedSteps`
|
|
80
|
+
* step stopped crossing this boundary at all: the first newline read as the code/action separator and
|
|
81
|
+
* the second as a shape this never writes, so the read returned `null` and the refusal died at the step.
|
|
82
|
+
*
|
|
83
|
+
* So {@link encodeWorkflowStepMessage} is **total**: whatever fields it is handed, what it writes decodes
|
|
84
|
+
* back. Each half is flattened to one line and, past the bound, truncated with an ellipsis rather than
|
|
85
|
+
* dropped. The asymmetry with the reader is deliberate and is the whole point — the writer owns text the
|
|
86
|
+
* kit authored and may reshape it; the reader is handed text from a Worker we did not write and may not.
|
|
72
87
|
*/
|
|
73
88
|
/**
|
|
74
89
|
* The separator between the step's sentence and its action. One newline, stated once.
|
|
@@ -111,16 +126,43 @@ function splitWorkflowStepCode(text) {
|
|
|
111
126
|
};
|
|
112
127
|
}
|
|
113
128
|
/**
|
|
129
|
+
* One half as this encoding can carry it: a single line, within the bound.
|
|
130
|
+
*
|
|
131
|
+
* Both reshapes exist because the alternative is losing the field, and the two ways a half can be
|
|
132
|
+
* unreadable are the two ways a writer actually gets it wrong. A line break would encode a shape the
|
|
133
|
+
* reader declines — the sentence would take the remedy down with it, or the remedy would forge a
|
|
134
|
+
* second field — so every break collapses to a space, exactly as `@pithy-sh/cloudflare` flattens an
|
|
135
|
+
* upstream's own text before it lands in `message`. Length is the same argument with a different
|
|
136
|
+
* cause: half a sentence naming its code and its remedy beats the platform's prose about durable
|
|
137
|
+
* execution, so the tail is spent rather than the field. The whole of it was never here anyway —
|
|
138
|
+
* `detail` holds the raw text, on the far side of a boundary it does not cross.
|
|
139
|
+
*/
|
|
140
|
+
function encodable(text) {
|
|
141
|
+
const flattened = text.replace(/\s*[\n\r]+\s*/g, " ").trim();
|
|
142
|
+
if (flattened.length <= 512) return flattened;
|
|
143
|
+
return `${flattened.slice(0, 511).trimEnd()}…`;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
114
146
|
* The text a terminal step throws. The inverse of {@link decodeWorkflowStepMessage}, and tested as one.
|
|
115
147
|
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* is
|
|
148
|
+
* **What this writes, that reads back — for a well-formed code and a sentence there is one.** Each half
|
|
149
|
+
* goes through {@link encodable}, so a multi-line sentence is flattened rather than fatal and an overlong
|
|
150
|
+
* one is truncated rather than declined. An action is appended only when there is one left after that —
|
|
151
|
+
* a remedy that was empty or only whitespace leaves no trailing separator, because a dangling separator
|
|
152
|
+
* is a field the reader would have to decline.
|
|
153
|
+
*
|
|
154
|
+
* **Two inputs it deliberately cannot represent, so "total" is not the whole word for it.** An empty
|
|
155
|
+
* `message` encodes to text {@link decodeWorkflowStepMessage} declines, and so does a `code` that does
|
|
156
|
+
* not match `CODE_PREFIX`. Both refusals are the boundary working: a step that failed without a sentence
|
|
157
|
+
* has none to promote — the caller's own `fallbackMessage` is what the operator should see — and a code
|
|
158
|
+
* the reader cannot recognize is exactly the foreign text this encoding exists to keep out of an
|
|
159
|
+
* operator's sentence. Neither is reachable from a kit throw site; an adopter code declared through
|
|
160
|
+
* `defineErrorPayload` is checked against the same shape.
|
|
119
161
|
*/
|
|
120
162
|
function encodeWorkflowStepMessage(fields) {
|
|
121
|
-
const stated = `${fields.code}: ${fields.message}`;
|
|
122
|
-
const action = fields.action
|
|
123
|
-
if (action ===
|
|
163
|
+
const stated = `${fields.code}: ${encodable(fields.message)}`;
|
|
164
|
+
const action = encodable(fields.action ?? "");
|
|
165
|
+
if (action === "") return stated;
|
|
124
166
|
return `${stated}
|
|
125
167
|
${action}`;
|
|
126
168
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { KitErrorCode } from "./payload";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `params` a throw site **guarantees** for a kit error code — and therefore the only names a
|
|
8
|
+
* locale may write as a `{placeholder}` in that code's sentence.
|
|
9
|
+
*
|
|
10
|
+
* **Why a declaration exists at all.** `params` crosses the wire with `message` so a translating
|
|
11
|
+
* client can render its own wording for a code (CLAUDE.md §Errors), and `interpolate` leaves a
|
|
12
|
+
* placeholder nobody supplied written out as `{apiAnswer}`. So a locale writing a placeholder is
|
|
13
|
+
* making a claim about a *call site*, and a call is not a declaration — there was nothing in this
|
|
14
|
+
* repository for a catalog test to read, which is why `@pithy-sh/i18n` could only pin the absence of
|
|
15
|
+
* placeholders. This is that missing half: one table, in `core`, that both sides import.
|
|
16
|
+
*
|
|
17
|
+
* **Guaranteed means every throw, not most.** A name belongs here only when the code's throw sites
|
|
18
|
+
* pass it on **every** path, including the degraded one — a Cloudflare call that never reached
|
|
19
|
+
* Cloudflare still supplies `apiAnswer`, as the empty string. A param a throw site passes only
|
|
20
|
+
* sometimes is still useful to a client reading `payload.params` directly, and is still stripped from
|
|
21
|
+
* nothing; it simply may not be written into a sentence, because the sentence has to render on the
|
|
22
|
+
* path where it is missing.
|
|
23
|
+
*
|
|
24
|
+
* A code absent from this table guarantees nothing, which is the honest default and the state of all
|
|
25
|
+
* but one of them.
|
|
26
|
+
*/
|
|
27
|
+
export const GUARANTEED_ERROR_PARAMS: Partial<Record<KitErrorCode, readonly string[]>> = {
|
|
28
|
+
// `@pithy-sh/cloudflare`'s `cloudflareRefusal` composes every Cloudflare refusal and always passes
|
|
29
|
+
// `apiAnswer`: Cloudflare's own code and sentence, punctuated to append (`": 10000 Authentication
|
|
30
|
+
// error"`), or `""` when the call failed without an answer. The value carries its own separator so
|
|
31
|
+
// one sentence renders correctly both ways — a locale cannot write an `if`.
|
|
32
|
+
"cloudflare/request_failed": ["apiAnswer"],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** The names a locale may interpolate into `code`'s sentence. Empty when the code guarantees none. */
|
|
36
|
+
export function guaranteedErrorParams(code: string): readonly string[] {
|
|
37
|
+
return GUARANTEED_ERROR_PARAMS[code as KitErrorCode] ?? [];
|
|
38
|
+
}
|
package/src/error/terminal.ts
CHANGED
|
@@ -5,11 +5,22 @@ import type { ErrorPayload } from "./payload";
|
|
|
5
5
|
import { PublicErrorPayload } from "./payload";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* Render an error for the terminal: the public `message`
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* `
|
|
12
|
-
*
|
|
8
|
+
* Render an error for the terminal: the public `message` on top and the optional `action` last — the
|
|
9
|
+
* brand-voice shape `docs/CLI.md` §3.3 specifies. This unifies the CLI's error output into the one
|
|
10
|
+
* `PithyError` family: the CLI catches a `PithyError`, colorizes via its `style.ts`, and prints this.
|
|
11
|
+
* `detail` is internal and never rendered. The same payload that encodes to HTTP renders here — one
|
|
12
|
+
* error, two surfaces.
|
|
13
|
+
*
|
|
14
|
+
* **`message` is one line, and the newline this adds is the only one in the rendering.** #534 tried
|
|
15
|
+
* the other way — a problem line with an upstream's `errors[]` indented beneath it, which reads
|
|
16
|
+
* beautifully in a terminal and is unencodable everywhere else. The newline is a *field separator* in
|
|
17
|
+
* two places that matter more than the indent does. `../workflow/stepMessage` uses it to carry a
|
|
18
|
+
* terminal step's remedy across a durable boundary, so a multi-line `message` raised inside a Workflow
|
|
19
|
+
* step was declined whole and the operator got the platform's prose about durable execution instead of
|
|
20
|
+
* the refusal. And here, the action line is the one line after the message, so a `message` carrying a
|
|
21
|
+
* break puts upstream text where the remedy goes — a forged action line, from text a Worker we did not
|
|
22
|
+
* write can choose. Upstream words are marked with a word (`Cloudflare said:`), never with a line: a
|
|
23
|
+
* word survives a step record, a `--json` line and a browser.
|
|
13
24
|
*/
|
|
14
25
|
export function renderTerminal(payload: ErrorPayload): string {
|
|
15
26
|
return payload.action ? `${payload.message}\n${payload.action}` : payload.message;
|
package/src/version.generated.ts
CHANGED
|
@@ -69,6 +69,21 @@
|
|
|
69
69
|
* `message`, throw-site context in `detail`, and a durable boundary is not a reason to move the line.
|
|
70
70
|
* `action` crosses because `action` is already operator-facing — the CLI prints it and `operatorError`
|
|
71
71
|
* includes it. This stops losing a field that was always meant for this reader.
|
|
72
|
+
*
|
|
73
|
+
* ## The encoder owns producing something the reader accepts
|
|
74
|
+
*
|
|
75
|
+
* The reader is strict, and it has to be: text the kit did not write must not become an operator's
|
|
76
|
+
* sentence. The consequence is that a *writer* handing it a shape it declines loses the whole channel —
|
|
77
|
+
* the code, the sentence and the remedy at once — and the operator is handed `core/workflow_failed` 500
|
|
78
|
+
* with the platform's prose about durable execution. That is not a hypothetical. #534 put an upstream's
|
|
79
|
+
* `errors[]` into a multi-line `message`, and every Cloudflare refusal raised inside a `classifiedSteps`
|
|
80
|
+
* step stopped crossing this boundary at all: the first newline read as the code/action separator and
|
|
81
|
+
* the second as a shape this never writes, so the read returned `null` and the refusal died at the step.
|
|
82
|
+
*
|
|
83
|
+
* So {@link encodeWorkflowStepMessage} is **total**: whatever fields it is handed, what it writes decodes
|
|
84
|
+
* back. Each half is flattened to one line and, past the bound, truncated with an ellipsis rather than
|
|
85
|
+
* dropped. The asymmetry with the reader is deliberate and is the whole point — the writer owns text the
|
|
86
|
+
* kit authored and may reshape it; the reader is handed text from a Worker we did not write and may not.
|
|
72
87
|
*/
|
|
73
88
|
|
|
74
89
|
/**
|
|
@@ -123,17 +138,48 @@ export function splitWorkflowStepCode(text: string): { code?: string; rest: stri
|
|
|
123
138
|
return { code: matched[1], rest: text.slice(matched[0].length) };
|
|
124
139
|
}
|
|
125
140
|
|
|
141
|
+
/**
|
|
142
|
+
* One half as this encoding can carry it: a single line, within the bound.
|
|
143
|
+
*
|
|
144
|
+
* Both reshapes exist because the alternative is losing the field, and the two ways a half can be
|
|
145
|
+
* unreadable are the two ways a writer actually gets it wrong. A line break would encode a shape the
|
|
146
|
+
* reader declines — the sentence would take the remedy down with it, or the remedy would forge a
|
|
147
|
+
* second field — so every break collapses to a space, exactly as `@pithy-sh/cloudflare` flattens an
|
|
148
|
+
* upstream's own text before it lands in `message`. Length is the same argument with a different
|
|
149
|
+
* cause: half a sentence naming its code and its remedy beats the platform's prose about durable
|
|
150
|
+
* execution, so the tail is spent rather than the field. The whole of it was never here anyway —
|
|
151
|
+
* `detail` holds the raw text, on the far side of a boundary it does not cross.
|
|
152
|
+
*/
|
|
153
|
+
function encodable(text: string): string {
|
|
154
|
+
// The break and the whitespace that dressed it: an indent under a line is structure, and structure
|
|
155
|
+
// that survives as run-on spaces reads as a typo. Whitespace elsewhere in the half is left alone —
|
|
156
|
+
// this reshapes what the line break did, not what the sentence says.
|
|
157
|
+
const flattened = text.replace(/\s*[\n\r]+\s*/g, " ").trim();
|
|
158
|
+
if (flattened.length <= MAX_WORKFLOW_STEP_TEXT) return flattened;
|
|
159
|
+
return `${flattened.slice(0, MAX_WORKFLOW_STEP_TEXT - 1).trimEnd()}…`;
|
|
160
|
+
}
|
|
161
|
+
|
|
126
162
|
/**
|
|
127
163
|
* The text a terminal step throws. The inverse of {@link decodeWorkflowStepMessage}, and tested as one.
|
|
128
164
|
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
* is
|
|
165
|
+
* **What this writes, that reads back — for a well-formed code and a sentence there is one.** Each half
|
|
166
|
+
* goes through {@link encodable}, so a multi-line sentence is flattened rather than fatal and an overlong
|
|
167
|
+
* one is truncated rather than declined. An action is appended only when there is one left after that —
|
|
168
|
+
* a remedy that was empty or only whitespace leaves no trailing separator, because a dangling separator
|
|
169
|
+
* is a field the reader would have to decline.
|
|
170
|
+
*
|
|
171
|
+
* **Two inputs it deliberately cannot represent, so "total" is not the whole word for it.** An empty
|
|
172
|
+
* `message` encodes to text {@link decodeWorkflowStepMessage} declines, and so does a `code` that does
|
|
173
|
+
* not match `CODE_PREFIX`. Both refusals are the boundary working: a step that failed without a sentence
|
|
174
|
+
* has none to promote — the caller's own `fallbackMessage` is what the operator should see — and a code
|
|
175
|
+
* the reader cannot recognize is exactly the foreign text this encoding exists to keep out of an
|
|
176
|
+
* operator's sentence. Neither is reachable from a kit throw site; an adopter code declared through
|
|
177
|
+
* `defineErrorPayload` is checked against the same shape.
|
|
132
178
|
*/
|
|
133
179
|
export function encodeWorkflowStepMessage(fields: WorkflowStepMessage): string {
|
|
134
|
-
const stated = `${fields.code}: ${fields.message}`;
|
|
135
|
-
const action = fields.action
|
|
136
|
-
if (action ===
|
|
180
|
+
const stated = `${fields.code}: ${encodable(fields.message)}`;
|
|
181
|
+
const action = encodable(fields.action ?? "");
|
|
182
|
+
if (action === "") return stated;
|
|
137
183
|
return `${stated}${WORKFLOW_STEP_SEPARATOR}${action}`;
|
|
138
184
|
}
|
|
139
185
|
|