@taprootio/site-authoring 0.8.5 → 0.8.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taprootio/site-authoring",
3
- "version": "0.8.5",
3
+ "version": "0.8.6",
4
4
  "description": "Taproot site authoring CLI: sign in once in the browser, then pull, validate, push, preview, approve, and deploy a site from the terminal",
5
5
  "type": "module",
6
6
  "license": "ISC",
package/src/api.js CHANGED
@@ -103,6 +103,15 @@ export const DEPLOYMENT_STATUSES = Object.freeze([
103
103
  DEPLOYMENT_STATUS_FAILED,
104
104
  ]);
105
105
 
106
+ // DeploySite's admission outcome (TR00839): whether this request created the
107
+ // deployment or coalesced into an already-anchored one.
108
+ export const DEPLOYMENT_REQUEST_OUTCOME_ACCEPTED = "DEPLOYMENT_REQUEST_OUTCOME_ACCEPTED";
109
+ export const DEPLOYMENT_REQUEST_OUTCOME_COALESCED = "DEPLOYMENT_REQUEST_OUTCOME_COALESCED";
110
+ export const DEPLOYMENT_REQUEST_OUTCOMES = Object.freeze([
111
+ DEPLOYMENT_REQUEST_OUTCOME_ACCEPTED,
112
+ DEPLOYMENT_REQUEST_OUTCOME_COALESCED,
113
+ ]);
114
+
106
115
  export const IMAGE_PROCESSING_STATE_UNKNOWN = "IMAGE_PROCESSING_STATE_UNKNOWN";
107
116
  export const IMAGE_PROCESSING_STATE_COMPLETE = "IMAGE_PROCESSING_STATE_COMPLETE";
108
117
  export const IMAGE_PROCESSING_STATE_FAILED = "IMAGE_PROCESSING_STATE_FAILED";
@@ -351,7 +360,11 @@ const REFUSAL_GUIDANCE = Object.freeze({
351
360
  [REFUSAL_CREDENTIAL_REJECTED]:
352
361
  "The site authoring credential was rejected: it is invalid, revoked, or bound to a different site. "
353
362
  + "Stop and re-issue it; retrying cannot succeed.",
354
- [REFUSAL_THROTTLED]: "This credential exceeded its request budget. Back off and retry with delay.",
363
+ // Covers both the per-key request-budget throttle and the durable
364
+ // per-site/per-account deployment request limit (TR00839): both surface as
365
+ // gRPC ResourceExhausted / HTTP 429, and the remedy is identical either way.
366
+ [REFUSAL_THROTTLED]:
367
+ "Too many deployment requests for this site or account. Back off and retry after the delay the server reported.",
355
368
  });
356
369
 
