@superblocksteam/sdk 2.0.151 → 2.0.152-next.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.
Files changed (84) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/cli-replacement/automatic-upgrades.d.ts.map +1 -1
  3. package/dist/cli-replacement/automatic-upgrades.js +8 -6
  4. package/dist/cli-replacement/automatic-upgrades.js.map +1 -1
  5. package/dist/cli-replacement/dev-s3-restore.test.mjs +1 -0
  6. package/dist/cli-replacement/dev-s3-restore.test.mjs.map +1 -1
  7. package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs +1 -0
  8. package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs.map +1 -1
  9. package/dist/cli-replacement/dev.d.mts +148 -1
  10. package/dist/cli-replacement/dev.d.mts.map +1 -1
  11. package/dist/cli-replacement/dev.interception.test.mjs +259 -4
  12. package/dist/cli-replacement/dev.interception.test.mjs.map +1 -1
  13. package/dist/cli-replacement/dev.mjs +429 -28
  14. package/dist/cli-replacement/dev.mjs.map +1 -1
  15. package/dist/client.billing-usage.test.js +25 -0
  16. package/dist/client.billing-usage.test.js.map +1 -1
  17. package/dist/client.d.ts +19 -0
  18. package/dist/client.d.ts.map +1 -1
  19. package/dist/client.js.map +1 -1
  20. package/dist/collect-sdk-apis.d.mts +61 -3
  21. package/dist/collect-sdk-apis.d.mts.map +1 -1
  22. package/dist/collect-sdk-apis.mjs +97 -6
  23. package/dist/collect-sdk-apis.mjs.map +1 -1
  24. package/dist/collect-sdk-apis.test.mjs +277 -1
  25. package/dist/collect-sdk-apis.test.mjs.map +1 -1
  26. package/dist/dev-utils/dev-server-metrics.d.mts +17 -0
  27. package/dist/dev-utils/dev-server-metrics.d.mts.map +1 -1
  28. package/dist/dev-utils/dev-server-metrics.dependency-failure.test.d.mts +2 -0
  29. package/dist/dev-utils/dev-server-metrics.dependency-failure.test.d.mts.map +1 -0
  30. package/dist/dev-utils/dev-server-metrics.dependency-failure.test.mjs +108 -0
  31. package/dist/dev-utils/dev-server-metrics.dependency-failure.test.mjs.map +1 -0
  32. package/dist/dev-utils/dev-server-metrics.mjs +16 -2
  33. package/dist/dev-utils/dev-server-metrics.mjs.map +1 -1
  34. package/dist/dev-utils/dev-server.d.mts +9 -0
  35. package/dist/dev-utils/dev-server.d.mts.map +1 -1
  36. package/dist/dev-utils/dev-server.mjs +44 -27
  37. package/dist/dev-utils/dev-server.mjs.map +1 -1
  38. package/dist/dev-utils/dev-server.status.test.mjs +31 -1
  39. package/dist/dev-utils/dev-server.status.test.mjs.map +1 -1
  40. package/dist/dev-utils/fatal-exit.d.mts +17 -4
  41. package/dist/dev-utils/fatal-exit.d.mts.map +1 -1
  42. package/dist/dev-utils/fatal-exit.mjs +4 -3
  43. package/dist/dev-utils/fatal-exit.mjs.map +1 -1
  44. package/dist/extract-api-integrations.d.mts +1 -1
  45. package/dist/extract-api-integrations.d.mts.map +1 -1
  46. package/dist/extract-api-integrations.mjs +1 -1
  47. package/dist/extract-api-integrations.mjs.map +1 -1
  48. package/dist/flag.d.ts +5 -0
  49. package/dist/flag.d.ts.map +1 -1
  50. package/dist/flag.js +7 -0
  51. package/dist/flag.js.map +1 -1
  52. package/dist/flag.test.js +18 -0
  53. package/dist/flag.test.js.map +1 -1
  54. package/dist/index.d.ts +1 -1
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js.map +1 -1
  57. package/dist/types/common.d.ts +1 -0
  58. package/dist/types/common.d.ts.map +1 -1
  59. package/dist/types/common.js.map +1 -1
  60. package/dist/vite-plugin-generate-api-build-manifest.d.mts.map +1 -1
  61. package/dist/vite-plugin-generate-api-build-manifest.mjs +46 -0
  62. package/dist/vite-plugin-generate-api-build-manifest.mjs.map +1 -1
  63. package/package.json +6 -6
  64. package/src/cli-replacement/automatic-upgrades.ts +8 -6
  65. package/src/cli-replacement/dev-s3-restore.test.mts +1 -0
  66. package/src/cli-replacement/dev-startup-git-before-dbfs-order.test.mts +1 -0
  67. package/src/cli-replacement/dev.interception.test.mts +334 -10
  68. package/src/cli-replacement/dev.mts +499 -33
  69. package/src/client.billing-usage.test.ts +26 -0
  70. package/src/client.ts +21 -0
  71. package/src/collect-sdk-apis.mts +141 -5
  72. package/src/collect-sdk-apis.test.mts +380 -1
  73. package/src/dev-utils/dev-server-metrics.dependency-failure.test.mts +149 -0
  74. package/src/dev-utils/dev-server-metrics.mts +25 -1
  75. package/src/dev-utils/dev-server.mts +52 -29
  76. package/src/dev-utils/dev-server.status.test.mts +42 -0
  77. package/src/dev-utils/fatal-exit.mts +18 -4
  78. package/src/extract-api-integrations.mts +3 -0
  79. package/src/flag.test.ts +21 -0
  80. package/src/flag.ts +8 -0
  81. package/src/index.ts +1 -0
  82. package/src/types/common.ts +1 -0
  83. package/src/vite-plugin-generate-api-build-manifest.mts +74 -4
  84. package/tsconfig.tsbuildinfo +1 -1
