run402 4.72.1 → 4.73.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -546,6 +546,80 @@ function deriveSliceKinds(spec) {
546
546
  set.add("asset");
547
547
  return [...set].sort((a, b) => (a === "release" ? -1 : 1));
548
548
  }
549
+ /**
550
+ * Decide and (when warranted) run the rehearsal for a cloud apply. Returns
551
+ * the `DeployResult.rehearsal` block plus, after a passed rehearsal, the
552
+ * bound `requiredPlan` the commit must carry. Throws `REHEARSAL_FAILED`
553
+ * (with the report attached) when the rehearsal fails — nothing has been
554
+ * committed at that point.
555
+ */
556
+ async function rehearseBeforeCommit(client, plan, planId, project, opts, emit) {
557
+ const skip = (reason) => {
558
+ emit({ type: "rehearsal.skipped", reason });
559
+ return { block: { status: "skipped", reason } };
560
+ };
561
+ if (opts.noRehearse)
562
+ return skip("disabled");
563
+ if (opts.requiredPlan)
564
+ return skip("reviewed_plan");
565
+ const envelope = plan.rehearsal;
566
+ if (!envelope || !envelope.available) {
567
+ const reason = envelope?.reason ?? "no_migrations";
568
+ return skip(reason === "no_live_release" ? "no_live_release" : "no_migrations");
569
+ }
570
+ emit({ type: "rehearsal.started", planId });
571
+ let rehearsed;
572
+ try {
573
+ rehearsed = await client.request(`/apply/v1/plans/${encodeURIComponent(planId)}/rehearse`, {
574
+ method: "POST",
575
+ body: {},
576
+ authMeta: { method: "deploy.rehearse", capability: "project.deploy", target: { project_id: project } },
577
+ context: "rehearsing deploy plan",
578
+ });
579
+ }
580
+ catch (err) {
581
+ throw translateDeployError(err, "rehearsal", planId, null);
582
+ }
583
+ const report = rehearsed.report;
584
+ emit({
585
+ type: "rehearsal.finished",
586
+ planId,
587
+ status: report.status,
588
+ operationId: report.operation_id,
589
+ branchProjectId: report.branch_project_id,
590
+ durationMs: report.duration_ms,
591
+ });
592
+ if (report.status !== "passed") {
593
+ throw new Run402DeployError(`Rehearsal failed on a contained branch; nothing was committed. ${describeRehearsalFailure(report)}`, {
594
+ code: "REHEARSAL_FAILED",
595
+ phase: "rehearsal",
596
+ retryable: false,
597
+ context: "rehearsing deploy plan",
598
+ planId,
599
+ operationId: report.operation_id,
600
+ body: { rehearsal: report, next_actions: report.next_actions },
601
+ });
602
+ }
603
+ const bound = report.next_actions.find((a) => a.type === "commit_plan")?.body?.required_plan;
604
+ return {
605
+ block: {
606
+ status: "passed",
607
+ report,
608
+ operation_id: report.operation_id,
609
+ branch_project_id: report.branch_project_id,
610
+ },
611
+ ...(bound ? { requiredPlan: { planId: bound.plan_id, planFingerprint: bound.plan_fingerprint } } : {}),
612
+ };
613
+ }
614
+ function describeRehearsalFailure(report) {
615
+ const failedMigration = report.migrations.find((m) => m.status === "failed");
616
+ if (failedMigration)
617
+ return `Migration ${failedMigration.id} failed${failedMigration.error ? `: ${failedMigration.error}` : ""}.`;
618
+ const failedCheck = report.checks.find((c) => c.status === "failed");
619
+ if (failedCheck)
620
+ return `Check ${failedCheck.name} failed${failedCheck.error ? `: ${failedCheck.error}` : ""}.`;
621
+ return report.error?.message ? `${report.error.message}.` : "";
622
+ }
549
623
  async function applyOnce(client, spec, opts, emit) {
550
624
  const allowWarningCodes = normalizeAllowWarningCodes(opts.allowWarningCodes);
551
625
  const target = opts.target === "core" ? "core" : "cloud";
@@ -601,6 +675,15 @@ async function applyOnce(client, spec, opts, emit) {
601
675
  ...(sliceKinds.length > 0 ? { slice_kinds: sliceKinds } : {}),
602
676
  });
603
677
  const { planId, operationId } = requirePersistedPlan(plan, "applying deploy");
678
+ // first-deploy-agent-dx: rehearsal is apply's decision, from the plan
679
+ // envelope. A migration-bearing plan against a project with a live release
680
+ // is rehearsed on a contained branch and committed only on a passing
681
+ // report; a first deploy (`reason: "no_live_release"`) has nothing to
682
+ // protect and commits directly. A passed rehearsal binds the commit to the
683
+ // report's `required_plan` — unbound commits of rehearsed plans are
684
+ // refused by the gateway.
685
+ const rehearsal = await rehearseBeforeCommit(client, plan, planId, spec.project, opts, emit);
686
+ const requiredPlan = rehearsal.requiredPlan ?? opts.requiredPlan;
604
687
  // gitvault §6.5 — the handshake. Content is uploaded, so the artifacts this
605
688
  // release ships are fixed; `authorize` verifies snapshot correspondence,
606
689
  // mints the activation token, and hands back the commit block. It throws
@@ -613,8 +696,9 @@ async function applyOnce(client, spec, opts, emit) {
613
696
  apply_plan_sha256: plan.gitvault?.apply_plan_sha256 ?? null,
614
697
  })
615
698
  : undefined;
616
- const commit = requireCloudCommitResponse(await commitInternal(client, planId, opts.idempotencyKey, spec.project, opts.requiredPlan, gitvaultCommit), "applying deploy");
699
+ const commit = requireCloudCommitResponse(await commitInternal(client, planId, opts.idempotencyKey, spec.project, requiredPlan, gitvaultCommit), "applying deploy");
617
700
  const result = await pollUntilReady(client, commit, plan.diff, plan.warnings, emit, spec.project, sliceKinds);
701
+ result.rehearsal = rehearsal.block;
618
702
  // v1.48 unified-apply: thread the plan response's `asset_entries[]` back
619
703
  // into DeployResult.assets so callers reading `result.assets.byKey[key]`
620
704
  // get the gateway-authoritative `AssetRef` envelope (URLs, SRI, etag).