mandala-computer-mcp 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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +544 -0
  3. package/dist/api.d.ts +186 -0
  4. package/dist/api.d.ts.map +1 -0
  5. package/dist/api.js +932 -0
  6. package/dist/api.js.map +1 -0
  7. package/dist/cli.d.ts +55 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +292 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/errors.d.ts +560 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +873 -0
  14. package/dist/errors.js.map +1 -0
  15. package/dist/events.d.ts +406 -0
  16. package/dist/events.d.ts.map +1 -0
  17. package/dist/events.js +1679 -0
  18. package/dist/events.js.map +1 -0
  19. package/dist/format.d.ts +125 -0
  20. package/dist/format.d.ts.map +1 -0
  21. package/dist/format.js +180 -0
  22. package/dist/format.js.map +1 -0
  23. package/dist/http.d.ts +46 -0
  24. package/dist/http.d.ts.map +1 -0
  25. package/dist/http.js +792 -0
  26. package/dist/http.js.map +1 -0
  27. package/dist/index.d.ts +13 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +12 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/paths.d.ts +394 -0
  32. package/dist/paths.d.ts.map +1 -0
  33. package/dist/paths.js +677 -0
  34. package/dist/paths.js.map +1 -0
  35. package/dist/server.d.ts +18 -0
  36. package/dist/server.d.ts.map +1 -0
  37. package/dist/server.js +97 -0
  38. package/dist/server.js.map +1 -0
  39. package/dist/session.d.ts +78 -0
  40. package/dist/session.d.ts.map +1 -0
  41. package/dist/session.js +166 -0
  42. package/dist/session.js.map +1 -0
  43. package/dist/stdio.d.ts +11 -0
  44. package/dist/stdio.d.ts.map +1 -0
  45. package/dist/stdio.js +43 -0
  46. package/dist/stdio.js.map +1 -0
  47. package/dist/tools/agent.d.ts +16 -0
  48. package/dist/tools/agent.d.ts.map +1 -0
  49. package/dist/tools/agent.js +147 -0
  50. package/dist/tools/agent.js.map +1 -0
  51. package/dist/tools/computers.d.ts +3 -0
  52. package/dist/tools/computers.d.ts.map +1 -0
  53. package/dist/tools/computers.js +1037 -0
  54. package/dist/tools/computers.js.map +1 -0
  55. package/dist/tools/events.d.ts +3 -0
  56. package/dist/tools/events.d.ts.map +1 -0
  57. package/dist/tools/events.js +1077 -0
  58. package/dist/tools/events.js.map +1 -0
  59. package/dist/tools/guest.d.ts +3 -0
  60. package/dist/tools/guest.d.ts.map +1 -0
  61. package/dist/tools/guest.js +761 -0
  62. package/dist/tools/guest.js.map +1 -0
  63. package/dist/tools/input.d.ts +3 -0
  64. package/dist/tools/input.d.ts.map +1 -0
  65. package/dist/tools/input.js +240 -0
  66. package/dist/tools/input.js.map +1 -0
  67. package/dist/tools/snapshots.d.ts +3 -0
  68. package/dist/tools/snapshots.d.ts.map +1 -0
  69. package/dist/tools/snapshots.js +333 -0
  70. package/dist/tools/snapshots.js.map +1 -0
  71. package/dist/tools/templates.d.ts +3 -0
  72. package/dist/tools/templates.d.ts.map +1 -0
  73. package/dist/tools/templates.js +492 -0
  74. package/dist/tools/templates.js.map +1 -0
  75. package/dist/tools/types.d.ts +18 -0
  76. package/dist/tools/types.d.ts.map +1 -0
  77. package/dist/tools/types.js +2 -0
  78. package/dist/tools/types.js.map +1 -0
  79. package/dist/tools/webhooks.d.ts +3 -0
  80. package/dist/tools/webhooks.d.ts.map +1 -0
  81. package/dist/tools/webhooks.js +260 -0
  82. package/dist/tools/webhooks.js.map +1 -0
  83. package/package.json +59 -0