357
370
  /**
@@ -436,6 +449,12 @@ export function announceRefusal(error, onProgress, action = "request") {
436
449
  }
437
450
  const guidance = REFUSAL_GUIDANCE[kind];
438
451
  if (guidance) onProgress(guidance);
452
+ // The retry-after trailer (TR00839) is the exact delay the server measured;
453
+ // surface it when the transport captured it rather than leaving the agent
454
+ // to guess a backoff.
455
+ if (kind === REFUSAL_THROTTLED && typeof error.retryAfterSeconds === "number") {
456
+ onProgress(` Retry after: ${error.retryAfterSeconds}s.`);
457
+ }
439
458
  }
440
459
 
441
460
  /**
@@ -1017,6 +1036,9 @@ export function normalizeDeployment(value) {
1017
1036
  errorMessage: text(deployment.errorMessage),
1018
1037
  pageCount: safeCount(deployment.pageCount),
1019
1038
  phaseTimings: normalizePhaseTimings(deployment.phaseHistory),
1039
+ // Identical requests coalesced into this deployment inside its settling
1040
+ // window (TR00839). Zero for a deployment that never anchored one.
1041
+ coalescedRequestCount: safeCount(deployment.coalescedRequestCount),
1020
1042
  };
1021
1043
  }
1022
1044
 
@@ -1074,7 +1096,19 @@ export async function deploySite(client, siteId, body) {
1074
1096
  }
1075
1097
  throw error;
1076
1098
  }
1077
- return normalizeDeployment(response.deployment);
1099
+ const deployment = normalizeDeployment(response.deployment);
1100
+ // `DEPLOYMENT_REQUEST_OUTCOME_ACCEPTED` is the zero-adjacent default: an
1101
+ // older server that omits the field, or the immutable Standard/Docs
1102
+ // branches that never run through the admission seam, both read as this
1103
+ // request creating its own deployment (TR00839).
1104
+ const outcome = enumValue(
1105
+ response.outcome,
1106
+ DEPLOYMENT_REQUEST_OUTCOMES,
1107
+ DEPLOYMENT_REQUEST_OUTCOME_ACCEPTED,
1108
+ "api.deployment_contract",
1109
+ "deployment request outcome",
1110
+ );
1111
+ return { ...deployment, outcome };
1078
1112
  }
1079
1113
 
1080
1114
  /**
package/src/constants.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const CLI_NAME = "@taprootio/site-authoring";
2
- export const CLI_VERSION = "0.8.5";
2
+ export const CLI_VERSION = "0.8.6";
3
3
  export const CLI_BINARY_NAME = "taproot-site";
4
4
  export const RESULT_SCHEMA_VERSION = 1;
5
5
  export const CONFIG_FILE_NAME = "taproot-site.json";
package/src/transport.js CHANGED
@@ -396,8 +396,21 @@ export function capabilityRefusal(value) {
396
396
  return undefined;
397
397
  }
398
398
 
399
+ /**
400
+ * Reads the `retry-after` response header as a non-negative integer number of
401
+ * seconds. `headers` is a Fetch `Headers` instance when available; anything
402
+ * else, a missing header, or a value that does not parse as a non-negative
403
+ * number, returns undefined rather than throwing.
404
+ */
405
+ function parseRetryAfterHeader(headers) {
406
+ const raw = typeof headers?.get === "function" ? headers.get("retry-after") : undefined;
407
+ if (typeof raw !== "string" || raw.length === 0) return undefined;
408
+ const seconds = Number(raw);
409
+ return Number.isFinite(seconds) && seconds >= 0 ? seconds : undefined;
410
+ }
411
+
399
412
  export class ApiError extends SiteAuthoringError {
400
- constructor(httpStatus, body) {
413
+ constructor(httpStatus, body, headers) {
401
414
  const grpcCode = Number.isSafeInteger(body?.code) ? body.code : undefined;
402
415
  const fields = fieldViolations(body);
403
416
  const capability = capabilityRefusal(body);
@@ -433,6 +446,11 @@ export class ApiError extends SiteAuthoringError {
433
446
  this.fields = fields;
434
447
  this.violationDescriptions = fieldDescriptions(body);
435
448
  this.capability = capability;
449
+ // The `retry-after` trailer (TR00839's deployment throttle, or any other
450
+ // ResourceExhausted refusal that sets it) transcodes to an ordinary
451
+ // response header. Undefined when absent or unparseable — callers must
452
+ // not assume every throttle carries a delay.
453
+ this.retryAfterSeconds = parseRetryAfterHeader(headers);
436
454
  }
437
455
 
438
456
  hasField(field) {
@@ -767,7 +785,7 @@ export class SiteApiClient {
767
785
  responseBody = Object.create(null);
768
786
  }
769
787
  if (remainingBudget(deadline, now) <= 0) throw deadlineError();
770
- throw new ApiError(response.status, responseBody);
788
+ throw new ApiError(response.status, responseBody, response.headers);
771
789
  }
772
790
  throw new SiteAuthoringError("transport.retry_exhausted", "The Taproot API request exhausted its retry budget.");
773
791
  }
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  DEPLOYMENT_ENVIRONMENT_PRODUCTION,
3
3
  DEPLOYMENT_ENVIRONMENT_STAGING,
4
+ DEPLOYMENT_REQUEST_OUTCOME_COALESCED,
4
5
  DEPLOYMENT_STATUS_COMPLETED,
5
6
  deploySite,
6
7
  getDeploySelection,
@@ -80,6 +81,16 @@ function usageError(code, message, field) {
80
81
  return new SiteAuthoringError(code, message, { field, exitCode: 2 });
81
82
  }
82
83
 
84
+ /**
85
+ * One line when this request coalesced into an already in-flight or recently
86
+ * anchored identical deployment (TR00839), instead of creating a new one.
87
+ */
88
+ function announceOutcome(created, onProgress) {
89
+ if (created.outcome === DEPLOYMENT_REQUEST_OUTCOME_COALESCED) {
90
+ onProgress(`An identical deployment request is already in progress; reusing deployment ${created.id}.`);
91
+ }
92
+ }
93
+
83
94
  function explicitSelection(invocation) {
84
95
  const stagedPageIds = Array.isArray(invocation.stagedPageIds) ? invocation.stagedPageIds : undefined;
85
96
  const selectedSettingsTypes = Array.isArray(invocation.selectedSettingsTypes)
@@ -268,6 +279,7 @@ export async function deploy(invocation) {
268
279
  ...(invocation.allowFailedPreview === true ? { allowFailedPreview: true } : {}),
269
280
  });
270
281
  onProgress(`Production deployment ${created.id} accepted; waiting for it to complete.`);
282
+ announceOutcome(created, onProgress);
271
283
  const completed = await waitForDeployment(client, {
272
284
  siteId,
273
285
  deploymentId: created.id,
@@ -283,6 +295,7 @@ export async function deploy(invocation) {
283
295
  ...(presentationOnly ? { authoringSurface: SURFACE_DOCS_PRESENTATION } : {}),
284
296
  promotedStagingDeploymentId: stagingDeploymentId,
285
297
  deployment: completed,
298
+ outcome: created.outcome,
286
299
  readiness: reportReadiness(readiness),
287
300
  });
288
301
  }
@@ -357,6 +370,7 @@ export async function deploy(invocation) {
357
370
  ...(invocation.allowFailedPreview === true ? { allowFailedPreview: true } : {}),
358
371
  });
359
372
  onProgress(`Staging deployment ${created.id} accepted; waiting for it to complete.`);
373
+ announceOutcome(created, onProgress);
360
374
  const completed = await waitForDeployment(client, {
361
375
  siteId,
362
376
  deploymentId: created.id,
@@ -377,6 +391,7 @@ export async function deploy(invocation) {
377
391
  includeNavigation: false,
378
392
  },
379
393
  deployment: completed,
394
+ outcome: created.outcome,
380
395
  readiness: reportReadiness(readiness),
381
396
  stagingPreview,
382
397
  nextStep: stagingPreview.url === "" ? "staging review" : "deploy --production",
@@ -393,6 +408,7 @@ export async function deploy(invocation) {
393
408
  includeNavigation,
394
409
  },
395
410
  deployment: completed,
411
+ outcome: created.outcome,
396
412
  readiness: reportReadiness(readiness),
397
413
  stagingPreview,
398
414
  nextStep: stagingPreview.redirects?.verified === true ? "deploy --production" : "redirects check",