@@ -19,6 +19,7 @@ import { AutoConnectingRpcClient } from "@superblocksteam/vite-plugin-file-sync/
19
19
  import { SyncService } from "@superblocksteam/vite-plugin-file-sync/sync-service";
20
20
  import { devServerMetrics } from "../dev-utils/dev-server-metrics.mjs";
21
21
  import { createDevServer } from "../dev-utils/dev-server.mjs";
22
+ import { buildFatalExitLog, FATAL_EXIT_EVENT, parseIsWarm, } from "../dev-utils/fatal-exit.mjs";
22
23
  import { AUTO_UPGRADE_EXIT_CODE } from "../index.js";
23
24
  import { getTracer, isCloudPrem, shutdownTelemetry, } from "../telemetry/index.js";
24
25
  import { getErrorMeta, getLogger } from "../telemetry/logging.js";
@@ -695,12 +696,321 @@ export function primeTokenManagerWithInitialToken(tokenManager, token) {
695
696
  tokenManager.updateToken(token);
696
697
  }
697
698
  }
699
+ /**
700
+ * True when this dev server was launched as a CSB sandbox — `SUPERBLOCKS_IS_CSB
701
+ * === "true"`, the same variable that already gates the CSB-only behavior
702
+ * elsewhere in this file. That is two populations, not one:
703
+ *
704
+ * - a disposable, orchestrator-managed SABS live-edit pod, and
705
+ * - the LOCAL `csb-mock-server` emulator, which sets the identical variable
706
+ * when it spawns `superblocks dev` for code-mode work
707
+ * (`packages/csb-mock-server/src/app-manager.ts`, `startProcess` and
708
+ * `restartSandbox`; neither passes anything that skips the auto-upgrade).
709
+ *
710
+ * So this is NOT "cloud, not a developer's machine". The emulator answering
711
+ * `true` is intended — it exists to emulate a pod, and an exit is the behavior a
712
+ * pod would show — but it emulates only half the story: `attachEventListeners`
713
+ * in that file merely logs the child's exit, so nothing restarts it. The
714
+ * practical consequence for a local dev whose auto-upgrade fails this way is a
715
+ * dead dev server until something calls `startProcess` again (reopening or
716
+ * restarting the app in code mode). Only a dev server the developer ran
717
+ * directly, with no `SUPERBLOCKS_IS_CSB`, still degrades.
718
+ *
719
+ * Read at call time, not module load, so tests can set the variable per-case.
720
+ */
721
+ export function isDisposableSandbox() {
722
+ return process.env.SUPERBLOCKS_IS_CSB === "true";
723
+ }
724
+ /**
725
+ * The single implementation of "flush telemetry, but never wait forever".
726
+ *
727
+ * Two paths need it, for the same reason: the OTel metric reader exports on a
728
+ * 10s interval, so a sample recorded moments before the process goes away dies
729
+ * with it unless something forces the export — and neither path may block
730
+ * indefinitely on a slow or unreachable collector. The callers are the
731
+ * fatal-exit sequence (`flushTelemetryBeforeExit`, just below) and the
732
+ * auto-upgrade CLI restart in `dev()`, which used to carry its own copy of this
733
+ * race with the timer-clear missing.
734
+ *
735
+ * Resolves `true` only when a flush actually ran to completion — `false` on an
736
+ * internal shutdown error, when telemetry was never initialized, or when the
737
+ * timeout won the race.
738
+ *
739
+ * Reporting that boolean is left to the callers rather than parameterized here,
740
+ * because the useful part of the message is what an operator should read
741
+ * INSTEAD of the dropped samples, and that differs per path: the fatal paths
742
+ * point at their `event=dev_server_fatal_exit` line, which the restart path
743
+ * never writes.
744
+ *
745
+ * Rejects only if `shutdownTelemetry()` breaks its documented never-reject
746
+ * contract, so callers keep their own defensive catch.
747
+ */
748
+ export async function flushTelemetryWithTimeout() {
749
+ let flushed = false;
750
+ let timer;
751
+ try {
752
+ await Promise.race([
753
+ shutdownTelemetry().then((didFlush) => {
754
+ flushed = didFlush;
755
+ }),
756
+ new Promise((resolve) => {
757
+ timer = setTimeout(resolve, TELEMETRY_FLUSH_TIMEOUT_MS);
758
+ }),
759
+ ]);
760
+ }
761
+ finally {
762
+ // The race is settled; leaving the timer armed would hold the event loop
763
+ // open for callers (tests, in particular) that do not immediately exit.
764
+ if (timer)
765
+ clearTimeout(timer);
766
+ }
767
+ return flushed;
768
+ }
769
+ /**
770
+ * Force a telemetry export before a fatal exit.
771
+ *
772
+ * The OTel metric reader exports on a 10s interval, so anything recorded
773
+ * moments before `process.exit(1)` dies with the process — the same drop
774
+ * already documented for the auto-upgrade restart path below. Any metric whose
775
+ * entire purpose is to make an exit observable has to be flushed here or it is
776
+ * never seen. The raced flush itself lives in `flushTelemetryWithTimeout`,
777
+ * shared with that restart path; this wrapper only adds the fatal-exit
778
+ * reporting.
779
+ *
780
+ * MUST BE CALLED LAST in any shutdown sequence. This does not merely flush: it
781
+ * awaits `shutdownTelemetry()`, which tears the provider down, so every signal
782
+ * emitted after it goes into a dead pipeline. It can also block for up to
783
+ * `TELEMETRY_FLUSH_TIMEOUT_MS`, and delaying `lockService.shutdown()` by that
784
+ * much delays the lock release that lets a replacement sandbox take over —
785
+ * which is the entire recovery story this exit path exists to enable.
786
+ *
787
+ * "Last" is only reachable because the callers drive the lock service with
788
+ * `shutdown()` rather than `shutdownAndExit()`. The latter ends in an
789
+ * unconditional `finally { process.exit(1) }`, and `process.exit` is
790
+ * synchronous, so anything sequenced after it never runs at all. See the two
791
+ * fatal-exit blocks in `dev()`.
792
+ *
793
+ * Never throws: a stuck exporter is raced against a timeout so it cannot wedge
794
+ * the exit path.
795
+ *
796
+ * `shutdownTelemetry()` resolves `true` only when a flush actually ran to
797
+ * completion — `false` on an internal shutdown error or when telemetry was
798
+ * never initialized — and the timeout branch leaves the flag `false`. Log that
799
+ * distinction rather than discarding it: when it is `false` the `outcome="exit"`
800
+ * sample did NOT make it out, and the only surviving evidence of the exit is the
801
+ * synchronous `event=dev_server_fatal_exit` line the caller already wrote. An
802
+ * operator who cannot tell those apart reads "zero exit samples" as "the exit
803
+ * path never ran". The auto-upgrade restart path reports the same distinction,
804
+ * against its own log line.
805
+ */
806
+ async function flushTelemetryBeforeExit(logger) {
807
+ let flushed = false;
808
+ try {
809
+ flushed = await flushTelemetryWithTimeout();
810
+ }
811
+ catch (e) {
812
+ // Defensive only: `shutdownTelemetry()` catches internally and resolves
813
+ // `false` rather than rejecting. Kept so a future change to that contract
814
+ // cannot turn this into an unhandled rejection on the exit path.
815
+ logger.warn(`[dev-server] telemetry flush before exit failed: ${e}`);
816
+ }
817
+ if (!flushed) {
818
+ logger.warn(`[dev-server] telemetry flush did not complete (timed out after ${TELEMETRY_FLUSH_TIMEOUT_MS}ms, errored, or telemetry disabled) before fatal exit; ` +
819
+ `dependency-failure samples may be dropped — use the event=${FATAL_EXIT_EVENT} log line as the source of truth for this exit`);
820
+ }
821
+ }
822
+ /**
823
+ * Upper bound on any single teardown step run before a fatal exit.
824
+ *
825
+ * Each step talks to something that can stop answering (the ai-service over
826
+ * IPC, the lock service over the network). Without a bound, one unsettled
827
+ * promise means the `finally { process.exit(1) }` below it never runs and the
828
+ * pod stays Ready but zombied — the exact ENG-5297 symptom this exit path
829
+ * exists to end, one call earlier. The whole point of these paths is that the
830
+ * process goes away, so a step that has not answered in this long is abandoned
831
+ * rather than waited on.
832
+ */
833
+ const FATAL_EXIT_STEP_TIMEOUT_MS = 5000;
834
+ /**
835
+ * Run one best-effort teardown step on a fatal-exit path.
836
+ *
837
+ * Neither a throw nor a hang may stop the later steps: the lock release is what
838
+ * lets a replacement sandbox take over, and the telemetry flush is what makes
839
+ * the exit observable, so losing them to a failure in an earlier, less
840
+ * important step is strictly worse than the failure itself.
841
+ */
842
+ async function runFatalExitStep(logger, name, step) {
843
+ if (!step) {
844
+ return;
845
+ }
846
+ let timer;
847
+ try {
848
+ await Promise.race([
849
+ // A synchronous throw from `step()` propagates out of the array literal
850
+ // before the race is constructed, and is caught by the same `catch`.
851
+ // `undefined` (the service was never constructed) is a non-thenable, so
852
+ // the race settles immediately and the step is a no-op.
853
+ step(),
854
+ new Promise((resolve) => {
855
+ timer = setTimeout(() => {
856
+ logger.error(`[dev-server] ${name} did not settle within ${FATAL_EXIT_STEP_TIMEOUT_MS}ms before fatal exit; abandoning it and continuing`);
857
+ resolve();
858
+ }, FATAL_EXIT_STEP_TIMEOUT_MS);
859
+ }),
860
+ ]);
861
+ }
862
+ catch (e) {
863
+ logger.error(`[dev-server] ${name} failed before fatal exit; continuing`, getErrorMeta(e));
864
+ }
865
+ finally {
866
+ if (timer)
867
+ clearTimeout(timer);
868
+ }
869
+ }
870
+ /**
871
+ * The ordered teardown every fatal-exit path in `dev()` runs immediately before
872
+ * `process.exit(1)`. Stated once, here, so the ordering guarantee cannot drift
873
+ * between the two call sites.
874
+ *
875
+ * Order is load-bearing:
876
+ * 1. `removeIntegrationCache` — ai-service state that must not outlive us.
877
+ * 2. `shutdownLockService` — releases the AppBranchLock. Recovery-critical:
878
+ * the heartbeat dies with the process, so skipping this makes the
879
+ * replacement sandbox's `acquireLock()` fail with a ConflictError until
880
+ * the server-side TTL expires, and the replacement is the entire point of
881
+ * exiting.
882
+ * 3. `flushTelemetryBeforeExit` — LAST, because it tears the telemetry
883
+ * provider down (anything after it is unobservable) and can block for
884
+ * `TELEMETRY_FLUSH_TIMEOUT_MS`, which would otherwise delay the lock
885
+ * release above.
886
+ *
887
+ * Every step is individually guarded and individually bounded, so neither a
888
+ * throw nor a hang in an earlier step can cost a later one. Never throws.
889
+ *
890
+ * This does NOT exit. The caller owns `process.exit(1)` from a `finally`, so
891
+ * the exit still happens even if this function is somehow bypassed. That is
892
+ * also why callers must drive the lock service through `shutdown()` and not
893
+ * `shutdownAndExit()`: the latter ends in its own unconditional
894
+ * `finally { process.exit(1) }`, and `process.exit` is synchronous, so
895
+ * everything sequenced after it — including the flush — silently never ran.
896
+ */
897
+ export async function runFatalExitShutdown(steps) {
898
+ const { logger } = steps;
899
+ await runFatalExitStep(logger, "ai-service integration cache removal", steps.removeIntegrationCache);
900
+ await runFatalExitStep(logger, "lock service shutdown", steps.shutdownLockService);
901
+ await flushTelemetryBeforeExit(logger);
902
+ }
903
+ /**
904
+ * Upgrade-failure categories that are decided BEFORE npm can extract anything.
905
+ *
906
+ * npm builds the whole ideal tree from registry metadata before it writes a
907
+ * single byte into `node_modules`, so a run that died because the registry
908
+ * refused, could not be reached, could not be TLS-verified, or did not have the
909
+ * requested version never reached the extraction phase — the on-disk tree and
910
+ * the running esbuild service it feeds are provably untouched.
911
+ *
912
+ * These are also precisely the shape a registry/proxy outage takes. Treating
913
+ * them as fatal would exit every live-edit pod in the fleet simultaneously and
914
+ * hand SABS a queue of replacements that hit the same outage, converting a
915
+ * degraded-but-serving fleet into an unavailable one.
916
+ *
917
+ * Typed against `DependencyUpgradeError["category"]` so a future category added
918
+ * to that union has to be classified deliberately rather than drifting in.
919
+ *
920
+ * The union has exactly five members today, so holding the other four here
921
+ * makes `upgradeCategoryCouldTaintTree` presently equivalent to
922
+ * `category === "unknown"`. The set shape is kept anyway because the two forms
923
+ * diverge the moment a sixth category is added: an allowlist of known-safe
924
+ * categories fails CLOSED (a new, unclassified category exits, which is the
925
+ * conservative side), whereas `=== "unknown"` fails OPEN (a new category
926
+ * silently degrades, which is precisely the ENG-5297 zombie-pod bug). Naming
927
+ * the four safe categories also forces whoever adds the sixth to decide which
928
+ * side it belongs on.
929
+ *
930
+ * Residual risk, stated plainly — and this is the known hole in the gate, not a
931
+ * theoretical one. These categories are derived from npm/pnpm's error text,
932
+ * which carries no notion of install PHASE, so a later-phase failure can wear an
933
+ * early-phase label: `ERR_PNPM_FETCH_<5xx>` is pnpm's TARBALL fetch error yet
934
+ * maps to `registry_unreachable`, and `detectReasonFromText` matches
935
+ * ECONNRESET/ETIMEDOUT/ENOTFOUND anywhere in stderr. A CDN 502 or a mid-download
936
+ * reset therefore degrades even though extraction had begun. npm rolls the reify
937
+ * back, but rollback restores FILES only — it does not restart the esbuild
938
+ * service child process that died when its binary was replaced, which is the
939
+ * actual harm chain in ENG-5297. So this gate does not close that case.
940
+ *
941
+ * The trade is still taken knowingly: closing it properly means stamping the
942
+ * phase where it is actually known (a `treePossiblyMutated` flag set at the
943
+ * throw site in `upgradeCliWithPackageManager`) or probing esbuild/Vite health
944
+ * after an `UpgradeFailed`, both of which change the shared
945
+ * `DependencyUpgradeError` contract and belong in their own change. Meanwhile
946
+ * this gate is strictly better than the previous behavior, which degraded on
947
+ * every upgrade failure without exception, and exiting on these categories
948
+ * instead would amplify a registry outage into a fleet-wide restart.
949
+ */
950
+ const RESOLUTION_PHASE_UPGRADE_CATEGORIES = new Set([
951
+ "not_in_registry",
952
+ "registry_auth_failed",
953
+ "registry_unreachable",
954
+ "tls_failed",
955
+ ]);
956
+ /**
957
+ * True when a failed auto-upgrade could have mutated the live dependency tree,
958
+ * and therefore may have killed the running esbuild service out from under
959
+ * Vite. Unknown/unclassified categories deliberately answer `true`: ENG-5297
960
+ * arrived as `category: "unknown"`, and a category this function has never seen
961
+ * must fail closed (exit) rather than inherit the degrade path by default.
962
+ *
963
+ * This reduces to `category === "unknown"` against today's five-member union —
964
+ * see `RESOLUTION_PHASE_UPGRADE_CATEGORIES` for why the set is kept rather than
965
+ * collapsed to that comparison.
966
+ *
967
+ * The parameter is `string`, not `DependencyUpgradeError["category"]`, because
968
+ * that is the actual runtime contract: the payload is deserialized from the
969
+ * socket, so the five-member union is a promise about our own writers rather
970
+ * than a guarantee about the value — which is also why the set above is a
971
+ * `ReadonlySet<string>`. It is also what lets the fail-closed default be
972
+ * asserted for a category a FUTURE CLI adds; that case is not expressible
973
+ * through an `UpgradeFailed` marker without an `as` cast, and it is the one this
974
+ * gate most needs to keep.
975
+ *
976
+ * `"unknown"` is itself coarse; the two sub-cases sit on opposite sides of the
977
+ * question this function asks, and only one of them is a false positive:
978
+ *
979
+ * no install command `resolveCommand` returned nothing, so `child_process.exec`
980
+ * was never reached (automatic-upgrades.ts, the
981
+ * `if (!installCommand)` early throw). The tree is
982
+ * provably untouched and exiting is a false positive.
983
+ * version validation `getCurrentCliVersion()` returned nothing / mismatched.
984
+ * That check runs AFTER `await promise` resolves — i.e.
985
+ * after the global install completed — so the tree
986
+ * genuinely moved and exiting is correct.
987
+ *
988
+ * Only the first is a false positive, and it is not distinguishable from data
989
+ * already on `serverError`: both arrive as `category: "unknown"` with no
990
+ * `npmErrorCode`, and the only thing separating them is free-text English in
991
+ * `rawError`. Keying the exit decision on that string would be the same
992
+ * phase-blind text proxy this file already flags as the gate's weak point.
993
+ * Splitting them properly means a flag stamped at the throw site on
994
+ * `DependencyUpgradeError` — a change to a shared contract, deliberately not
995
+ * made here. Until then both land on exit, the direction this function is meant
996
+ * to err in.
997
+ *
998
+ * Note on cost if the false positive fires: SABS pods run `RestartPolicy: Never`,
999
+ * so the retry is a pod REPLACEMENT driven by the orchestrator, not an in-pod
1000
+ * crash loop. A deterministic, environment-derived cause would therefore
1001
+ * re-trigger in each replacement, and no per-process bound inside this file
1002
+ * could stop that — a replacement is a fresh process with a fresh counter. Any
1003
+ * real bound belongs in the orchestrator that decides to replace.
1004
+ */
1005
+ export function upgradeCategoryCouldTaintTree(category) {
1006
+ return !RESOLUTION_PHASE_UPGRADE_CATEGORIES.has(category);
1007
+ }
698
1008
  /** Decide how the startup catch handles an error: degrade (record, keep Vite up)
699
1009
  * for an app-install failure (`InitialInstallFailed`) or a best-effort
700
1010
  * CLI/library auto-upgrade failure (`UpgradeFailed`), or exit for anything else
701
1011
  * (lock/sync/genuinely-unusable upgrade env). Pure + unit-tested. Does NOT call
702
1012
  * process.exit. */
