@run402/sdk 1.69.7 → 1.70.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.
@@ -37,6 +37,38 @@ const SECRET_KEY_RE = /^[A-Z_][A-Z0-9_]{0,127}$/;
37
37
  const APPLY_SAFE_RETRY_CODES = new Set([
38
38
  "BASE_RELEASE_CONFLICT",
39
39
  ]);
40
+ const STATIC_ACTIVATION_FAILURE_CODES = new Set([
41
+ "BAD_FIELD",
42
+ "INVALID_SPEC",
43
+ "FUNCTION_ACTIVATE_FAILED",
44
+ "FUNCTION_CONFIG_INVALID",
45
+ "FUNCTION_TIMEOUT_EXCEEDS_TIER",
46
+ "FUNCTION_MEMORY_EXCEEDS_TIER",
47
+ "FUNCTION_SCHEDULE_EXCEEDS_TIER",
48
+ "FUNCTION_SCHEDULE_INTERVAL_TOO_SHORT",
49
+ "FUNCTION_SCHEDULE_LIMIT_EXCEEDED",
50
+ "TIER_LIMIT_EXCEEDED",
51
+ ]);
52
+ const STATIC_FUNCTION_LIMITS_BY_TIER = {
53
+ prototype: {
54
+ maxTimeoutSeconds: 10,
55
+ maxMemoryMb: 128,
56
+ maxScheduledFunctions: 1,
57
+ minCronIntervalMinutes: 15,
58
+ },
59
+ hobby: {
60
+ maxTimeoutSeconds: 30,
61
+ maxMemoryMb: 256,
62
+ maxScheduledFunctions: 3,
63
+ minCronIntervalMinutes: 5,
64
+ },
65
+ team: {
66
+ maxTimeoutSeconds: 60,
67
+ maxMemoryMb: 512,
68
+ maxScheduledFunctions: 10,
69
+ minCronIntervalMinutes: 1,
70
+ },
71
+ };
40
72
  const MANIFEST_CONTENT_TYPE = "application/vnd.run402.deploy-manifest+json";