package/dist/errors.js ADDED
@@ -0,0 +1,873 @@
1
+ /**
2
+ * What the platform's status codes mean, as types.
3
+ *
4
+ * The distinctions here are the ones an agent has to act on and cannot infer
5
+ * from prose. A 400 never clears and retrying it burns a turn. A 402 is a plan
6
+ * limit, which no amount of waiting fixes and which the user — not the model —
7
+ * has to resolve. A 409 is the one that is not uniform: most of them are a
8
+ * passing state and worth retrying, and some are a decision about the request
9
+ * that no retry turns into a yes — see {@link ConflictError} and
10
+ * {@link MoveRequiredError}.
11
+ *
12
+ * Mirrors the mapping in mandala-computer-python's `_exceptions.py`, and
13
+ * deliberately so: two clients disagreeing about what a 402 is means the same
14
+ * failure reads differently depending on which one you reached for.
15
+ */
16
+ export class MandalaError extends Error {
17
+ name = 'MandalaError';
18
+ }
19
+ export class APIError extends MandalaError {
20
+ status;
21
+ body;
22
+ name = 'APIError';
23
+ /**
24
+ * The platform's own word for what KIND of refusal this is, where it sent
25
+ * one: `contention`, `starting`, `unavailable` or `unsupported` (OPL-3898).
26
+ * `undefined` for most errors, and always will be — the platform is explicit
27
+ * that an absent value means unclassified rather than "none of the four".
28
+ *
29
+ * Read on the base class rather than on the one 409 it was filed for, because
30
+ * the platform keys it on the ERROR and not on the route: the same sentinel is
31
+ * reached from several endpoints, and `unavailable` arrives as a 400 as well
32
+ * as a 409 — whoever loses the race to the running check hears the same fact
33
+ * the caller who arrived a moment earlier heard.
34
+ */
35
+ reason;
36
+ constructor(message, status, body) {
37
+ super(message);
38
+ this.status = status;
39
+ this.body = body;
40
+ this.reason = refusalReason(body);
41
+ }
42
+ }
43
+ /**
44
+ * The two answers `reason` can carry, as sets rather than as types.
45
+ *
46
+ * Kept as data deliberately. The platform states that a fifth word may be added
47
+ * and that a client must read one it does not recognise as "no answer given" —
48
+ * an allow-list of classes would make the next word a breaking change, and the
49
+ * same word arrives on more than one status, so a subclass of any one of them
50
+ * could not carry it. Both memberships are tested rather than one being inferred
51
+ * from the other, which is what makes an unknown word fall through to the type
52
+ * answer instead of reading as permanent. Mirrors `_REASON_CLEARS` and
53
+ * `_REASON_PERMANENT` in mandala-computer-python's `_exceptions.py`.
54
+ */
55
+ const REASON_CLEARS = new Set(['contention', 'starting']);
56
+ const REASON_PERMANENT = new Set(['unavailable', 'unsupported']);
57
+ /**
58
+ * What to tell a model about a refusal the platform classified, or `undefined`.
59
+ *
60
+ * The word itself is for a program, and the client here is a language model that
61
+ * cannot switch on a JSON key it never sees: {@link failed} renders an error as
62
+ * one sentence. So the classification travels as the clause it means, and the
63
+ * loop OPL-3898 was filed about — a blanket retry against a computer that is
64
+ * simply stopped — is the one these sentences exist to stop.
65
+ *
66
+ * Deliberately silent about anything else. A word this version does not know is
67
+ * not described at all, because inventing advice for it is the mistake the
68
+ * platform's "absent means unclassified" contract exists to prevent.
69
+ */
70
+ export function reasonAdvice(reason) {
71
+ switch (reason) {
72
+ case 'contention':
73
+ return 'something was in flight; the same call works once it finishes, so this one is worth sending again';
74
+ case 'starting':
75
+ return 'the guest agent is still inside its boot window, so this is worth sending again in a moment';
76
+ case 'unavailable':
77
+ return 'the computer is not running, and this does NOT clear by waiting — start_computer is the fix, and retrying without it spends a turn every time';
78
+ case 'unsupported':
79
+ return 'this computer cannot do it at all, so do not retry it — the answer is the same forever';
80
+ default:
81
+ return undefined;
82
+ }
83
+ }
84
+ /**
85
+ * The platform's one-word classification off a refusal body, or `undefined`.
86
+ *
87
+ * Shape-checked in the manner of {@link moveOffer} and for its reason: this
88
+ * decides a retry policy, so a body with a `reason` that is not a string has to
89
+ * read as "no answer given" and fall back to what this server did before the key
90
+ * existed. Any string is kept, including one this version has never heard of —
91
+ * the callers compare against the sets above, because the sets are the contract
92
+ * and the raw word belongs to whoever is embedding this.
93
+ */
94
+ function refusalReason(body) {
95
+ if (!body || typeof body !== 'object')
96
+ return undefined;
97
+ const reason = body.reason;
98
+ return typeof reason === 'string' ? reason : undefined;
99
+ }
100
+ /** 401 — the key is missing, malformed, or revoked. */
101
+ export class AuthenticationError extends APIError {
102
+ name = 'AuthenticationError';
103
+ }
104
+ /** 402 — the account's plan will not allow this. Not a retry. */
105
+ export class PlanLimitError extends APIError {
106
+ name = 'PlanLimitError';
107
+ }
108
+ /** 403 — the key's role on the account is too low for this route. */
109
+ export class PermissionDeniedError extends APIError {
110
+ name = 'PermissionDeniedError';
111
+ }
112
+ /** 404 — no such computer, snapshot, or route. */
113
+ export class NotFoundError extends APIError {
114
+ name = 'NotFoundError';
115
+ }
116
+ /**
117
+ * 409 — the thing exists but is in the wrong state for this right now.
118
+ *
119
+ * The one status on this list that is usually temporary: a guest still booting,
120
+ * a guest agent busy with another call, a desktop session that has not come up
121
+ * yet. Worth retrying; the others on this list are not.
122
+ *
123
+ * USUALLY, and the exception is why {@link MoveRequiredError} exists. Whether a
124
+ * 409 clears is a property of the BODY and not of the status: some describe a
125
+ * passing state, and some describe a decision about the request — the size does
126
+ * not fit, the computer is the wrong one for this, the saved session cannot
127
+ * travel — which no amount of retrying turns into a yes. This class said
128
+ * "worth retrying" flatly, {@link isTransient} agreed with it, and that
129
+ * predicate is exported, so a host application wrapping a resize in
130
+ * `if (isTransient(err)) retry()` looped on a refusal that was never going to
131
+ * move (OPL-3775).
132
+ *
133
+ * Only the refusal that could be acted on has been given a class of its own so
134
+ * far, because a type is worth adding where a caller can DO something different
135
+ * with it. The rest stay here and are told apart by {@link APIError.reason},
136
+ * which is the word the platform added for that purpose (OPL-3898) and the one
137
+ * its own reference now says to switch on — never the sentence, which is prose
138
+ * written for a person and rewritten whenever a better one is. Where no word was
139
+ * sent this class means what it always did, and that fallback is the contract
140
+ * rather than a gap: not every refusal here has an answer yet.
141
+ */
142
+ export class ConflictError extends APIError {
143
+ name = 'ConflictError';
144
+ }
145
+ /**
146
+ * The 409 that is an OFFER rather than a refusal: a resize needs the computer
147
+ * moved to another host first.
148
+ *
149
+ * `PATCH /computers/{id}` growing `ram_mb` past what the computer's current host
150
+ * can run answers 409 with a `move` object on the body rather than only a
151
+ * sentence — `{"required":true,"possible":true}` means somewhere else in the
152
+ * region could run that size, and `POST /computers/{id}/move` is how a caller
153
+ * agrees to go there. `possible:false` means nowhere in the region can, and the
154
+ * size is the thing to change.
155
+ *
156
+ * Its own class for the reason {@link RangeNotSatisfiableError} has one: it is a
157
+ * refusal the caller can correct without knowing anything it did not just learn,
158
+ * and the correction is a different call rather than a smaller number. Reaching
159
+ * it through a bare `ConflictError` left the flag on `body` where nothing looked
160
+ * for it, and left the retry predicate saying yes.
161
+ *
162
+ * {@link movePossible} is the branch, and it is deliberately read off the body
163
+ * here rather than left to every caller: `move.required` is true in both cases
164
+ * and it is the second field that decides what to do.
165
+ */
166
+ export class MoveRequiredError extends ConflictError {
167
+ movePossible;
168
+ name = 'MoveRequiredError';
169
+ constructor(message, status, body,
170
+ /** Whether a host in this region could run the size that was asked for. */
171
+ movePossible) {
172
+ super(message, status, body);
173
+ this.movePossible = movePossible;
174
+ }
175
+ }
176
+ /**
177
+ * The `move` object the platform puts on the refusal above, if this body has one.
178
+ *
179
+ * Shape-checked rather than trusted: this decides a retry policy and a tool's
180
+ * next step, so a body with a `move` key that is a string, or an object with no
181
+ * `possible`, must read as "not that refusal" rather than as a move that is
182
+ * impossible. Absent and malformed are the same answer here, and it is the
183
+ * conservative one — an ordinary ConflictError, which is what this was before.
184
+ */
185
+ function moveOffer(body) {
186
+ if (!body || typeof body !== 'object')
187
+ return undefined;
188
+ const move = body.move;
189
+ if (!move || typeof move !== 'object')
190
+ return undefined;
191
+ const { required, possible } = move;
192
+ if (required !== true || typeof possible !== 'boolean')
193
+ return undefined;
194
+ return { possible };
195
+ }
196
+ /**
197
+ * 416 — the `Range` named no byte the file has.
198
+ *
199
+ * Its own class because it is the one refusal on the download route that a
200
+ * caller can correct without knowing anything it did not just learn: the
201
+ * response carries `Content-Range: bytes *\/<size>`, so the file's real length
202
+ * arrives with the complaint about the offset. {@link size} is that number,
203
+ * kept off the message so a tool can put the offset it sent beside it.
204
+ *
205
+ * A model paging a file it has not measured is the caller that meets this, and
206
+ * an offset past the end is the mistake it will actually make.
207
+ */
208
+ export class RangeNotSatisfiableError extends APIError {
209
+ size;
210
+ name = 'RangeNotSatisfiableError';
211
+ constructor(message, status, body,
212
+ /** The file's real length, off `Content-Range`, when the response sent one. */
213
+ size) {
214
+ super(message, status, body);
215
+ this.size = size;
216
+ }
217
+ }
218
+ /**
219
+ * 429 — the request is valid; the caller has spent a temporary rate budget.
220
+ *
221
+ * A class rather than a number in a list, and that is the OPL-3724 change in
222
+ * one line: {@link isTransient} used to reach 429 by matching `err.status`,
223
+ * which meant this client answered "is it worth retrying" by a mechanism the
224
+ * other two did not share. It is a moment rather than a property of the
225
+ * request, exactly like a 409, and it belongs in the same shape.
226
+ *
227
+ * {@link retryAfterMs} is set when the response carried a usable `Retry-After`.
228
+ * The poll loops honour it, because retrying at their own faster cadence is
229
+ * how a rate limit becomes a longer one.
230
+ */
231
+ export class RateLimitError extends APIError {
232
+ retryAfterMs;
233
+ name = 'RateLimitError';
234
+ constructor(message, status, body,
235
+ /** From `Retry-After`, in milliseconds from now, when the response sent one. */
236
+ retryAfterMs) {
237
+ super(message, status, body);
238
+ this.retryAfterMs = retryAfterMs;
239
+ }
240
+ }
241
+ /** 503 — a hypervisor could not be reached, so an inventory would be short. */
242
+ export class UnavailableError extends APIError {
243
+ name = 'UnavailableError';
244
+ }
245
+ /**
246
+ * The request was given up on before the platform answered.
247
+ *
248
+ * Not an API failure and not a connectivity failure, which is the whole reason
249
+ * it has a type: an aborted fetch surfaces from the client as a bare
250
+ * `TypeError: This operation was aborted`, and wrapping that as "could not
251
+ * reach <host>" told every reader the platform was down when in fact the caller
252
+ * had hung up. A cancellation is also the one failure here where the request
253
+ * may well have been received and acted on, so the message says so rather than
254
+ * claiming nothing happened.
255
+ *
256
+ * Deliberately not transient: retrying something nobody is waiting for is the
257
+ * behaviour `with(signal)` exists to stop.
258
+ */
259
+ export class CancelledError extends MandalaError {
260
+ name = 'CancelledError';
261
+ }
262
+ /**
263
+ * The request never left. Nothing was dispatched, so anything may be replayed.
264
+ *
265
+ * NARROWER than it used to be, and the narrowing is the point. This class once
266
+ * wrapped every rejection the transport produced, which meant it also carried
267
+ * the failures that happen AFTER the request reached the platform — a socket
268
+ * reset while the response body was being read, a protocol error on the way
269
+ * back. Those wear the opposite outcome: the platform may well have acted, and
270
+ * the answer is what was lost. They now get {@link ConnectivityInterruptedError},
271
+ * which is a subclass, so `catch (e) { if (e instanceof ConnectivityError) }`
272
+ * still sees both.
273
+ *
274
+ * What is left here is what the name always claimed: DNS that did not resolve,
275
+ * a socket that was refused, a connect that timed out, a TLS handshake that
276
+ * failed. Not one byte of the request was written, so {@link isTransient} can
277
+ * say yes to it even for a caller replaying a create.
278
+ *
279
+ * `Api` raises this one only for a cause it can positively identify as
280
+ * connect-phase; see `neverDispatched` in `src/api.ts`. Everything it cannot
281
+ * identify is the subclass, because the cost of the two wrong answers is not
282
+ * symmetric — see there.
283
+ */
284
+ export class ConnectivityError extends MandalaError {
285
+ name = 'ConnectivityError';
286
+ }
287
+ /**
288
+ * The request was dispatched and the answer was lost. Outcome unknown.
289
+ *
290
+ * A socket that resets while the response body is being read, an HTTP parser
291
+ * error on the way back, an undici body or headers timeout, a connection
292
+ * failure this client cannot place in either phase. The shared property is the
293
+ * one that matters: the platform may have received the request and acted on it,
294
+ * and nothing in the error says whether it did.
295
+ *
296
+ * So this is FATAL to {@link isTransient} and transparent to
297
+ * {@link isTransientForPoll}, and the split is the same one OPL-3724 made for
298
+ * 502 and 504. Its words apply here unchanged — "a status that is not safe for
299
+ * the riskiest caller of an exported predicate does not belong in it" — and
300
+ * this case had escaped them only because it wears a class whose name says the
301
+ * request never left. `computers.create()` reaches the platform, the platform
302
+ * builds the computer, the socket dies mid-response: an embedder asking
303
+ * {@link isTransient} used to be told yes, replayed the create, and paid for
304
+ * two computers.
305
+ *
306
+ * A SUBCLASS rather than a sibling, which is what keeps this from breaking
307
+ * anyone. `instanceof ConnectivityError` still matches, so existing catch
308
+ * blocks and {@link isTransientForPoll}'s floor need no change; only the one
309
+ * predicate that promises blind replay had to learn the difference. It is the
310
+ * same shape {@link MoveRequiredError} has under {@link ConflictError}, for the
311
+ * same reason: a case that is genuinely a kind of its parent and genuinely
312
+ * answers one question the other way.
313
+ *
314
+ * The poll predicate still rides it out, and that is not an oversight. The wait
315
+ * tools replay reads — a `GET /computers/:id`, an `exec 'exit 0'` probe — and a
316
+ * read whose outcome was lost can simply be read again. Only a caller who might
317
+ * be replaying a WRITE needs the distinction, which is exactly the caller
318
+ * {@link isTransient} is exported for.
319
+ */
320
+ export class ConnectivityInterruptedError extends ConnectivityError {
321
+ name = 'ConnectivityInterruptedError';
322
+ }
323
+ /**
324
+ * 504, 524 — a proxy in front of the platform gave up before the platform answered.
325
+ *
326
+ * Not a refusal. The request arrived, is very likely still running, and nothing
327
+ * was cancelled; what ended was one hop's willingness to hold a connection open
328
+ * with no response crossing it.
329
+ *
330
+ * One class, two retry answers, and {@link isTransientForPoll} keeps them apart
331
+ * by status rather than by type on purpose — the ONE place a status number
332
+ * still decides anything, because a type cannot separate two statuses that
333
+ * share it. A 504 is worth polling again and a 524 is not, because of where
334
+ * each is reachable from: the wait tools are the only thing here that retries,
335
+ * they poll with short requests, and a 504 on one of those is infrastructure
336
+ * noise that clears. A 524 is only ever reached by holding a request open past
337
+ * the ceiling below — so retrying it unchanged reproduces it exactly, at the
338
+ * same place, because the hop that gave up never saw how long the caller asked
339
+ * to wait.
340
+ *
341
+ * Neither is in {@link isTransient}. That predicate is what an embedder may
342
+ * wrap a create in, and a gateway timeout is the case where the platform has
343
+ * most likely acted already (OPL-3724).
344
+ *
345
+ * Against `app.mandala.computer` that hop is Cloudflare and the ceiling is about
346
+ * two minutes. Measured 2026-08-20: `sleep 130` died at 125.2s with
347
+ * `timeout_s: 300` and at 125.3s with `timeout_s: 3600`, while `sleep 110`
348
+ * returned normally at 110.6s. A foreground `exec` slower than that always ends
349
+ * here; `background: true` is the shape that does not, because it answers as
350
+ * soon as the command has started.
351
+ *
352
+ * The abandoned command keeps running, which is why the next call on the same
353
+ * computer often raises {@link ConflictError} — the guest agent is still busy
354
+ * with it. That is this failure continuing, not a second one.
355
+ */
356
+ export class GatewayTimeoutError extends APIError {
357
+ name = 'GatewayTimeoutError';
358
+ }
359
+ /**
360
+ * 521-523 — a proxy in front of the platform could not reach it.
361
+ *
362
+ * The range this once claimed — 520-523, 525, 526 — is the range before the two
363
+ * statuses that needed their own answers were split out of it: 520 to
364
+ * {@link OriginResponseError}, because the platform WAS reached, and 525-526 to
365
+ * {@link OriginTLSError}, because waiting does not fix a certificate.
366
+ *
367
+ * The rest of what an edge generates on its own, and the same bug as
368
+ * {@link GatewayTimeoutError} a few statuses along: with no class and no written
369
+ * message these fell through to the bare `HTTP 522`, which names no cause, no
370
+ * culprit and no way out — the exact reading that cost the debugging above.
371
+ *
372
+ * A different event from a gateway timeout, which is why it is a different type
373
+ * rather than more entries on that one. A 524 means the request arrived and is
374
+ * still being worked on; these mean it never arrived at all, so nothing was
375
+ * started and there is no command outliving anything. A caller branching on the
376
+ * class to decide whether its work survived gets opposite answers, correctly.
377
+ *
378
+ * Deliberately absent from mandala-computer-python's `_exceptions.py`, which
379
+ * this file otherwise mirrors. That mapping is of the platform's own statuses;
380
+ * these belong to whatever is deployed in front of it. The divergence is worth
381
+ * it here because this client's messages are read by a model, which cannot go
382
+ * and look up what a 523 is.
383
+ */
384
+ export class OriginUnreachableError extends APIError {
385
+ name = 'OriginUnreachableError';
386
+ }
387
+ /**
388
+ * 502, 520 — a proxy had no usable answer from the platform.
389
+ *
390
+ * Sits between the other two and must not be filed with either, because the
391
+ * question a caller is really asking is whether their work happened, and these
392
+ * are the statuses whose honest answer is "unknown".
393
+ *
394
+ * One class, two messages, because the two do not know the same amount. A 520 is
395
+ * Cloudflare naming its origin's reply unreadable, so arrival is established. A
396
+ * 502 is any proxy saying it has nothing it can use, which covers both an
397
+ * invalid reply and no reply at all — indistinguishable from here, so it claims
398
+ * neither. See BAD_GATEWAY_MESSAGE.
399
+ *
400
+ * A 524 means the request arrived and is still being worked on. 521-523 mean it
401
+ * never arrived, so nothing was started. A 520 means it **did** arrive — the
402
+ * platform received it and then returned an empty, unknown or oversized
403
+ * response, so it may have been carried out in full, in part, or not at all, and
404
+ * the answer was lost rather than never produced.
405
+ *
406
+ * Which makes a blind retry the thing to be careful about, and a model the
407
+ * likeliest caller to attempt one. Re-sending a read costs nothing; re-sending a
408
+ * create can leave two computers where one was meant, both billable, on the
409
+ * strength of a failure that said the first never happened.
410
+ *
411
+ * It was filed with {@link OriginUnreachableError} at first, on the reading that
412
+ * the whole 52x range is the edge failing to reach the platform. It is not, and
413
+ * the message that came with it — "the request never arrived, so nothing was
414
+ * started" — was exactly the confident falsehood this work exists to remove,
415
+ * pointed the other way. Caught by a review of the Python SDK, which had
416
+ * inherited the same grouping from this file.
417
+ */
418
+ export class OriginResponseError extends APIError {
419
+ name = 'OriginResponseError';
420
+ }
421
+ /**
422
+ * 525, 526 — a proxy and the platform could not agree on TLS.
423
+ *
424
+ * Split from {@link OriginUnreachableError}, which it used to share, because the
425
+ * two need opposite answers to "should I try again". An unreachable origin is a
426
+ * passing outage; an expired or mismatched certificate fails identically on
427
+ * every retry, and is a deployment somebody has to go and fix.
428
+ *
429
+ * The line was already drawn by status number, so the retry behaviour here was
430
+ * unchanged when this class appeared — but a caller reading the TYPE was told
431
+ * the two were the same thing, while the list beside it said they were not.
432
+ *
433
+ * The type is now what draws it: {@link isTransientForPoll} names this class in
434
+ * its fatal set, and 521-523 fall through to the poll (OPL-3724). Which is also
435
+ * why splitting it mattered more than it looked — the mandala-computer-python
436
+ * SDK had the same pairing and named classes in its fatal set, so a wait helper
437
+ * there could not tell 526 from 522 and spent its whole timeout retrying a
438
+ * certificate.
439
+ */
440
+ export class OriginTLSError extends APIError {
441
+ name = 'OriginTLSError';
442
+ }
443
+ const BY_STATUS = {
444
+ 401: AuthenticationError,
445
+ 402: PlanLimitError,
446
+ 403: PermissionDeniedError,
447
+ 404: NotFoundError,
448
+ 409: ConflictError,
449
+ // Only ever reached through errorForStatus, which cannot see the response
450
+ // headers and so cannot fill in `size`. Api.#error builds this one itself for
451
+ // that reason; the entry is here so the mapping stays complete, and so a 416
452
+ // arriving from anywhere else is still the right class with the platform's
453
+ // own message on it.
454
+ 416: RangeNotSatisfiableError,
455
+ // Reached through errorForStatus only when the response headers were not in
456
+ // hand — Api.#error builds this one itself, for RangeNotSatisfiableError's
457
+ // reason: `Retry-After` is on the headers and the number is worth keeping.
458
+ // The entry is here so a 429 arriving from anywhere else is still the right
459
+ // class, which is what {@link isTransient} now asks about.
460
+ 429: RateLimitError,
461
+ // The other status a proxy writes on its own, and it was the one gap left in
462
+ // this range: with no entry it fell through to a bare APIError, so a model
463
+ // read `HTTP 502` or 500 characters of nginx's HTML — the exact failure the
464
+ // statuses below exist to remove. It polls through isTransientForPoll — the
465
+ // outcome of a 502 is unknown, and a read whose outcome is unknown can simply
466
+ // be read again — so the wait tools reach it and replay whichever of those two
467
+ // it was into their give-up text. Filed with 520 because the honest answer is the same one:
468
+ // unknown. See BAD_GATEWAY_MESSAGE for why it is not filed with 521-523.
469
+ 502: OriginResponseError,
470
+ 503: UnavailableError,
471
+ 504: GatewayTimeoutError,
472
+ // NOT OriginUnreachableError, which is the trap in this range: 520 means the
473
+ // platform WAS reached and answered unreadably. See OriginResponseError.
474
+ 520: OriginResponseError,
475
+ 521: OriginUnreachableError,
476
+ 522: OriginUnreachableError,
477
+ 523: OriginUnreachableError,
478
+ 524: GatewayTimeoutError,
479
+ // Their own class, not more entries on the one above: an unreachable origin is
480
+ // a passing outage and these are a deployment somebody has to fix.
481
+ 525: OriginTLSError,
482
+ 526: OriginTLSError,
483
+ };
484
+ /**
485
+ * What a caller is told when a proxy abandoned the request and named nothing.
486
+ *
487
+ * Used only where the response carried no structured message of its own — see
488
+ * {@link platformNamed}. A 524 is generated at the edge, so that is the usual
489
+ * case: it carries a proxy's HTML error page or — when the request asked for
490
+ * JSON, as every request from this server does — nothing at all. The empty
491
+ * case is the dangerous one:
492
+ * it left the model reading the bare string `HTTP 524`, which names no cause, no
493
+ * culprit and no way out, and whose obvious next move is a retry that fails
494
+ * identically.
495
+ *
496
+ * Two statuses share this class and must NOT share all of this wording, which is
497
+ * why the text is built per status rather than written once. The ceiling — about
498
+ * two minutes, a larger `timeout_s` buying no time, `background: true` as the
499
+ * shape that survives it — is a fact about a 524 specifically. A 504 comes from
500
+ * any hop that gave up early, at no fixed deadline, and
501
+ * {@link isTransientForPoll} says it is worth retrying unchanged; telling its
502
+ * caller that retrying buys no time contradicts that and is false besides. Hedging on "if this was an exec" does
503
+ * not fix it, because the wrong half is the status, not the route.
504
+ */
505
+ const GATEWAY_TIMEOUT_SHARED = 'a proxy in front of the platform gave up waiting for it to answer. Nothing was ' +
506
+ 'cancelled: the platform never saw this deadline, so anything this request had already ' +
507
+ 'set going carries on without it';
508
+ /**
509
+ * The 524 tail: the ceiling, and what it means for the route that meets it most.
510
+ *
511
+ * Still hedged on the route, because a screenshot or a listing can meet the same
512
+ * ceiling and neither takes a `timeout_s` — the wait tools do not reach a 524 at
513
+ * all, which is the other half of why this is safe to say here and was not safe
514
+ * to say for a 504.
515
+ */
516
+ const GATEWAY_TIMEOUT_CEILING = ' — usually it has the request and is still working. Most often that is a foreground ' +
517
+ 'exec, and if this one was, it ended this way after about two minutes however large a ' +
518
+ 'timeout_s it was given: the ceiling belongs to the proxy, not to the platform, so on ' +
519
+ 'that route a larger timeout_s buys no time, background: true with exec_poll is what ' +
520
+ 'runs something slower, and the next call on that computer may report the guest agent ' +
521
+ 'as busy with the command that outlived the request';
522
+ /**
523
+ * The 504 tail: no ceiling, no route-specific advice, and retrying is the move.
524
+ *
525
+ * A 504 can be raised by any hop, including one that never reached the platform,
526
+ * so it cannot promise the work is running the way a 524 can.
527
+ */
528
+ const GATEWAY_TIMEOUT_TRANSIENT = ', though a 504 can also come from a hop that never reached it, so whether the work is ' +
529
+ 'running is not knowable from here. No fixed deadline was hit and nothing about the ' +
530
+ 'request needs changing: this usually clears, and the same call again is the move — ' +
531
+ 'check before repeating anything that creates something, since the first attempt may ' +
532
+ 'yet have landed';
533
+ /** The message for a gateway timeout, with only the half its status can support. */
534
+ function gatewayTimeoutMessage(status) {
535
+ return (GATEWAY_TIMEOUT_SHARED + (status === 524 ? GATEWAY_TIMEOUT_CEILING : GATEWAY_TIMEOUT_TRANSIENT));
536
+ }
537
+ /** What a caller is told when the platform's own answer arrived unreadable. */
538
+ const ORIGIN_RESPONSE_MESSAGE = 'the platform received the request and the exchange then broke on the way back — an ' +
539
+ 'empty or unreadable response, a connection dropped before the headers, an origin that ' +
540
+ 'stopped part-way. Unlike an unreachable origin, the request did arrive, so it may have ' +
541
+ 'been carried out in full, in part, or not at all. Retrying a read costs nothing; before ' +
542
+ 'retrying anything that creates something — a computer, a snapshot — check whether the ' +
543
+ 'first attempt took effect, or you may end up with two of it';
544
+ /**
545
+ * What a caller is told for a 502, which is the two failures either side of it.
546
+ *
547
+ * Not ORIGIN_RESPONSE_MESSAGE, though it shares that class. A 520 is Cloudflare
548
+ * saying the origin answered unreadably, so "the request did arrive" is known.
549
+ * A 502 is any proxy saying it has no usable answer, and the two reasons —
550
+ * upstream replied with something invalid, upstream could not be reached — are
551
+ * indistinguishable from the outside. Asserting arrival would be the same shape
552
+ * of confident falsehood as the "nothing was started" this branch removed from
553
+ * 520, pointed the other way, so this says the one true thing instead.
554
+ */
555
+ const BAD_GATEWAY_MESSAGE = 'a proxy in front of the platform had no usable answer from it — either the platform ' +
556
+ 'replied with something the proxy could not read, or it could not be reached at all, ' +
557
+ 'and which of those happened is not visible from here. So whether the request arrived ' +
558
+ 'is unknown, and with it whether the work was done: retrying a read costs nothing, but ' +
559
+ 'before retrying anything that creates something — a computer, a snapshot — check ' +
560
+ 'whether the first attempt took effect. Usually this is a passing outage that clears';
561
+ /** What a caller is told when a proxy could not reach the platform at all. */
562
+ const ORIGIN_UNREACHABLE_MESSAGE = 'a proxy in front of the platform could not reach it. Almost always that means the ' +
563
+ 'request was never sent, so nothing was started and there is no work on the other side ' +
564
+ 'of this to account for — unlike a gateway timeout. Almost, rather than never, because ' +
565
+ 'a connection can also time out after it was established, and bytes already on the wire ' +
566
+ 'are not unsent because the answer never came back: retry a read freely, and look before ' +
567
+ 'retrying something that creates. Usually this is the platform restarting or a short ' +
568
+ 'outage, which clears on its own; if it persists the platform is down, and waiting is ' +
569
+ 'the only thing that helps';
570
+ /** The same, for the two of those that waiting will not fix. */
571
+ const ORIGIN_TLS_MESSAGE = 'a proxy in front of the platform could not complete a TLS handshake with it, so the ' +
572
+ 'request was never sent. This is a misconfigured deployment rather than a passing ' +
573
+ 'outage — an expired or mismatched certificate fails the same way on every retry, so ' +
574
+ 'report it rather than waiting it out';
575
+ /**
576
+ * Whether the response named this failure in the shape this surface uses.
577
+ *
578
+ * Only a JSON body with a non-empty `error` string counts. An HTML page and an
579
+ * empty body are an intermediary's, and both are worth discarding for the
580
+ * wording below; a structured message is not. "upstream unavailable before
581
+ * dispatch" is a more specific true thing than anything written here, and
582
+ * replacing it would be this server overwriting a hop that knew more than it
583
+ * does with a guess.
584
+ *
585
+ * Which hop wrote it is not knowable from here, and does not need to be. The
586
+ * test is whether SOMETHING said something specific, not whether it was the
587
+ * platform — a 504 can be raised by any proxy in the chain, including one in
588
+ * front of a MANDALA_BASE_URL this server has never seen.
589
+ */
590
+ function platformNamed(body) {
591
+ return platformSaid(body) !== undefined;
592
+ }
593
+ /**
594
+ * The sentence a response carried in its own words, or `undefined` for one that
595
+ * carried none — the same test {@link platformNamed} asks, with the answer kept.
596
+ *
597
+ * Exported for the one caller that has to print a message the substitutions
598
+ * above would otherwise have written for it. A tool whose route contradicts the
599
+ * generic wording for a status — `window_action` and a 504, where
600
+ * {@link GatewayTimeoutError}'s tail says the same call again is the move and on
601
+ * that route it is not — cannot append its own paragraph under prose that says
602
+ * the opposite. It needs to know whether the sentence in `message` came from a
603
+ * hop that knew this request, in which case it is worth repeating, or from this
604
+ * file, in which case it is worth replacing (OPL-3910).
605
+ *
606
+ * Reads the BODY rather than the message, deliberately. By the time an error
607
+ * exists the two may differ — that is what the substitutions are — so asking the
608
+ * message whether it is the platform's would be asking the answer to vouch for
609
+ * itself.
610
+ */
611
+ export function platformSaid(body) {
612
+ if (!body || typeof body !== 'object')
613
+ return undefined;
614
+ const err = body.error;
615
+ return typeof err === 'string' && err.length > 0 ? err : undefined;
616
+ }
617
+ /** Build the error for a status, with the platform's own message when it sent one. */
618
+ export function errorForStatus(status, message, body) {
619
+ const Cls = BY_STATUS[status] ?? APIError;
620
+ // The 409 that is an offer, told apart by its body. Before the substitutions
621
+ // below because it never wants one: the platform's sentence here is the whole
622
+ // explanation of what will not fit and what moving would cost, written to be
623
+ // read by whoever has to agree to it.
624
+ if (Cls === ConflictError) {
625
+ const offer = moveOffer(body);
626
+ if (offer)
627
+ return new MoveRequiredError(message, status, body, offer.possible);
628
+ }
629
+ // Substituted for an empty body, which says nothing, and for a proxy's HTML
630
+ // page, which says 500 characters of nothing. NOT for a structured message:
631
+ // that is the one case where the response knows more than this file does.
632
+ //
633
+ // The same guard on every branch below, because platformNamed already settles
634
+ // the question they were once split over. Two of these used to substitute
635
+ // unconditionally, on the reading that a 521-526 cannot carry the platform's
636
+ // account of itself — true, and beside the point. platformNamed does not ask
637
+ // whether the PLATFORM spoke; it asks whether anything did, precisely because
638
+ // a hop in front of a self-hosted MANDALA_BASE_URL is a hop this server has
639
+ // never seen and cannot outrank. An operator's own gateway answering 522 with
640
+ // `{"error":"backend pool empty; scale the worker group"}` knows more about
641
+ // that deployment than the generic outage prose here does, and discarding it
642
+ // was the very thing the 504 and 520 guards exist to prevent.
643
+ if (Cls === GatewayTimeoutError && !platformNamed(body)) {
644
+ return new GatewayTimeoutError(gatewayTimeoutMessage(status), status, body);
645
+ }
646
+ if (Cls === OriginResponseError && !platformNamed(body)) {
647
+ // 502 and 520 share a class and not a message: one knows the request
648
+ // arrived, the other cannot tell. See BAD_GATEWAY_MESSAGE.
649
+ const said = status === 502 ? BAD_GATEWAY_MESSAGE : ORIGIN_RESPONSE_MESSAGE;
650
+ return new OriginResponseError(said, status, body);
651
+ }
652
+ if (Cls === OriginTLSError && !platformNamed(body)) {
653
+ return new OriginTLSError(ORIGIN_TLS_MESSAGE, status, body);
654
+ }
655
+ if (Cls === OriginUnreachableError && !platformNamed(body)) {
656
+ return new OriginUnreachableError(ORIGIN_UNREACHABLE_MESSAGE, status, body);
657
+ }
658
+ return new Cls(message, status, body);
659
+ }
660
+ /**
661
+ * Whether an error is worth trying again without changing the request.
662
+ *
663
+ * The PUBLIC answer, exported from the package, and therefore a contract with
664
+ * embedders rather than a private note to this file. Its caller is a host
665
+ * application wrapping an arbitrary call in `if (isTransient(err)) retry()` —
666
+ * including one that creates something — so it names only failures that both
667
+ * clear on their own AND are safe to replay blind.
668
+ *
669
+ * Answered by TYPE, with no status numbers at all, which is the OPL-3724
670
+ * decision written down. Three clients had drifted into three mechanisms for
671
+ * one question: this file matched classes plus a list of numbers, the
672
+ * TypeScript SDK matched classes alone, and the Python SDK named the fatal
673
+ * exceptions and retried the rest. The status list is what let this one drift,
674
+ * because a number can be added to it without anyone having to say which of the
675
+ * three answers changed. It now reads identically in all three:
676
+ *
677
+ * - {@link ConflictError} — something is in flight that this cannot run
678
+ * alongside, minus the one that is a decision
679
+ * - {@link RateLimitError} — a cadence, and the response usually says how long
680
+ * - {@link UnavailableError} — a hypervisor briefly out of reach
681
+ * - {@link ConnectivityError} — the request never left
682
+ *
683
+ * That last line is now literally true, and it was not always. The class used
684
+ * to cover every transport rejection, a lost response body included, so this
685
+ * predicate told a caller replaying a create that the platform had not been
686
+ * reached when in fact it had been and the answer was what went missing.
687
+ * {@link ConnectivityInterruptedError} carries that case now and is excluded
688
+ * below — the same decision as the paragraph after this one, applied to the
689
+ * one class it had missed (OPL-3855).
690
+ *
691
+ * 502 and 504 USED to be here and are deliberately gone. The paragraph below
692
+ * had already conceded the point that removes them: both can arrive after the
693
+ * platform has acted, so replaying a `create_computer` through one can leave a
694
+ * second billable computer behind a failure that read as nothing having
695
+ * happened. A status that is not safe for the riskiest caller of an exported
696
+ * predicate does not belong in it. Nothing waits less as a result — the wait
697
+ * tools ask {@link isTransientForPoll}, which still rides both out.
698
+ *
699
+ * "Worth trying again" is not "the call definitely did not happen", and no
700
+ * predicate taking only an error can tell you the second. Even here, a 409 can
701
+ * be answered after a change landed. So retry reads freely, and check before
702
+ * repeating anything that creates something.
703
+ *
704
+ * Nor is a status enough on its own to answer it. 409 is the case: most of them
705
+ * are a passing state, and the move offer is a decision that no retry changes,
706
+ * which is why the check below leads with the type rather than the number. If a
707
+ * second such refusal earns a class, it belongs on that line too.
708
+ *
709
+ * One 409 could not be given a class and could not be seen from here at all: a
710
+ * clipboard read or write against a computer that is STOPPED does not clear on
711
+ * its own — a start is the fix, not another attempt — and nothing in the body
712
+ * told it apart from a conflict that is merely passing. The advice was to read
713
+ * the message, which is prose the platform is free to reword and exactly the
714
+ * matching OPL-3724 got three clients out of. The platform now says which kind
715
+ * it is, so {@link APIError.reason} is consulted BEFORE the types below, and an
716
+ * absent word — or one this version does not know — leaves the type answer
717
+ * standing unchanged (OPL-3898).
718
+ *
719
+ * One refusal is known to sit on the wrong side of that fallback, and it stays
720
+ * there deliberately (OPL-3909). A computer runs at most sixteen background
721
+ * commands, and the request for a seventeenth is refused 409 with no `reason` —
722
+ * correctly, since the slots may be held by servers and the platform will not
723
+ * advise a retry it cannot promise. The type answer therefore stands, and it
724
+ * says yes to something that may never clear. The alternative is to read the
725
+ * platform's sentence, which is the exported-contract version of the mistake
726
+ * OPL-3724 removed from three clients: a predicate that changes its answer when
727
+ * somebody rewords a message. So the next step is given where the sentence can
728
+ * be read safely — in `exec`, which knows it asked for a slot and which prints
729
+ * the platform's own words either way — and this predicate is left honest about
730
+ * what it can and cannot tell apart.
731
+ */
732
+ export function isTransient(err) {
733
+ // A move offer is a 409 and is NOT transient — it is a decision about the
734
+ // size that was asked for, and the same request answers the same way forever.
735
+ // First, because it is a subclass of the very branch below that would say yes
736
+ // (OPL-3775). An embedder wrapping a resize in `if (isTransient(err)) retry()`
737
+ // is the caller this line is for.
738
+ if (err instanceof MoveRequiredError)
739
+ return false;
740
+ // A lost RESPONSE is not a request that never left, and only one of the two
741
+ // is safe to replay blind. Same shape as the line above and the same reason:
742
+ // a subclass of a branch below that would otherwise say yes (OPL-3855).
743
+ if (err instanceof ConnectivityInterruptedError)
744
+ return false;
745
+ // The platform's own word, ahead of the types below, because it is the more
746
+ // specific answer and it is the one that tells the 409 that never clears from
747
+ // the two that do (OPL-3898). Only an APIError carries a shape-checked one:
748
+ // an arbitrary exception may happen to have a `reason` property, and that is
749
+ // neither this protocol nor retry advice.
750
+ if (err instanceof APIError && err.reason !== undefined) {
751
+ if (REASON_CLEARS.has(err.reason))
752
+ return true;
753
+ if (REASON_PERMANENT.has(err.reason))
754
+ return false;
755
+ }
756
+ return (err instanceof ConflictError ||
757
+ err instanceof RateLimitError ||
758
+ err instanceof UnavailableError ||
759
+ err instanceof ConnectivityError);
760
+ }
761
+ /**
762
+ * The same words, a different question: worth POLLING again.
763
+ *
764
+ * Asked only by the wait tools, and deliberately not exported. They replay a
765
+ * `GET /computers/:id`, a `GET /moves`, a build read or an `exec 'exit 0'`
766
+ * probe — every one of them idempotent, every one of them under a deadline the
767
+ * caller set. That pair of properties is what makes this predicate generous,
768
+ * and it is a property of what those calls DO rather than of the error, which
769
+ * is exactly why it cannot be published: the same `true` handed to an embedder
770
+ * retrying a create means something else entirely.
771
+ *
772
+ * DENY-LIST, and the inversion is the second half of the OPL-3724 decision.
773
+ * Where {@link isTransient} names what may be retried, this names what may not
774
+ * and polls through everything else. The polarity follows from who pays for a
775
+ * wrong answer. Retrying something unretryable costs one poll interval and,
776
+ * at worst, the deadline the caller chose. NOT retrying something that would
777
+ * have cleared costs a wait that reports a machine as unreachable while it was
778
+ * coming up — and every status the edge invents next year lands in that second
779
+ * category under an allow-list, silently, until somebody notices and adds a
780
+ * number. This way round, an unmapped 5xx is ridden out, which is what a poll
781
+ * loop was for.
782
+ *
783
+ * The line is REQUEST versus MOMENT. A failure describing the request answers
784
+ * the same way forever and is fatal here; a failure describing the moment is
785
+ * what a poll exists to outlast.
786
+ *
787
+ * Fatal, therefore:
788
+ *
789
+ * - anything that is not a failed REQUEST. That is the floor, and a deny-list
790
+ * needs one: only {@link APIError} and {@link ConnectivityError} describe an
791
+ * exchange with the platform that did not work, and only those can be worth
792
+ * making again. A `TypeError` from a bug in this file is not the platform
793
+ * being slow, and riding one out spends the caller's deadline before
794
+ * reporting the wrong cause.
795
+ *
796
+ * A bare {@link MandalaError} is caught by the same floor, and that is the
797
+ * half worth spelling out. `Api` raises them for a response that arrived and
798
+ * made no sense — "expected JSON from GET /computers/:id, got: <html>" — and
799
+ * a poll loop raises them as verdicts about a poll that SUCCEEDED. Neither is
800
+ * a moment to outlast: one is a defect and the other is an answer. Polling
801
+ * through a verdict is an infinite loop with a deadline on it, which is
802
+ * exactly what the TypeScript SDK's suite caught when this predicate was
803
+ * ported there with `MandalaError` as its floor.
804
+ *
805
+ * {@link ConnectivityInterruptedError} passes that floor and is meant to, on
806
+ * the strength of what the callers here DO. It says the outcome of one
807
+ * request is unknown; every request this predicate guards is a read, and a
808
+ * read whose outcome is unknown can be read again. Fatal to
809
+ * {@link isTransient} and transparent here is the whole point of there being
810
+ * two predicates.
811
+ * - {@link CancelledError} — the caller hung up. Excluded by the floor above,
812
+ * since it is neither, and retrying something nobody is waiting for is what
813
+ * `with(signal)` exists to stop.
814
+ * - {@link MoveRequiredError} — a decision about the size that was asked for.
815
+ * - {@link OriginTLSError} (525, 526) — a certificate the edge and the platform
816
+ * cannot agree on fails identically on every retry, so waiting one out spends
817
+ * the whole deadline to report the wrong cause. Its own message says to go and
818
+ * fix the deployment; this is what makes that true.
819
+ * - 524 — reached only by holding a request open past the edge's ceiling, so an
820
+ * identical retry reproduces it at the same place. It shares
821
+ * {@link GatewayTimeoutError} with 504, which is retryable, and that is why
822
+ * this one status is still matched by NUMBER: the type cannot separate them.
823
+ * - anything below 500 that is not named. A 4xx describes the request — a bad
824
+ * body, a revoked key, a plan limit, a deleted id, an offset past the end of a
825
+ * file — and repeating it unchanged cannot change the answer. Three are named
826
+ * because they describe the moment instead: 409 (something in flight), 429 (a
827
+ * cadence), and 408, which RFC 9110 defines as a request the client may repeat
828
+ * unchanged and which the edge in front of this surface does emit.
829
+ *
830
+ * A 3xx goes with the 4xx, which is why the test is `>= 500` rather than "not
831
+ * a 4xx". `Api` treats every non-2xx as an error and does not follow
832
+ * redirects, so a MANDALA_BASE_URL missing its trailing path answers 301 — and
833
+ * under a 4xx-only rule that was polled until the deadline, ending in a
834
+ * give-up that named nothing about the redirect. The mandala-computer-python
835
+ * SDK found that one; this is the same rule, and it is why all three now say
836
+ * `>= 500`.
837
+ *
838
+ * {@link APIError.reason} is deliberately NOT consulted here, and that is the
839
+ * one place this predicate and {@link isTransient} part company (OPL-3898).
840
+ * `unavailable` means the computer is not running, which is a permanent answer
841
+ * to whoever asked — and a poll under a deadline is the one caller for whom it
842
+ * may not be, since a computer coming up passes through it. The same generosity
843
+ * as every unmapped 5xx below, for the same reason: this only ever replays a
844
+ * read, and the loops above return a refusal of their own the moment the status
845
+ * they are watching says stopped or suspended. mandala-computer-python's
846
+ * `_is_transient_for_poll` draws the line in the same place.
847
+ *
848
+ * Everything at 5xx polls through, 502 and 520-523 included: they mean the
849
+ * outcome is unknown, and a read whose outcome is unknown can simply be read
850
+ * again.
851
+ *
852
+ * 5xx has an upper bound as well as a lower one, and it is not decoration. The
853
+ * HTTP parser under `fetch` accepts any three digits, so a broken or hostile
854
+ * origin can answer 700 — which `>= 500` alone called a passing moment and
855
+ * polled until the caller's deadline (Codex adversarial review, OPL-3724).
856
+ */
857
+ export function isTransientForPoll(err) {
858
+ if (!(err instanceof APIError) && !(err instanceof ConnectivityError))
859
+ return false;
860
+ if (err instanceof MoveRequiredError)
861
+ return false;
862
+ if (err instanceof OriginTLSError)
863
+ return false;
864
+ if (err instanceof APIError) {
865
+ if (err.status === 524)
866
+ return false;
867
+ if (err.status === 408 || err.status === 409 || err.status === 429)
868
+ return true;
869
+ return err.status >= 500 && err.status < 600;
870
+ }
871
+ return true;
872
+ }
873
+ //# sourceMappingURL=errors.js.map