run402 1.50.0 → 1.51.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/lib/deploy-v2.mjs +71 -0
- package/lib/deploy.mjs +37 -23
- package/lib/projects.mjs +10 -12
- package/lib/sdk-errors.mjs +40 -0
- package/package.json +1 -1
- package/sdk/dist/errors.d.ts +17 -1
- package/sdk/dist/errors.d.ts.map +1 -1
- package/sdk/dist/errors.js +39 -0
- package/sdk/dist/errors.js.map +1 -1
- package/sdk/dist/kernel.d.ts.map +1 -1
- package/sdk/dist/kernel.js +13 -3
- package/sdk/dist/kernel.js.map +1 -1
- package/sdk/dist/namespaces/apps.d.ts +1 -1
- package/sdk/dist/namespaces/apps.d.ts.map +1 -1
- package/sdk/dist/namespaces/apps.js +14 -12
- package/sdk/dist/namespaces/apps.js.map +1 -1
- package/sdk/dist/namespaces/blobs.d.ts.map +1 -1
- package/sdk/dist/namespaces/blobs.js +20 -6
- package/sdk/dist/namespaces/blobs.js.map +1 -1
- package/sdk/dist/namespaces/blobs.types.d.ts +20 -5
- package/sdk/dist/namespaces/blobs.types.d.ts.map +1 -1
- package/sdk/dist/namespaces/deploy.d.ts +23 -1
- package/sdk/dist/namespaces/deploy.d.ts.map +1 -1
- package/sdk/dist/namespaces/deploy.js +265 -34
- package/sdk/dist/namespaces/deploy.js.map +1 -1
- package/sdk/dist/namespaces/deploy.types.d.ts +28 -0
- package/sdk/dist/namespaces/deploy.types.d.ts.map +1 -1
- package/sdk/dist/node/sites-node.d.ts.map +1 -1
- package/sdk/dist/node/sites-node.js +8 -1
- package/sdk/dist/node/sites-node.js.map +1 -1
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* See `unified-deploy` and `cas-content` capability specs for normative
|
|
19
19
|
* behavior; this file is the implementation.
|
|
20
20
|
*/
|
|
21
|
-
import { ApiError, Run402DeployError, } from "../errors.js";
|
|
21
|
+
import { ApiError, NetworkError, Run402DeployError, } from "../errors.js";
|
|
22
22
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
23
23
|
const PLAN_BODY_LIMIT_BYTES = 5 * 1024 * 1024;
|
|
24
24
|
const COMMIT_POLL_INITIAL_MS = 1_000;
|
|
@@ -115,8 +115,21 @@ export class Deploy {
|
|
|
115
115
|
* via the auto-resume worker.)
|
|
116
116
|
*/
|
|
117
117
|
async resume(operationId, opts = {}) {
|
|
118
|
+
if (!operationId || !operationId.startsWith("op_")) {
|
|
119
|
+
throw new Run402DeployError(`Invalid operation id: "${operationId}"`, {
|
|
120
|
+
code: "OPERATION_NOT_FOUND",
|
|
121
|
+
retryable: false,
|
|
122
|
+
context: "resuming deploy operation",
|
|
123
|
+
});
|
|
124
|
+
}
|
|
118
125
|
const emit = makeEmitter(opts.onEvent);
|
|
119
|
-
|
|
126
|
+
let snapshot;
|
|
127
|
+
try {
|
|
128
|
+
snapshot = await this.client.request(`/deploy/v2/operations/${encodeURIComponent(operationId)}/resume`, { method: "POST", context: "resuming deploy operation" });
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
throw translateDeployError(err, "resume", null, operationId);
|
|
132
|
+
}
|
|
120
133
|
return await pollSnapshotUntilReady(this.client, snapshot, {}, emit, opts.project);
|
|
121
134
|
}
|
|
122
135
|
/**
|
|
@@ -128,6 +141,50 @@ export class Deploy {
|
|
|
128
141
|
const headers = opts.project ? await apikeyHeaders(this.client, opts.project) : {};
|
|
129
142
|
return this.client.request(`/deploy/v2/operations/${operationId}`, { headers, context: "fetching deploy operation" });
|
|
130
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* List recent deploy operations for a project. The endpoint requires
|
|
146
|
+
* `apikey` auth, so `project` is required. `limit` is forwarded to the
|
|
147
|
+
* gateway as a query string when set; the gateway picks a default
|
|
148
|
+
* otherwise.
|
|
149
|
+
*/
|
|
150
|
+
async list(opts) {
|
|
151
|
+
const headers = await apikeyHeaders(this.client, opts.project);
|
|
152
|
+
const qs = new URLSearchParams();
|
|
153
|
+
if (opts.limit !== undefined)
|
|
154
|
+
qs.set("limit", String(opts.limit));
|
|
155
|
+
const path = qs.toString().length > 0
|
|
156
|
+
? `/deploy/v2/operations?${qs.toString()}`
|
|
157
|
+
: `/deploy/v2/operations`;
|
|
158
|
+
return this.client.request(path, {
|
|
159
|
+
headers,
|
|
160
|
+
context: "listing deploy operations",
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Fetch the synthesized phase event stream for an operation. Returns the
|
|
165
|
+
* events the gateway has recorded so far — useful for inspecting a deploy
|
|
166
|
+
* after the fact, or resuming an event subscription from a different
|
|
167
|
+
* process. For live subscription during an in-flight deploy, use
|
|
168
|
+
* {@link Deploy.start} and iterate `op.events()`.
|
|
169
|
+
*
|
|
170
|
+
* The endpoint requires `apikey` auth, so `project` is required.
|
|
171
|
+
*/
|
|
172
|
+
async events(operationId, opts) {
|
|
173
|
+
if (!operationId || !operationId.startsWith("op_")) {
|
|
174
|
+
throw new Run402DeployError(`Invalid operation id: "${operationId}"`, {
|
|
175
|
+
code: "OPERATION_NOT_FOUND",
|
|
176
|
+
retryable: false,
|
|
177
|
+
context: "fetching deploy events",
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
const headers = await apikeyHeaders(this.client, opts.project);
|
|
181
|
+
try {
|
|
182
|
+
return await this.client.request(`/deploy/v2/operations/${encodeURIComponent(operationId)}/events`, { headers, context: "fetching deploy events" });
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
throw translateDeployError(err, "events", null, operationId);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
131
188
|
/**
|
|
132
189
|
* Fetch a release by id. (Endpoint may not be live in early v2 builds —
|
|
133
190
|
* falls through to the gateway's standard 404 handling in that case.)
|
|
@@ -200,6 +257,20 @@ async function commitInternal(client, planId, idempotencyKey) {
|
|
|
200
257
|
}
|
|
201
258
|
}
|
|
202
259
|
async function uploadMissing(client, projectId, presence, byteReaders, emit) {
|
|
260
|
+
// Surface CAS dedup hits so agents can distinguish "N files were already
|
|
261
|
+
// present" from "nothing happened". The gateway reports both present and
|
|
262
|
+
// missing refs in `missing_content`; emit a skipped event for each present
|
|
263
|
+
// one before short-circuiting on a fully-deduped plan. (#124, #134)
|
|
264
|
+
const skipped = presence.filter((p) => p.present);
|
|
265
|
+
for (const p of skipped) {
|
|
266
|
+
const reader = byteReaders.get(p.sha256);
|
|
267
|
+
emit({
|
|
268
|
+
type: "content.upload.skipped",
|
|
269
|
+
label: reader?.label ?? p.sha256,
|
|
270
|
+
sha256: p.sha256,
|
|
271
|
+
reason: "present",
|
|
272
|
+
});
|
|
273
|
+
}
|
|
203
274
|
// Filter to refs the gateway reported as missing for this project.
|
|
204
275
|
const needsUpload = presence.filter((p) => !p.present);
|
|
205
276
|
if (needsUpload.length === 0)
|
|
@@ -235,7 +306,7 @@ async function uploadMissing(client, projectId, presence, byteReaders, emit) {
|
|
|
235
306
|
});
|
|
236
307
|
}
|
|
237
308
|
const bytes = await reader();
|
|
238
|
-
await
|
|
309
|
+
await uploadOneWithRetry(client.fetch, session, bytes);
|
|
239
310
|
// Per-session completion — promotes the staged object to CAS via
|
|
240
311
|
// services/cas-promote.ts. The plan-level `/content/v1/plans/:id/commit`
|
|
241
312
|
// call below is the plan-level finalize; per-session promotion happens
|
|
@@ -270,6 +341,27 @@ async function uploadMissing(client, projectId, presence, byteReaders, emit) {
|
|
|
270
341
|
// above; this call is the plan-level idempotency anchor.
|
|
271
342
|
await client.request(`/content/v1/plans/${encodeURIComponent(planRes.plan_id)}/commit`, { method: "POST", headers, body: {}, context: "committing content upload" });
|
|
272
343
|
}
|
|
344
|
+
// Wrap `uploadOne` with exponential backoff for retryable failures.
|
|
345
|
+
// `putToS3` raises Run402DeployError(retryable: true) for transient network
|
|
346
|
+
// drops and 5xx/403 responses; one network blip should not fail the entire
|
|
347
|
+
// deploy. Cap at 3 attempts (1 initial + 2 retries) with delays 1s, 2s.
|
|
348
|
+
// Non-retryable errors (4xx other than 403, internal SDK invariants) bubble
|
|
349
|
+
// up on the first attempt. See GH-140.
|
|
350
|
+
async function uploadOneWithRetry(fetchFn, session, bytes) {
|
|
351
|
+
const MAX_ATTEMPTS = 3;
|
|
352
|
+
for (let attempt = 1;; attempt++) {
|
|
353
|
+
try {
|
|
354
|
+
await uploadOne(fetchFn, session, bytes);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
catch (err) {
|
|
358
|
+
const retryable = err instanceof Run402DeployError && err.retryable;
|
|
359
|
+
if (!retryable || attempt >= MAX_ATTEMPTS)
|
|
360
|
+
throw err;
|
|
361
|
+
await sleep(1000 * Math.pow(2, attempt - 1)); // 1s, 2s
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
273
365
|
async function uploadOne(fetchFn, entry, bytes) {
|
|
274
366
|
if (entry.mode === "single") {
|
|
275
367
|
if (entry.parts.length !== 1) {
|
|
@@ -397,12 +489,35 @@ async function pollSnapshotUntilReady(client, initial, diff, emit, projectId) {
|
|
|
397
489
|
};
|
|
398
490
|
return map[status] ?? null;
|
|
399
491
|
};
|
|
492
|
+
// Close out the previously-emitted phase as `done` (or `failed`) before
|
|
493
|
+
// emitting the next phase's `started` event. Skips when there's no prior
|
|
494
|
+
// phase, when the prior emission wasn't a `started` event (e.g. the
|
|
495
|
+
// `activation_pending` path which already emits `failed`), or when the
|
|
496
|
+
// prior phase string equals the next phase. (#135)
|
|
497
|
+
const closePreviousPhase = (nextPhase, closeStatus = "done") => {
|
|
498
|
+
if (lastPhaseEmitted === null)
|
|
499
|
+
return;
|
|
500
|
+
const prev = phaseFor(lastPhaseEmitted);
|
|
501
|
+
if (!prev || prev.type !== "commit.phase")
|
|
502
|
+
return;
|
|
503
|
+
if (prev.status !== "started")
|
|
504
|
+
return;
|
|
505
|
+
if (nextPhase !== undefined && prev.phase === nextPhase)
|
|
506
|
+
return;
|
|
507
|
+
emit({ type: "commit.phase", phase: prev.phase, status: closeStatus });
|
|
508
|
+
};
|
|
400
509
|
while (true) {
|
|
401
510
|
if (lastPhaseEmitted !== snapshot.status) {
|
|
402
511
|
const ev = phaseFor(snapshot.status);
|
|
403
|
-
if (ev)
|
|
512
|
+
if (ev) {
|
|
513
|
+
if (ev.type === "commit.phase")
|
|
514
|
+
closePreviousPhase(ev.phase);
|
|
404
515
|
emit(ev);
|
|
405
|
-
|
|
516
|
+
lastPhaseEmitted = snapshot.status;
|
|
517
|
+
}
|
|
518
|
+
// If `ev` is null (status not in the phase map, e.g. "ready"), leave
|
|
519
|
+
// lastPhaseEmitted pointing at the prior in-flight phase so the
|
|
520
|
+
// terminal-success closePreviousPhase() below can emit its `done`.
|
|
406
521
|
}
|
|
407
522
|
if (snapshot.status === SUCCESS_STATUS) {
|
|
408
523
|
if (!snapshot.release_id || !snapshot.urls) {
|
|
@@ -414,6 +529,7 @@ async function pollSnapshotUntilReady(client, initial, diff, emit, projectId) {
|
|
|
414
529
|
context: "polling deploy",
|
|
415
530
|
});
|
|
416
531
|
}
|
|
532
|
+
closePreviousPhase();
|
|
417
533
|
emit({ type: "ready", releaseId: snapshot.release_id, urls: snapshot.urls });
|
|
418
534
|
return {
|
|
419
535
|
release_id: snapshot.release_id,
|
|
@@ -423,6 +539,7 @@ async function pollSnapshotUntilReady(client, initial, diff, emit, projectId) {
|
|
|
423
539
|
};
|
|
424
540
|
}
|
|
425
541
|
if (TERMINAL_STATUSES.includes(snapshot.status)) {
|
|
542
|
+
closePreviousPhase(undefined, "failed");
|
|
426
543
|
throw translateGatewayError(snapshot.error, snapshot.status, snapshot.plan_id, snapshot.operation_id);
|
|
427
544
|
}
|
|
428
545
|
if (Date.now() - start > COMMIT_POLL_TIMEOUT_MS) {
|
|
@@ -508,6 +625,12 @@ async function startInternal(client, spec, opts) {
|
|
|
508
625
|
const queue = [...buffered];
|
|
509
626
|
let resolveNext = null;
|
|
510
627
|
let done = false;
|
|
628
|
+
// If a terminal event was already buffered before this iterator
|
|
629
|
+
// attached (e.g. iteration starts after `await op.result()`
|
|
630
|
+
// resolved), we'll go done after the queue drains. Without this,
|
|
631
|
+
// late iteration would hang forever waiting for an emit that
|
|
632
|
+
// will never come.
|
|
633
|
+
const terminalAlreadyBuffered = buffered.some((ev) => ev.type === "ready");
|
|
511
634
|
const subscriber = (ev) => {
|
|
512
635
|
const waiter = resolveNext;
|
|
513
636
|
if (waiter) {
|
|
@@ -525,21 +648,26 @@ async function startInternal(client, spec, opts) {
|
|
|
525
648
|
}
|
|
526
649
|
};
|
|
527
650
|
subscribers.push(subscriber);
|
|
528
|
-
//
|
|
529
|
-
|
|
651
|
+
// Wake up on either success or failure of the result promise
|
|
652
|
+
// so an iterator attached after termination always exits.
|
|
653
|
+
const finalize = () => {
|
|
530
654
|
done = true;
|
|
531
655
|
if (resolveNext) {
|
|
532
656
|
const r = resolveNext;
|
|
533
657
|
resolveNext = null;
|
|
534
658
|
r({ value: undefined, done: true });
|
|
535
659
|
}
|
|
536
|
-
}
|
|
660
|
+
};
|
|
661
|
+
resultPromise.then(finalize, finalize);
|
|
537
662
|
return {
|
|
538
663
|
next() {
|
|
539
664
|
if (queue.length > 0) {
|
|
540
665
|
return Promise.resolve({ value: queue.shift(), done: false });
|
|
541
666
|
}
|
|
542
|
-
if
|
|
667
|
+
// After queue drain, if we already saw the terminal event
|
|
668
|
+
// in the initial buffer (or the result promise has
|
|
669
|
+
// resolved/rejected), we're done.
|
|
670
|
+
if (done || terminalAlreadyBuffered) {
|
|
543
671
|
return Promise.resolve({
|
|
544
672
|
value: undefined,
|
|
545
673
|
done: true,
|
|
@@ -569,22 +697,30 @@ function validateSpec(spec) {
|
|
|
569
697
|
if (!spec || typeof spec !== "object") {
|
|
570
698
|
throw new Run402DeployError("ReleaseSpec must be an object", {
|
|
571
699
|
code: "INVALID_SPEC",
|
|
700
|
+
phase: "validate",
|
|
701
|
+
resource: "spec",
|
|
572
702
|
retryable: false,
|
|
703
|
+
fix: { action: "set_field", path: "" },
|
|
573
704
|
context: "validating spec",
|
|
574
705
|
});
|
|
575
706
|
}
|
|
576
707
|
if (!spec.project || typeof spec.project !== "string") {
|
|
577
708
|
throw new Run402DeployError("ReleaseSpec.project is required", {
|
|
578
709
|
code: "INVALID_SPEC",
|
|
710
|
+
phase: "validate",
|
|
711
|
+
resource: "spec.project",
|
|
579
712
|
retryable: false,
|
|
713
|
+
fix: { action: "set_field", path: "project" },
|
|
580
714
|
context: "validating spec",
|
|
581
715
|
});
|
|
582
716
|
}
|
|
583
717
|
if (spec.subdomains?.set && spec.subdomains.set.length > 1) {
|
|
584
718
|
throw new Run402DeployError("subdomains.set accepts at most one subdomain per project; multi-subdomain support is not yet available", {
|
|
585
719
|
code: "SUBDOMAIN_MULTI_NOT_SUPPORTED",
|
|
720
|
+
phase: "validate",
|
|
586
721
|
resource: "subdomains.set",
|
|
587
722
|
retryable: false,
|
|
723
|
+
fix: { action: "set_field", path: "subdomains.set" },
|
|
588
724
|
context: "validating spec",
|
|
589
725
|
});
|
|
590
726
|
}
|
|
@@ -708,8 +844,10 @@ async function normalizeMigration(client, projectId, m, remember) {
|
|
|
708
844
|
if (!m.id) {
|
|
709
845
|
throw new Run402DeployError("MigrationSpec.id is required", {
|
|
710
846
|
code: "INVALID_SPEC",
|
|
847
|
+
phase: "validate",
|
|
711
848
|
resource: "database.migrations",
|
|
712
849
|
retryable: false,
|
|
850
|
+
fix: { action: "set_field", path: "database.migrations[].id" },
|
|
713
851
|
context: "validating spec",
|
|
714
852
|
});
|
|
715
853
|
}
|
|
@@ -730,8 +868,13 @@ async function normalizeMigration(client, projectId, m, remember) {
|
|
|
730
868
|
else {
|
|
731
869
|
throw new Run402DeployError(`MigrationSpec ${m.id} must include sql or sql_ref`, {
|
|
732
870
|
code: "INVALID_SPEC",
|
|
871
|
+
phase: "validate",
|
|
733
872
|
resource: `database.migrations.${m.id}`,
|
|
734
873
|
retryable: false,
|
|
874
|
+
fix: {
|
|
875
|
+
action: "set_field",
|
|
876
|
+
path: `database.migrations.${m.id}.sql`,
|
|
877
|
+
},
|
|
735
878
|
context: "validating spec",
|
|
736
879
|
});
|
|
737
880
|
}
|
|
@@ -1037,9 +1180,19 @@ function translateDeployError(err, phase, planId, operationId) {
|
|
|
1037
1180
|
context: err.context,
|
|
1038
1181
|
});
|
|
1039
1182
|
}
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1183
|
+
if (err instanceof NetworkError) {
|
|
1184
|
+
return new Run402DeployError(err.message, {
|
|
1185
|
+
code: "NETWORK_ERROR",
|
|
1186
|
+
phase,
|
|
1187
|
+
retryable: true,
|
|
1188
|
+
operationId,
|
|
1189
|
+
planId,
|
|
1190
|
+
context: phase,
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
// Re-throw other Run402Error subclasses (PaymentRequired, Unauthorized, etc.)
|
|
1194
|
+
// as-is — the consumer handles them at a different layer than
|
|
1195
|
+
// deploy-state-machine errors.
|
|
1043
1196
|
if (err instanceof Error) {
|
|
1044
1197
|
return new Run402DeployError(err.message, {
|
|
1045
1198
|
code: "INTERNAL_ERROR",
|
|
@@ -1070,9 +1223,20 @@ function extractGatewayError(body) {
|
|
|
1070
1223
|
if (body.error &&
|
|
1071
1224
|
typeof body.error === "object" &&
|
|
1072
1225
|
typeof body.error.code === "string") {
|
|
1073
|
-
|
|
1226
|
+
const nested = body.error;
|
|
1227
|
+
return {
|
|
1228
|
+
...nested,
|
|
1229
|
+
category: nested.category ?? stringField(body, "category"),
|
|
1230
|
+
retryable: nested.retryable ?? booleanField(body, "retryable"),
|
|
1231
|
+
safe_to_retry: nested.safe_to_retry ?? booleanField(body, "safe_to_retry"),
|
|
1232
|
+
mutation_state: nested.mutation_state ?? stringField(body, "mutation_state"),
|
|
1233
|
+
trace_id: nested.trace_id ?? stringField(body, "trace_id"),
|
|
1234
|
+
details: nested.details ?? objectField(body, "details"),
|
|
1235
|
+
next_actions: nested.next_actions ?? arrayField(body, "next_actions"),
|
|
1236
|
+
};
|
|
1074
1237
|
}
|
|
1075
1238
|
if (typeof body.code === "string") {
|
|
1239
|
+
const details = objectField(body, "details");
|
|
1076
1240
|
const out = { code: body.code };
|
|
1077
1241
|
if (typeof body.message === "string") {
|
|
1078
1242
|
out.message = body.message;
|
|
@@ -1080,25 +1244,73 @@ function extractGatewayError(body) {
|
|
|
1080
1244
|
else if (typeof body.error === "string") {
|
|
1081
1245
|
out.message = body.error;
|
|
1082
1246
|
}
|
|
1247
|
+
else if (typeof details?.message === "string") {
|
|
1248
|
+
out.message = details.message;
|
|
1249
|
+
}
|
|
1083
1250
|
else {
|
|
1084
1251
|
out.message = `Deploy error: ${body.code}`;
|
|
1085
1252
|
}
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
if (
|
|
1095
|
-
out.
|
|
1096
|
-
if (
|
|
1097
|
-
out.
|
|
1253
|
+
const phase = stringField(body, "phase") ?? stringField(details, "phase");
|
|
1254
|
+
const resource = stringField(body, "resource") ?? stringField(details, "resource");
|
|
1255
|
+
const retryable = booleanField(body, "retryable") ?? booleanField(details, "retryable");
|
|
1256
|
+
const rolledBack = booleanField(body, "rolled_back") ?? booleanField(details, "rolled_back");
|
|
1257
|
+
const operationId = stringField(body, "operation_id") ?? stringField(details, "operation_id");
|
|
1258
|
+
const planId = stringField(body, "plan_id") ?? stringField(details, "plan_id");
|
|
1259
|
+
const fix = body.fix !== undefined ? body.fix : details?.fix;
|
|
1260
|
+
const logs = arrayField(body, "logs") ?? arrayField(details, "logs");
|
|
1261
|
+
if (phase !== undefined)
|
|
1262
|
+
out.phase = phase;
|
|
1263
|
+
if (resource !== undefined)
|
|
1264
|
+
out.resource = resource;
|
|
1265
|
+
if (retryable !== undefined)
|
|
1266
|
+
out.retryable = retryable;
|
|
1267
|
+
if (typeof body.category === "string")
|
|
1268
|
+
out.category = body.category;
|
|
1269
|
+
if (typeof body.safe_to_retry === "boolean")
|
|
1270
|
+
out.safe_to_retry = body.safe_to_retry;
|
|
1271
|
+
if (typeof body.mutation_state === "string")
|
|
1272
|
+
out.mutation_state = body.mutation_state;
|
|
1273
|
+
if (typeof body.trace_id === "string")
|
|
1274
|
+
out.trace_id = body.trace_id;
|
|
1275
|
+
if (details !== null)
|
|
1276
|
+
out.details = details;
|
|
1277
|
+
if (Array.isArray(body.next_actions))
|
|
1278
|
+
out.next_actions = body.next_actions;
|
|
1279
|
+
if (fix !== undefined)
|
|
1280
|
+
out.fix = fix;
|
|
1281
|
+
if (logs !== undefined)
|
|
1282
|
+
out.logs = logs;
|
|
1283
|
+
if (rolledBack !== undefined)
|
|
1284
|
+
out.rolled_back = rolledBack;
|
|
1285
|
+
if (operationId !== undefined)
|
|
1286
|
+
out.operation_id = operationId;
|
|
1287
|
+
if (planId !== undefined)
|
|
1288
|
+
out.plan_id = planId;
|
|
1289
|
+
out.source_body = body;
|
|
1098
1290
|
return out;
|
|
1099
1291
|
}
|
|
1100
1292
|
return null;
|
|
1101
1293
|
}
|
|
1294
|
+
function stringField(obj, key) {
|
|
1295
|
+
return obj && typeof obj === "object" && typeof obj[key] === "string"
|
|
1296
|
+
? obj[key]
|
|
1297
|
+
: undefined;
|
|
1298
|
+
}
|
|
1299
|
+
function booleanField(obj, key) {
|
|
1300
|
+
return obj && typeof obj === "object" && typeof obj[key] === "boolean"
|
|
1301
|
+
? obj[key]
|
|
1302
|
+
: undefined;
|
|
1303
|
+
}
|
|
1304
|
+
function objectField(obj, key) {
|
|
1305
|
+
const value = obj && typeof obj === "object" ? obj[key] : undefined;
|
|
1306
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
1307
|
+
? value
|
|
1308
|
+
: null;
|
|
1309
|
+
}
|
|
1310
|
+
function arrayField(obj, key) {
|
|
1311
|
+
const value = obj && typeof obj === "object" ? obj[key] : undefined;
|
|
1312
|
+
return Array.isArray(value) ? value : undefined;
|
|
1313
|
+
}
|
|
1102
1314
|
function translateGatewayError(gw, phase, planId, operationId) {
|
|
1103
1315
|
if (!gw) {
|
|
1104
1316
|
return new Run402DeployError("Deploy failed without a structured error", {
|
|
@@ -1110,17 +1322,36 @@ function translateGatewayError(gw, phase, planId, operationId) {
|
|
|
1110
1322
|
context: phase,
|
|
1111
1323
|
});
|
|
1112
1324
|
}
|
|
1325
|
+
// Normalize the gateway code to the SCREAMING_SNAKE_CASE convention used
|
|
1326
|
+
// by `Run402DeployErrorCode`. Some gateway routes return lowercase
|
|
1327
|
+
// (`operation_not_found`) while services return uppercase
|
|
1328
|
+
// (`OPERATION_NOT_FOUND`); consumers expect the canonical uppercase form.
|
|
1329
|
+
const normalizedCode = gw.code.toUpperCase();
|
|
1330
|
+
// Prefer body-supplied ids — the gateway is the authoritative source for
|
|
1331
|
+
// which operation/plan an error belongs to. The caller-provided arguments
|
|
1332
|
+
// are only used as a fallback (e.g., commit failures where the call site
|
|
1333
|
+
// already knows the plan id but the body omits it).
|
|
1334
|
+
const details = objectField(gw, "details");
|
|
1335
|
+
const opId = (gw && gw.operation_id) ??
|
|
1336
|
+
stringField(details, "operation_id") ??
|
|
1337
|
+
operationId;
|
|
1338
|
+
const pId = (gw && gw.plan_id) ??
|
|
1339
|
+
stringField(details, "plan_id") ??
|
|
1340
|
+
planId;
|
|
1341
|
+
const body = gw.source_body ?? gw;
|
|
1342
|
+
const fix = (gw.fix ?? details?.fix ?? null);
|
|
1343
|
+
const logs = (gw.logs ?? arrayField(details, "logs") ?? null);
|
|
1113
1344
|
return new Run402DeployError(gw.message ?? `Deploy failed: ${gw.code}`, {
|
|
1114
|
-
code:
|
|
1115
|
-
phase: gw.phase ?? phase,
|
|
1116
|
-
resource: gw.resource ?? null,
|
|
1345
|
+
code: normalizedCode,
|
|
1346
|
+
phase: gw.phase ?? stringField(details, "phase") ?? phase,
|
|
1347
|
+
resource: gw.resource ?? stringField(details, "resource") ?? null,
|
|
1117
1348
|
retryable: gw.retryable ?? false,
|
|
1118
|
-
operationId,
|
|
1119
|
-
planId,
|
|
1120
|
-
fix
|
|
1121
|
-
logs
|
|
1122
|
-
rolledBack: gw.rolled_back ?? false,
|
|
1123
|
-
body
|
|
1349
|
+
operationId: opId,
|
|
1350
|
+
planId: pId,
|
|
1351
|
+
fix,
|
|
1352
|
+
logs,
|
|
1353
|
+
rolledBack: gw.rolled_back ?? booleanField(details, "rolled_back") ?? false,
|
|
1354
|
+
body,
|
|
1124
1355
|
context: phase,
|
|
1125
1356
|
});
|
|
1126
1357
|
}
|