41
73
  const TERMINAL_STATUSES = [
42
74
  "ready",
@@ -331,11 +363,12 @@ function requireNonEmptyStringQueryOption(value, label, context) {
331
363
  }
332
364
  // ─── Internal pipeline ───────────────────────────────────────────────────────
333
365
  async function applyOnce(client, spec, opts, emit) {
366
+ const allowWarningCodes = normalizeAllowWarningCodes(opts.allowWarningCodes);
334
367
  emit({ type: "plan.started" });
335
368
  const { plan, byteReaders } = await planInternal(client, spec, opts.idempotencyKey);
336
369
  emit({ type: "plan.diff", diff: plan.diff });
337
370
  emitPlanWarnings(plan, emit);
338
- abortOnConfirmationWarnings(plan, opts);
371
+ abortOnConfirmationWarnings(plan, opts, allowWarningCodes);
339
372
  if (plan.payment_required) {
340
373
  emit({
341
374
  type: "payment.required",
@@ -420,6 +453,7 @@ async function planInternal(client, spec, idempotencyKey, dryRun = false) {
420
453
  if (ciCredentials)
421
454
  assertCiDeployableSpec(spec);
422
455
  const { normalized, byteReaders } = await normalizeReleaseSpec(client, spec);
456
+ await preflightTierFunctionLimits(client, normalized, ciCredentials);
423
457
  // The gateway expects { spec, manifest_ref?, idempotency_key? } with
424
458
  // ReleaseSpec.project (singular). For oversized specs the SDK uploads
425
459
  // the manifest JSON to CAS first and references it; the gateway still
@@ -474,6 +508,338 @@ async function planInternal(client, spec, idempotencyKey, dryRun = false) {
474
508
  }
475
509
  return { plan, byteReaders };
476
510
  }
511
+ async function preflightTierFunctionLimits(client, spec, ciCredentials) {
512
+ if (ciCredentials)
513
+ return;
514
+ if (!hasFunctionTierPreflightInputs(spec.functions))
515
+ return;
516
+ const limits = await readTierFunctionLimits(client);
517
+ if (!limits)
518
+ return;
519
+ const entries = collectFunctionPreflightEntries(spec.functions);
520
+ for (const entry of entries) {
521
+ const timeout = entry.fn.config?.timeoutSeconds;
522
+ if (timeout !== undefined &&
523
+ limits.maxTimeoutSeconds &&
524
+ timeout > limits.maxTimeoutSeconds.value) {
525
+ throw tierLimitError(`Function ${entry.name} timeoutSeconds ${timeout} exceeds the ${limits.tier} tier maximum of ${limits.maxTimeoutSeconds.value}.`, `${entry.fieldPrefix}.config.timeoutSeconds`, timeout, limits, limits.maxTimeoutSeconds, {
526
+ tier_max: limits.maxTimeoutSeconds.value,
527
+ max_function_timeout_seconds: limits.maxTimeoutSeconds.value,
528
+ });
529
+ }
530
+ const memory = entry.fn.config?.memoryMb;
531
+ if (memory !== undefined &&
532
+ limits.maxMemoryMb &&
533
+ memory > limits.maxMemoryMb.value) {
534
+ throw tierLimitError(`Function ${entry.name} memoryMb ${memory} exceeds the ${limits.tier} tier maximum of ${limits.maxMemoryMb.value}.`, `${entry.fieldPrefix}.config.memoryMb`, memory, limits, limits.maxMemoryMb, {
535
+ tier_max: limits.maxMemoryMb.value,
536
+ max_function_memory_mb: limits.maxMemoryMb.value,
537
+ });
538
+ }
539
+ if (isScheduledCron(entry.fn.schedule) && limits.minCronIntervalMinutes) {
540
+ const intervalMinutes = estimateCronMinimumIntervalMinutes(entry.fn.schedule);
541
+ if (intervalMinutes !== null &&
542
+ intervalMinutes < limits.minCronIntervalMinutes.value) {
543
+ throw tierLimitError(`Function ${entry.name} schedule runs every ${intervalMinutes} minute(s), below the ${limits.tier} tier minimum interval of ${limits.minCronIntervalMinutes.value} minutes.`, `${entry.fieldPrefix}.schedule`, entry.fn.schedule, limits, limits.minCronIntervalMinutes, {
544
+ interval_minutes: intervalMinutes,
545
+ min_interval_minutes: limits.minCronIntervalMinutes.value,
546
+ min_cron_interval_minutes: limits.minCronIntervalMinutes.value,
547
+ });
548
+ }
549
+ }
550
+ }
551
+ if (limits.maxScheduledFunctions) {
552
+ const lowerBound = countScheduledFunctionsInSetEntries(spec.functions);
553
+ if (lowerBound > limits.maxScheduledFunctions.value) {
554
+ throw scheduledCountTierLimitError(lowerBound, limits, limits.maxScheduledFunctions, "manifest");
555
+ }
556
+ const desired = await computeDesiredScheduledFunctionCount(client, spec);
557
+ if (desired && desired.count > limits.maxScheduledFunctions.value) {
558
+ throw scheduledCountTierLimitError(desired.count, limits, limits.maxScheduledFunctions, desired.source);
559
+ }
560
+ }
561
+ }
562
+ function hasFunctionTierPreflightInputs(functions) {
563
+ if (!functions)
564
+ return false;
565
+ return collectFunctionPreflightEntries(functions).some((entry) => (entry.fn.config?.timeoutSeconds !== undefined ||
566
+ entry.fn.config?.memoryMb !== undefined ||
567
+ isScheduledCron(entry.fn.schedule)));
568
+ }
569
+ function collectFunctionPreflightEntries(functions) {
570
+ if (!functions)
571
+ return [];
572
+ const entries = [];
573
+ for (const [name, fn] of Object.entries(functions.replace ?? {})) {
574
+ entries.push({ name, fn, fieldPrefix: `functions.${name}` });
575
+ }
576
+ for (const [name, fn] of Object.entries(functions.patch?.set ?? {})) {
577
+ entries.push({ name, fn, fieldPrefix: `functions.${name}` });
578
+ }
579
+ return entries;
580
+ }
581
+ async function readTierFunctionLimits(client) {
582
+ let status;
583
+ try {
584
+ status = await client.request("/tiers/v1/status", {
585
+ context: "checking tier status for deploy preflight",
586
+ });
587
+ }
588
+ catch {
589
+ return null;
590
+ }
591
+ if (typeof status.tier !== "string" || status.tier.length === 0) {
592
+ return null;
593
+ }
594
+ const tierKey = status.tier.toLowerCase();
595
+ const fallback = STATIC_FUNCTION_LIMITS_BY_TIER[tierKey];
596
+ const limits = { tier: status.tier };
597
+ limits.maxTimeoutSeconds = tierStatusLimitOrFallback(status, [
598
+ "max_function_timeout_seconds",
599
+ "max_timeout_seconds",
600
+ "timeout_seconds_max",
601
+ "function_timeout_seconds_max",
602
+ ], fallback?.maxTimeoutSeconds);
603
+ limits.maxMemoryMb = tierStatusLimitOrFallback(status, [
604
+ "max_function_memory_mb",
605
+ "max_memory_mb",
606
+ "memory_mb_max",
607
+ "function_memory_mb_max",
608
+ ], fallback?.maxMemoryMb);
609
+ limits.maxScheduledFunctions = tierStatusLimitOrFallback(status, [
610
+ "max_scheduled_functions",
611
+ "scheduled_functions_limit",
612
+ "scheduled_function_limit",
613
+ "max_function_schedules",
614
+ ], fallback?.maxScheduledFunctions);
615
+ limits.minCronIntervalMinutes = tierStatusLimitOrFallback(status, [
616
+ "min_cron_interval_minutes",
617
+ "minimum_cron_interval_minutes",
618
+ "min_schedule_interval_minutes",
619
+ "min_scheduled_function_interval_minutes",
620
+ ], fallback?.minCronIntervalMinutes);
621
+ limits.currentScheduledFunctions = tierStatusLimit(status, [
622
+ "current_scheduled_functions",
623
+ "current_scheduled_function_count",
624
+ "scheduled_function_count",
625
+ "scheduled_functions",
626
+ ]);
627
+ return hasAnyTierFunctionLimit(limits) ? limits : null;
628
+ }
629
+ function tierStatusLimitOrFallback(status, keys, fallback) {
630
+ return tierStatusLimit(status, keys) ?? (fallback === undefined
631
+ ? undefined
632
+ : { value: fallback, source: "local_static_fallback" });
633
+ }
634
+ function tierStatusLimit(status, keys) {
635
+ const limits = objectField(status, "limits");
636
+ const containers = [
637
+ objectField(status, "function_limits"),
638
+ objectField(limits, "functions"),
639
+ objectField(limits, "function_limits"),
640
+ objectField(status, "pool_usage"),
641
+ status,
642
+ ];
643
+ for (const container of containers) {
644
+ if (!container || typeof container !== "object" || Array.isArray(container))
645
+ continue;
646
+ const obj = container;
647
+ for (const key of keys) {
648
+ const value = obj[key];
649
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
650
+ return { value, source: "tier_status" };
651
+ }
652
+ }
653
+ }
654
+ return undefined;
655
+ }
656
+ function hasAnyTierFunctionLimit(limits) {
657
+ return Boolean(limits.maxTimeoutSeconds ||
658
+ limits.maxMemoryMb ||
659
+ limits.maxScheduledFunctions ||
660
+ limits.minCronIntervalMinutes ||
661
+ limits.currentScheduledFunctions);
662
+ }
663
+ async function computeDesiredScheduledFunctionCount(client, spec) {
664
+ const functions = spec.functions;
665
+ if (!functions)
666
+ return null;
667
+ const replaceScheduled = scheduledFunctionNames(functions.replace);
668
+ if (replaceScheduled) {
669
+ applyScheduledFunctionPatch(replaceScheduled, functions.patch);
670
+ return { count: replaceScheduled.size, source: "manifest" };
671
+ }
672
+ if (!functions.patch)
673
+ return null;
674
+ const activeScheduled = await readActiveScheduledFunctionNames(client, spec.project);
675
+ if (!activeScheduled)
676
+ return null;
677
+ applyScheduledFunctionPatch(activeScheduled, functions.patch);
678
+ return { count: activeScheduled.size, source: "active_release_inventory" };
679
+ }
680
+ function countScheduledFunctionsInSetEntries(functions) {
681
+ if (!functions)
682
+ return 0;
683
+ const names = new Set();
684
+ for (const [name, fn] of Object.entries(functions.replace ?? {})) {
685
+ if (isScheduledCron(fn.schedule))
686
+ names.add(name);
687
+ }
688
+ for (const [name, fn] of Object.entries(functions.patch?.set ?? {})) {
689
+ if (isScheduledCron(fn.schedule))
690
+ names.add(name);
691
+ }
692
+ return names.size;
693
+ }
694
+ function scheduledFunctionNames(functions) {
695
+ if (!functions)
696
+ return null;
697
+ const scheduled = new Set();
698
+ for (const [name, fn] of Object.entries(functions)) {
699
+ if (isScheduledCron(fn.schedule))
700
+ scheduled.add(name);
701
+ }
702
+ return scheduled;
703
+ }
704
+ function applyScheduledFunctionPatch(scheduled, patch) {
705
+ for (const name of patch?.delete ?? []) {
706
+ scheduled.delete(name);
707
+ }
708
+ for (const [name, fn] of Object.entries(patch?.set ?? {})) {
709
+ if (fn.schedule === null)
710
+ scheduled.delete(name);
711
+ else if (isScheduledCron(fn.schedule))
712
+ scheduled.add(name);
713
+ }
714
+ }
715
+ async function readActiveScheduledFunctionNames(client, projectId) {
716
+ let inventory;
717
+ try {
718
+ inventory = await client.request(appendQuery("/deploy/v2/releases/active", { site_limit: 1 }), {
719
+ headers: await apikeyHeaders(client, projectId),
720
+ context: "fetching active release inventory for deploy preflight",
721
+ });
722
+ }
723
+ catch {
724
+ return null;
725
+ }
726
+ const scheduled = new Set();
727
+ for (const fn of inventory.functions ?? []) {
728
+ if (isScheduledCron(fn.schedule))
729
+ scheduled.add(fn.name);
730
+ }
731
+ return scheduled;
732
+ }
733
+ function scheduledCountTierLimitError(count, limits, limit, countSource) {
734
+ return tierLimitError(`Deploy would have ${count} scheduled function(s), exceeding the ${limits.tier} tier maximum of ${limit.value}.`, "functions.scheduled_count", count, limits, limit, {
735
+ tier_max: limit.value,
736
+ max_scheduled_functions: limit.value,
737
+ count_source: countSource,
738
+ });
739
+ }
740
+ function tierLimitError(message, field, value, limits, limit, extraDetails) {
741
+ const hint = limit.source === "local_static_fallback"
742
+ ? "Tier limits came from the SDK's static fallback because /tiers/v1/status did not expose function caps. Run `run402 tier status` to refresh, lower the function setting, upgrade the tier, or retry and let gateway validation decide if this seems stale."
743
+ : "Lower the function setting or upgrade the tier before deploying.";
744
+ const details = {
745
+ field,
746
+ value,
747
+ tier: limits.tier,
748
+ limit_source: limit.source,
749
+ ...extraDetails,
750
+ };
751
+ const body = {
752
+ code: "BAD_FIELD",
753
+ category: "deploy",
754
+ message,
755
+ retryable: false,
756
+ details,
757
+ hint,
758
+ };
759
+ return new Run402DeployError(message, {
760
+ code: "BAD_FIELD",
761
+ phase: "validate",
762
+ resource: field,
763
+ retryable: false,
764
+ body,
765
+ context: "validating deploy tier limits",
766
+ });
767
+ }
768
+ function isScheduledCron(value) {
769
+ return typeof value === "string" && value.trim().length > 0;
770
+ }
771
+ function estimateCronMinimumIntervalMinutes(expression) {
772
+ const parts = expression.trim().split(/\s+/);
773
+ if (parts.length !== 5)
774
+ return null;
775
+ const minutes = expandCronNumberField(parts[0], 0, 59);
776
+ const hours = expandCronNumberField(parts[1], 0, 23);
777
+ if (!minutes || !hours || minutes.length === 0 || hours.length === 0)
778
+ return null;
779
+ const occurrences = [];
780
+ for (const hour of hours) {
781
+ for (const minute of minutes) {
782
+ occurrences.push(hour * 60 + minute);
783
+ }
784
+ }
785
+ occurrences.sort((a, b) => a - b);
786
+ if (occurrences.length <= 1)
787
+ return 24 * 60;
788
+ let minGap = Number.POSITIVE_INFINITY;
789
+ for (let i = 1; i < occurrences.length; i += 1) {
790
+ minGap = Math.min(minGap, occurrences[i] - occurrences[i - 1]);
791
+ }
792
+ minGap = Math.min(minGap, 24 * 60 - occurrences[occurrences.length - 1] + occurrences[0]);
793
+ return Number.isFinite(minGap) ? minGap : null;
794
+ }
795
+ function expandCronNumberField(field, min, max) {
796
+ const values = new Set();
797
+ for (const part of field.split(",")) {
798
+ const expanded = expandCronNumberPart(part.trim(), min, max);
799
+ if (!expanded)
800
+ return null;
801
+ for (const value of expanded)
802
+ values.add(value);
803
+ }
804
+ return [...values].sort((a, b) => a - b);
805
+ }
806
+ function expandCronNumberPart(part, min, max) {
807
+ if (!part)
808
+ return null;
809
+ const [rangePart, stepPart] = part.split("/");
810
+ if (part.split("/").length > 2)
811
+ return null;
812
+ const step = stepPart === undefined ? 1 : Number(stepPart);
813
+ if (!Number.isSafeInteger(step) || step < 1)
814
+ return null;
815
+ let start;
816
+ let end;
817
+ if (rangePart === "*") {
818
+ start = min;
819
+ end = max;
820
+ }
821
+ else if (rangePart?.includes("-")) {
822
+ const [rawStart, rawEnd] = rangePart.split("-");
823
+ start = Number(rawStart);
824
+ end = Number(rawEnd);
825
+ }
826
+ else {
827
+ start = Number(rangePart);
828
+ end = stepPart === undefined ? start : max;
829
+ }
830
+ if (!Number.isSafeInteger(start) ||
831
+ !Number.isSafeInteger(end) ||
832
+ start < min ||
833
+ end > max ||
834
+ start > end) {
835
+ return null;
836
+ }
837
+ const values = [];
838
+ for (let value = start; value <= end; value += step) {
839
+ values.push(value);
840
+ }
841
+ return values;
842
+ }
477
843
  async function commitInternal(client, planId, idempotencyKey) {
478
844
  try {
479
845
  return await client.request(`/deploy/v2/plans/${encodeURIComponent(planId)}/commit`, {
@@ -781,6 +1147,11 @@ async function pollSnapshotUntilReady(client, initial, diff, warnings, emit, pro
781
1147
  closePreviousPhase(undefined, "failed");
782
1148
  throw translateGatewayError(snapshot.error, snapshot.status, snapshot.plan_id, snapshot.operation_id);
783
1149
  }
1150
+ if (snapshot.status === "activation_pending" &&
1151
+ isTerminalStaticActivationError(snapshot.error)) {
1152
+ closePreviousPhase(undefined, "failed");
1153
+ throw translateGatewayError(snapshot.error, "activate", snapshot.plan_id, snapshot.operation_id);
1154
+ }
784
1155
  if (Date.now() - start > COMMIT_POLL_TIMEOUT_MS) {
785
1156
  throw new Run402DeployError(`Timed out waiting for operation ${snapshot.operation_id} to reach ready`, {
786
1157
  code: "INTERNAL_ERROR",
@@ -798,8 +1169,18 @@ async function pollSnapshotUntilReady(client, initial, diff, warnings, emit, pro
798
1169
  snapshot = await client.request(`/deploy/v2/operations/${encodeURIComponent(snapshot.operation_id)}`, { headers: opHeaders, context: "polling deploy operation" });
799
1170
  }
800
1171
  }
1172
+ function isTerminalStaticActivationError(error) {
1173
+ if (!error?.code)
1174
+ return false;
1175
+ if (error.retryable === false)
1176
+ return true;
1177
+ if (error.safe_to_retry === false)
1178
+ return true;
1179
+ return STATIC_ACTIVATION_FAILURE_CODES.has(error.code.toUpperCase());
1180
+ }
801
1181
  // ─── start() implementation ──────────────────────────────────────────────────
802
1182
  async function startInternal(client, spec, opts) {
1183
+ const allowWarningCodes = normalizeAllowWarningCodes(opts.allowWarningCodes);
803
1184
  const buffered = [];
804
1185
  const subscribers = [];
805
1186
  const emit = (event) => {
@@ -825,7 +1206,7 @@ async function startInternal(client, spec, opts) {
825
1206
  const { plan, byteReaders } = await planInternal(client, spec, opts.idempotencyKey);
826
1207
  emit({ type: "plan.diff", diff: plan.diff });
827
1208
  emitPlanWarnings(plan, emit);
828
- abortOnConfirmationWarnings(plan, opts);
1209
+ abortOnConfirmationWarnings(plan, opts, allowWarningCodes);
829
1210
  if (plan.payment_required) {
830
1211
  emit({
831
1212
  type: "payment.required",
@@ -937,6 +1318,7 @@ async function startInternal(client, spec, opts) {
937
1318
  };
938
1319
  }
939
1320
  const RELEASE_SPEC_FIELDS = new Set([
1321
+ "$schema",
940
1322
  "project",
941
1323
  "base",
942
1324
  "database",
@@ -976,7 +1358,7 @@ const SITE_PUBLIC_PATHS_FIELDS = new Set(["mode", "replace"]);
976
1358
  const PUBLIC_STATIC_PATH_FIELDS = new Set(["asset", "cache_class"]);
977
1359
  const SUBDOMAINS_SPEC_FIELDS = new Set(["set", "add", "remove"]);
978
1360
  const ROUTES_SPEC_FIELDS = new Set(["replace"]);
979
- const ROUTE_ENTRY_FIELDS = new Set(["pattern", "methods", "target"]);
1361
+ const ROUTE_ENTRY_FIELDS = new Set(["pattern", "methods", "target", "acknowledge_readonly"]);
980
1362
  const FUNCTION_ROUTE_TARGET_FIELDS = new Set(["type", "name"]);
981
1363
  const STATIC_ROUTE_TARGET_FIELDS = new Set(["type", "file"]);
982
1364
  const ROUTE_METHOD_SET = new Set(ROUTE_HTTP_METHODS);
@@ -1223,10 +1605,24 @@ function validateRouteEntry(route, resource) {
1223
1605
  }
1224
1606
  }
1225
1607
  const targetType = validateRouteTarget(entry.target, `${resource}.target`);
1608
+ validateRouteReadOnlyAcknowledgement(entry, targetType, resource);
1226
1609
  if (targetType === "static") {
1227
1610
  validateStaticRouteEntry(entry, resource);
1228
1611
  }
1229
1612
  }
1613
+ function validateRouteReadOnlyAcknowledgement(entry, targetType, resource) {
1614
+ if (entry.acknowledge_readonly === undefined)
1615
+ return;
1616
+ if (entry.acknowledge_readonly !== true) {
1617
+ throw invalidRouteSpec(`ReleaseSpec.${resource}.acknowledge_readonly must be true when present`, `${resource}.acknowledge_readonly`);
1618
+ }
1619
+ if (targetType !== "function" ||
1620
+ typeof entry.pattern !== "string" ||
1621
+ !isFinalWildcardRoutePattern(entry.pattern) ||
1622
+ !isReadOnlyRouteMethods(entry.methods)) {
1623
+ throw invalidRouteSpec(`ReleaseSpec.${resource}.acknowledge_readonly applies only to GET/HEAD final-wildcard function routes`, `${resource}.acknowledge_readonly`);
1624
+ }
1625
+ }
1230
1626
  function validateRouteTarget(target, resource) {
1231
1627
  const obj = requireObject(target, resource);
1232
1628
  if ((hasOwn(obj, "function") || hasOwn(obj, "static")) && !hasOwn(obj, "type")) {
@@ -1482,13 +1878,13 @@ function clientRoutePlanWarnings(spec) {
1482
1878
  .filter((route) => {
1483
1879
  if (route.target.type !== "function")
1484
1880
  return false;
1485
- if (!route.pattern.endsWith("/*"))
1881
+ if (!isFinalWildcardRoutePattern(route.pattern))
1486
1882
  return false;
1487
1883
  if (!route.methods)
1488
1884
  return false;
1489
- const methods = new Set(route.methods);
1490
- return (methods.size > 0 &&
1491
- [...methods].every((method) => method === "GET" || method === "HEAD"));
1885
+ if (route.acknowledge_readonly === true)
1886
+ return false;
1887
+ return isReadOnlyRouteMethods(route.methods);
1492
1888
  })
1493
1889
  .map((route) => route.pattern)
1494
1890
  .sort();
@@ -1509,21 +1905,65 @@ function clientRoutePlanWarnings(spec) {
1509
1905
  },
1510
1906
  ];
1511
1907
  }
1512
- function abortOnConfirmationWarnings(plan, opts) {
1908
+ function isFinalWildcardRoutePattern(pattern) {
1909
+ return pattern.endsWith("/*");
1910
+ }
1911
+ function isReadOnlyRouteMethods(methods) {
1912
+ if (!Array.isArray(methods) || methods.length === 0)
1913
+ return false;
1914
+ return methods.every((method) => method === "GET" || method === "HEAD");
1915
+ }
1916
+ function normalizeAllowWarningCodes(value) {
1917
+ if (value === undefined)
1918
+ return new Set();
1919
+ if (!Array.isArray(value)) {
1920
+ throw new Run402DeployError("ApplyOptions.allowWarningCodes must be an array of warning-code strings", {
1921
+ code: "INVALID_SPEC",
1922
+ phase: "validate",
1923
+ resource: "allowWarningCodes",
1924
+ retryable: false,
1925
+ context: "validating deploy warning options",
1926
+ });
1927
+ }
1928
+ const codes = new Set();
1929
+ for (const code of value) {
1930
+ if (typeof code !== "string" || code.length === 0) {
1931
+ throw new Run402DeployError("ApplyOptions.allowWarningCodes entries must be non-empty strings", {
1932
+ code: "INVALID_SPEC",
1933
+ phase: "validate",
1934
+ resource: "allowWarningCodes",
1935
+ retryable: false,
1936
+ context: "validating deploy warning options",
1937
+ });
1938
+ }
1939
+ codes.add(code);
1940
+ }
1941
+ return codes;
1942
+ }
1943
+ function abortOnConfirmationWarnings(plan, opts, allowWarningCodes) {
1513
1944
  if (opts.allowWarnings)
1514
1945
  return;
1515
1946
  const blocking = plan.warnings.filter((w) => w.requires_confirmation || w.code === "MISSING_REQUIRED_SECRET");
1516
1947
  if (blocking.length === 0)
1517
1948
  return;
1518
- const missing = blocking.find((w) => w.code === "MISSING_REQUIRED_SECRET");
1519
- const first = missing ?? blocking[0];
1520
- throw new Run402DeployError(`Deploy plan returned warning ${first.code} that requires confirmation; resolve it or retry with allowWarnings after explicit review.`, {
1949
+ const unacknowledged = blocking.filter((w) => !allowWarningCodes.has(w.code));
1950
+ if (unacknowledged.length === 0)
1951
+ return;
1952
+ const missing = unacknowledged.find((w) => w.code === "MISSING_REQUIRED_SECRET");
1953
+ const first = missing ?? unacknowledged[0];
1954
+ const unacknowledgedCodes = Array.from(new Set(unacknowledged.map((w) => w.code))).sort();
1955
+ throw new Run402DeployError(`Deploy plan returned unacknowledged warning ${first.code}; resolve it, retry with allowWarningCodes for reviewed warning codes, or retry with allowWarnings after explicit review.`, {
1521
1956
  code: first.code || "DEPLOY_WARNING_REQUIRES_CONFIRMATION",
1522
1957
  phase: "plan",
1523
1958
  resource: "warnings",
1524
1959
  retryable: false,
1525
1960
  fix: { action: "review_warnings", path: "warnings" },
1526
- body: { warnings: blocking },
1961
+ body: {
1962
+ warnings: blocking,
1963
+ unacknowledged_warnings: unacknowledged,
1964
+ unacknowledged_warning_codes: unacknowledgedCodes,
1965
+ allowed_warning_codes: Array.from(allowWarningCodes).sort(),
1966
+ },
1527
1967
  context: "planning deploy",
1528
1968
  });
1529
1969
  }