703
- export function handleStartupError(error, status, logger) {
1013
+ export function handleStartupError(error, status, logger, options) {
704
1014
  if (error instanceof InitialInstallFailed) {
705
1015
  status.serverErrors.push(error.serverError);
706
1016
  logger.error("[dev-server] initial dependency install failed; keeping dev server up", getErrorMeta(error));
@@ -712,17 +1022,56 @@ export function handleStartupError(error, status, logger) {
712
1022
  return "degrade";
713
1023
  }
714
1024
  if (error instanceof UpgradeFailed) {
715
- // The auto-upgrade is best-effort and the pod is still running the current
716
- // version, so a failed upgrade degrades instead of crash-looping. Recording
717
- // here also makes the failure observable: the process survives to the next
718
- // metrics export tick, which the pre-exit `process.exit(1)` always lost.
719
1025
  status.serverErrors.push(error.serverError);
720
- logger.error("[dev-server] dependency upgrade failed; keeping the current version", getErrorMeta(error));
1026
+ // The upgrade is an in-place `npm install` into the LIVE node_modules, and
1027
+ // its install manifest includes esbuild. A failure part-way through can
1028
+ // therefore have already killed the running esbuild service ("The service
1029
+ // was stopped"), which rejects vitePromise and leaves a dev server that can
1030
+ // never serve a request again. "Keeping the current version" is only true
1031
+ // when the tree survived, and we cannot cheaply prove that it did.
1032
+ //
1033
+ // So the decision turns on who owns the process AND on whether the failure
1034
+ // could have mutated the tree at all:
1035
+ // - disposable sandbox (SABS pod) + a failure that could have touched
1036
+ // `node_modules`: exit. The pod is cheap and SABS already reaps
1037
+ // terminal pods (they run RestartPolicy: Never), so exiting yields a
1038
+ // clean replacement. Degrading instead produced ENG-5297 — 85 minutes
1039
+ // of a Ready pod answering 500s.
1040
+ // - disposable sandbox + a resolution-phase failure: degrade. npm never
1041
+ // got as far as extraction, so the tree is intact and there is nothing
1042
+ // for a replacement pod to fix — see
1043
+ // `upgradeCategoryCouldTaintTree`, which also explains why exiting on
1044
+ // these would amplify a registry outage into a fleet-wide restart.
1045
+ // - anything not flagged as a CSB sandbox: degrade. Killing a dev server
1046
+ // the developer started themselves over a best-effort upgrade is worse
1047
+ // than running the older version. Note the carve-out is narrower than
1048
+ // "not in the cloud": the local `csb-mock-server` emulator also sets
1049
+ // `SUPERBLOCKS_IS_CSB=true`, so local code-mode dev servers exit here
1050
+ // too. That is intended (the emulator exists to behave like a pod), but
1051
+ // nothing in the emulator restarts the child — see
1052
+ // `isDisposableSandbox`.
1053
+ const outcome = options?.disposableSandbox &&
1054
+ upgradeCategoryCouldTaintTree(error.serverError.category)
1055
+ ? "exit"
1056
+ : "degrade";
721
1057
  devServerMetrics.recordUpgradeFailure({
722
1058
  category: error.serverError.category,
723
1059
  npmErrorCode: error.serverError.npmErrorCode,
724
1060
  hasAnyRegistryConfigured: error.serverError.hasAnyRegistryConfigured,
1061
+ outcome,
725
1062
  });
1063
+ if (outcome === "exit") {
1064
+ logger.error("[dev-server] dependency upgrade failed in a disposable sandbox; exiting so the sandbox is replaced (the live dependency tree may be tainted)", getErrorMeta(error));
1065
+ // No `devServerMetrics.flush()` here: `record()` and `flush()` gate on
1066
+ // the same `isTelemetryInitialized()` check and nothing async runs
1067
+ // between them, so if the sample above was buffered, flush would bail on
1068
+ // the identical condition — and if it was not buffered, there is nothing
1069
+ // to drain. The caller's `flushTelemetryBeforeExit` (which awaits
1070
+ // `shutdownTelemetry()`) is what actually forces the export before
1071
+ // `process.exit(1)`.
1072
+ return "exit";
1073
+ }
1074
+ logger.error("[dev-server] dependency upgrade failed; keeping the current version", getErrorMeta(error));
726
1075
  return "degrade";
727
1076
  }
728
1077
  return "exit";
@@ -880,6 +1229,7 @@ export async function dev(options) {
880
1229
  superblocksBaseUrl: tokenConfig.superblocksBaseUrl,
881
1230
  rpcClient,
882
1231
  logger,
1232
+ removeManualCommitsEnabled: featureFlags.removeManualCommitsEnabled(),
883
1233
  });
884
1234
  logger.info("Checking if local files are synced with the server");
885
1235
  try {
@@ -1410,20 +1760,18 @@ export async function dev(options) {
1410
1760
  // which is why that runner reports zero samples in every env
1411
1761
  // despite real upgrades emitting it (spans survive because they
1412
1762
  // flush on the faster batch processor; the metric does not).
1413
- // shutdownTelemetry() never rejects; the catch is purely
1414
- // defensive. It resolves to `true` only when a flush actually
1415
- // ran `false` on internal shutdown error or when telemetry was
1416
- // disabled and the timeout branch leaves the flag false. So
1417
- // the log below reflects whether samples were really flushed, not
1418
- // merely that the race settled.
1763
+ // The timeout-raced flush is shared with the fatal-exit path
1764
+ // (`flushTelemetryWithTimeout`) this site used to carry its own
1765
+ // copy of the race, and that copy leaked its timer.
1766
+ // `flushTelemetryWithTimeout` resolves `true` only when a flush
1767
+ // actually ran `false` on internal shutdown error, when
1768
+ // telemetry was disabled, or when the timeout won — so the log
1769
+ // below reflects whether samples were really flushed, not merely
1770
+ // that the race settled. It rejects only if shutdownTelemetry()
1771
+ // breaks its never-reject contract, so the catch stays defensive.
1419
1772
  let telemetryFlushed = false;
1420
1773
  try {
1421
- await Promise.race([
1422
- shutdownTelemetry().then((flushed) => {
1423
- telemetryFlushed = flushed;
1424
- }),
1425
- new Promise((resolve) => setTimeout(resolve, TELEMETRY_FLUSH_TIMEOUT_MS)),
1426
- ]);
1774
+ telemetryFlushed = await flushTelemetryWithTimeout();
1427
1775
  }
1428
1776
  catch (e) {
1429
1777
  logger.error("Error flushing telemetry before CLI restart", getErrorMeta(e));
@@ -1441,18 +1789,54 @@ export async function dev(options) {
1441
1789
  });
1442
1790
  }
1443
1791
  catch (error) {
1444
- if (handleStartupError(error, devServerStatus, logger) === "degrade") {
1792
+ if (handleStartupError(error, devServerStatus, logger, {
1793
+ disposableSandbox: isDisposableSandbox(),
1794
+ }) === "degrade") {
1445
1795
  // app-install failure: do NOT exit — fall through to Vite startup below.
1446
1796
  // upload + CLI-restart were already skipped (the rejecting join threw first).
1447
1797
  }
1448
1798
  else {
1449
- logger.error("[dev-server] Startup failed during sync/lock/setup (exiting with code 1)", getErrorMeta(error));
1799
+ logger.error(
1800
+ // Names the stage, because `buildFatalExitLog` below carries only
1801
+ // the generic `handler=startupFailure`. Two stages reach here now:
1802
+ // sync/lock/setup, and (since ENG-5297) a dependency upgrade that
1803
+ // failed in a CSB sandbox and may have tainted the live tree.
1804
+ "[dev-server] Startup failed during sync/lock/setup, or a dependency upgrade failed in a CSB sandbox (exiting with code 1)", getErrorMeta(error));
1805
+ // Emitted BEFORE any teardown, and as a synchronous log line rather
1806
+ // than a counter, per the convention documented in
1807
+ // dev-utils/fatal-exit.mts: at exit time stdout is the only sink we
1808
+ // can rely on, so the reason has to land even if the flush below
1809
+ // never completes. The `outcome="exit"` metric is a complement to
1810
+ // this line, not a substitute for it.
1811
+ logger.error(buildFatalExitLog({
1812
+ handler: "startupFailure",
1813
+ exitCode: 1,
1814
+ isWarm: parseIsWarm(process.env.SUPERBLOCKS_WARM_STANDBY),
1815
+ }));
1450
1816
  try {
1451
- await aiService?.removeIntegrationCache();
1452
- await lockService?.shutdownAndExit();
1817
+ // `shutdown()`, NOT `shutdownAndExit()`. The latter ends in an
1818
+ // unconditional `finally { process.exit(1) }`; `process.exit` is
1819
+ // synchronous, so every statement below it would be dead code
1820
+ // whenever `lockService` is set — which is the normal state in a
1821
+ // SABS pod, exactly the population this exit path targets. The
1822
+ // telemetry flush was silently never running. `shutdown()` does
1823
+ // the same teardown without owning the exit, matching how
1824
+ // `runGracefulShutdown` in dev-server.mts drives it; the
1825
+ // `finally` below performs the exit instead.
1826
+ //
1827
+ // Ordering, per-step error handling, and per-step timeouts all
1828
+ // live in `runFatalExitShutdown` so the two fatal paths cannot
1829
+ // drift apart.
1830
+ await runFatalExitShutdown({
1831
+ logger,
1832
+ removeIntegrationCache: () => aiService?.removeIntegrationCache(),
1833
+ shutdownLockService: () => lockService?.shutdown({ serverInitiated: false }),
1834
+ });
1453
1835
  }
1454
1836
  finally {
1455
- // this is redundant, but it's here to make sure the lock service is shutdown and the process exits
1837
+ // The only exit on this path now that the lock service no longer
1838
+ // owns one. `runFatalExitShutdown` never throws and bounds every
1839
+ // step, so this runs regardless of what teardown did.
1456
1840
  process.exit(1);
1457
1841
  }
1458
1842
  }
@@ -1485,15 +1869,32 @@ export async function dev(options) {
1485
1869
  await joinUpgradeThenInstall("before Vite startup");
1486
1870
  }
1487
1871
  catch (error) {
1488
- if (handleStartupError(error, devServerStatus, logger) === "exit") {
1872
+ if (handleStartupError(error, devServerStatus, logger, {
1873
+ disposableSandbox: isDisposableSandbox(),
1874
+ }) === "exit") {
1489
1875
  logger.error("[dev-server] Startup failed during pre-Vite install join (exiting with code 1)", getErrorMeta(error));
1876
+ // Same convention as the sync/lock catch above: a synchronous
1877
+ // `event=dev_server_fatal_exit` line, written before teardown, is the
1878
+ // thing oncall queries for this exit class.
1879
+ logger.error(buildFatalExitLog({
1880
+ handler: "startupFailure",
1881
+ exitCode: 1,
1882
+ isWarm: parseIsWarm(process.env.SUPERBLOCKS_WARM_STANDBY),
1883
+ }));
1490
1884
  try {
1491
- await aiService?.removeIntegrationCache();
1492
- await lockService?.shutdownAndExit();
1885
+ // `shutdown()`, NOT `shutdownAndExit()` — same reason as the
1886
+ // sync/lock catch above: `shutdownAndExit()` exits the process
1887
+ // synchronously in its own `finally`, which made everything
1888
+ // sequenced after it unreachable.
1889
+ await runFatalExitShutdown({
1890
+ logger,
1891
+ removeIntegrationCache: () => aiService?.removeIntegrationCache(),
1892
+ shutdownLockService: () => lockService?.shutdown({ serverInitiated: false }),
1893
+ });
1493
1894
  }
1494
1895
  finally {
1495
- // Redundant with `shutdownAndExit`; here so a thrown shutdown
1496
- // path can't leave the process hanging on a stuck handle.
1896
+ // The only exit on this path; here so a thrown shutdown path can't
1897
+ // leave the process hanging on a stuck handle.
1497
1898
  process.exit(1);
1498
1899
  }
1499
1900
  }