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.
@@ -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
- const snapshot = await this.client.request(`/deploy/v2/operations/${operationId}/resume`, { method: "POST", context: "resuming deploy operation" });
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 uploadOne(client.fetch, session, bytes);
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
- lastPhaseEmitted = snapshot.status;
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
- // Surface terminal failure as iterator end.
529
- resultPromise.catch(() => {
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 (done) {
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
- // Re-throw other Run402Error subclasses (PaymentRequired, Unauthorized,
1041
- // NetworkError, etc.) as-is — the consumer handles them at a different
1042
- // layer than deploy-state-machine errors.
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
- return body.error;
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
- if (typeof body.phase === "string")
1087
- out.phase = body.phase;
1088
- if (typeof body.resource === "string")
1089
- out.resource = body.resource;
1090
- if (typeof body.retryable === "boolean")
1091
- out.retryable = body.retryable;
1092
- if (body.fix !== undefined)
1093
- out.fix = body.fix;
1094
- if (Array.isArray(body.logs))
1095
- out.logs = body.logs;
1096
- if (typeof body.rolled_back === "boolean")
1097
- out.rolled_back = body.rolled_back;
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: gw.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: (gw.fix ?? null),
1121
- logs: gw.logs ?? null,
1122
- rolledBack: gw.rolled_back ?? false,
1123
- body: gw,
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
  }