@superblocksteam/sdk 2.0.151 → 2.0.152-next.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.
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
@@ -11,7 +11,10 @@ import fs from "fs-extra";
11
11
  import { resolveCommand } from "package-manager-detector";
12
12
  import { detect } from "package-manager-detector/detect";
13
13
 
14
- import type { ServerError } from "@superblocksteam/library-shared/types";
14
+ import type {
15
+ DependencyUpgradeError,
16
+ ServerError,
17
+ } from "@superblocksteam/library-shared/types";
15
18
  import {
16
19
  buildGithubSuperblocksSyncWorkflow,
17
20
  buildGithubSuperblocksSyncWorkflowFromBaseUrl,
@@ -50,6 +53,11 @@ import { SyncService } from "@superblocksteam/vite-plugin-file-sync/sync-service
50
53
 
51
54
  import { devServerMetrics } from "../dev-utils/dev-server-metrics.mjs";
52
55
  import { createDevServer } from "../dev-utils/dev-server.mjs";
56
+ import {
57
+ buildFatalExitLog,
58
+ FATAL_EXIT_EVENT,
59
+ parseIsWarm,
60
+ } from "../dev-utils/fatal-exit.mjs";
53
61
  import { AUTO_UPGRADE_EXIT_CODE } from "../index.js";
54
62
  import type {
55
63
  AuthHotReloadServer,
@@ -901,6 +909,349 @@ export interface DevServerStatus {
901
909
  serverErrors: ServerError[];
902
910
  }
903
911
 
912
+ /**
913
+ * True when this dev server was launched as a CSB sandbox — `SUPERBLOCKS_IS_CSB
914
+ * === "true"`, the same variable that already gates the CSB-only behavior
915
+ * elsewhere in this file. That is two populations, not one:
916
+ *
917
+ * - a disposable, orchestrator-managed SABS live-edit pod, and
918
+ * - the LOCAL `csb-mock-server` emulator, which sets the identical variable
919
+ * when it spawns `superblocks dev` for code-mode work
920
+ * (`packages/csb-mock-server/src/app-manager.ts`, `startProcess` and
921
+ * `restartSandbox`; neither passes anything that skips the auto-upgrade).
922
+ *
923
+ * So this is NOT "cloud, not a developer's machine". The emulator answering
924
+ * `true` is intended — it exists to emulate a pod, and an exit is the behavior a
925
+ * pod would show — but it emulates only half the story: `attachEventListeners`
926
+ * in that file merely logs the child's exit, so nothing restarts it. The
927
+ * practical consequence for a local dev whose auto-upgrade fails this way is a
928
+ * dead dev server until something calls `startProcess` again (reopening or
929
+ * restarting the app in code mode). Only a dev server the developer ran
930
+ * directly, with no `SUPERBLOCKS_IS_CSB`, still degrades.
931
+ *
932
+ * Read at call time, not module load, so tests can set the variable per-case.
933
+ */
934
+ export function isDisposableSandbox(): boolean {
935
+ return process.env.SUPERBLOCKS_IS_CSB === "true";
936
+ }
937
+
938
+ /**
939
+ * The single implementation of "flush telemetry, but never wait forever".
940
+ *
941
+ * Two paths need it, for the same reason: the OTel metric reader exports on a
942
+ * 10s interval, so a sample recorded moments before the process goes away dies
943
+ * with it unless something forces the export — and neither path may block
944
+ * indefinitely on a slow or unreachable collector. The callers are the
945
+ * fatal-exit sequence (`flushTelemetryBeforeExit`, just below) and the
946
+ * auto-upgrade CLI restart in `dev()`, which used to carry its own copy of this
947
+ * race with the timer-clear missing.
948
+ *
949
+ * Resolves `true` only when a flush actually ran to completion — `false` on an
950
+ * internal shutdown error, when telemetry was never initialized, or when the
951
+ * timeout won the race.
952
+ *
953
+ * Reporting that boolean is left to the callers rather than parameterized here,
954
+ * because the useful part of the message is what an operator should read
955
+ * INSTEAD of the dropped samples, and that differs per path: the fatal paths
956
+ * point at their `event=dev_server_fatal_exit` line, which the restart path
957
+ * never writes.
958
+ *
959
+ * Rejects only if `shutdownTelemetry()` breaks its documented never-reject
960
+ * contract, so callers keep their own defensive catch.
961
+ */
962
+ export async function flushTelemetryWithTimeout(): Promise<boolean> {
963
+ let flushed = false;
964
+ let timer: NodeJS.Timeout | undefined;
965
+ try {
966
+ await Promise.race([
967
+ shutdownTelemetry().then((didFlush) => {
968
+ flushed = didFlush;
969
+ }),
970
+ new Promise<void>((resolve) => {
971
+ timer = setTimeout(resolve, TELEMETRY_FLUSH_TIMEOUT_MS);
972
+ }),
973
+ ]);
974
+ } finally {
975
+ // The race is settled; leaving the timer armed would hold the event loop
976
+ // open for callers (tests, in particular) that do not immediately exit.
977
+ if (timer) clearTimeout(timer);
978
+ }
979
+ return flushed;
980
+ }
981
+
982
+ /**
983
+ * Force a telemetry export before a fatal exit.
984
+ *
985
+ * The OTel metric reader exports on a 10s interval, so anything recorded
986
+ * moments before `process.exit(1)` dies with the process — the same drop
987
+ * already documented for the auto-upgrade restart path below. Any metric whose
988
+ * entire purpose is to make an exit observable has to be flushed here or it is
989
+ * never seen. The raced flush itself lives in `flushTelemetryWithTimeout`,
990
+ * shared with that restart path; this wrapper only adds the fatal-exit
991
+ * reporting.
992
+ *
993
+ * MUST BE CALLED LAST in any shutdown sequence. This does not merely flush: it
994
+ * awaits `shutdownTelemetry()`, which tears the provider down, so every signal
995
+ * emitted after it goes into a dead pipeline. It can also block for up to
996
+ * `TELEMETRY_FLUSH_TIMEOUT_MS`, and delaying `lockService.shutdown()` by that
997
+ * much delays the lock release that lets a replacement sandbox take over —
998
+ * which is the entire recovery story this exit path exists to enable.
999
+ *
1000
+ * "Last" is only reachable because the callers drive the lock service with
1001
+ * `shutdown()` rather than `shutdownAndExit()`. The latter ends in an
1002
+ * unconditional `finally { process.exit(1) }`, and `process.exit` is
1003
+ * synchronous, so anything sequenced after it never runs at all. See the two
1004
+ * fatal-exit blocks in `dev()`.
1005
+ *
1006
+ * Never throws: a stuck exporter is raced against a timeout so it cannot wedge
1007
+ * the exit path.
1008
+ *
1009
+ * `shutdownTelemetry()` resolves `true` only when a flush actually ran to
1010
+ * completion — `false` on an internal shutdown error or when telemetry was
1011
+ * never initialized — and the timeout branch leaves the flag `false`. Log that
1012
+ * distinction rather than discarding it: when it is `false` the `outcome="exit"`
1013
+ * sample did NOT make it out, and the only surviving evidence of the exit is the
1014
+ * synchronous `event=dev_server_fatal_exit` line the caller already wrote. An
1015
+ * operator who cannot tell those apart reads "zero exit samples" as "the exit
1016
+ * path never ran". The auto-upgrade restart path reports the same distinction,
1017
+ * against its own log line.
1018
+ */
1019
+ async function flushTelemetryBeforeExit(logger: Logger): Promise<void> {
1020
+ let flushed = false;
1021
+ try {
1022
+ flushed = await flushTelemetryWithTimeout();
1023
+ } catch (e) {
1024
+ // Defensive only: `shutdownTelemetry()` catches internally and resolves
1025
+ // `false` rather than rejecting. Kept so a future change to that contract
1026
+ // cannot turn this into an unhandled rejection on the exit path.
1027
+ logger.warn(`[dev-server] telemetry flush before exit failed: ${e}`);
1028
+ }
1029
+ if (!flushed) {
1030
+ logger.warn(
1031
+ `[dev-server] telemetry flush did not complete (timed out after ${TELEMETRY_FLUSH_TIMEOUT_MS}ms, errored, or telemetry disabled) before fatal exit; ` +
1032
+ `dependency-failure samples may be dropped — use the event=${FATAL_EXIT_EVENT} log line as the source of truth for this exit`,
1033
+ );
1034
+ }
1035
+ }
1036
+
1037
+ /**
1038
+ * Upper bound on any single teardown step run before a fatal exit.
1039
+ *
1040
+ * Each step talks to something that can stop answering (the ai-service over
1041
+ * IPC, the lock service over the network). Without a bound, one unsettled
1042
+ * promise means the `finally { process.exit(1) }` below it never runs and the
1043
+ * pod stays Ready but zombied — the exact ENG-5297 symptom this exit path
1044
+ * exists to end, one call earlier. The whole point of these paths is that the
1045
+ * process goes away, so a step that has not answered in this long is abandoned
1046
+ * rather than waited on.
1047
+ */
1048
+ const FATAL_EXIT_STEP_TIMEOUT_MS = 5000;
1049
+
1050
+ /**
1051
+ * One teardown step. Returning `undefined` is allowed so callers can pass the
1052
+ * optional-chained call directly (`() => lockService?.shutdown(...)`) without
1053
+ * having to re-narrow a mutable binding at the call site.
1054
+ */
1055
+ type FatalExitStep = () => Promise<unknown> | undefined;
1056
+
1057
+ /**
1058
+ * Run one best-effort teardown step on a fatal-exit path.
1059
+ *
1060
+ * Neither a throw nor a hang may stop the later steps: the lock release is what
1061
+ * lets a replacement sandbox take over, and the telemetry flush is what makes
1062
+ * the exit observable, so losing them to a failure in an earlier, less
1063
+ * important step is strictly worse than the failure itself.
1064
+ */
1065
+ async function runFatalExitStep(
1066
+ logger: Logger,
1067
+ name: string,
1068
+ step: FatalExitStep | undefined,
1069
+ ): Promise<void> {
1070
+ if (!step) {
1071
+ return;
1072
+ }
1073
+ let timer: NodeJS.Timeout | undefined;
1074
+ try {
1075
+ await Promise.race([
1076
+ // A synchronous throw from `step()` propagates out of the array literal
1077
+ // before the race is constructed, and is caught by the same `catch`.
1078
+ // `undefined` (the service was never constructed) is a non-thenable, so
1079
+ // the race settles immediately and the step is a no-op.
1080
+ step(),
1081
+ new Promise<void>((resolve) => {
1082
+ timer = setTimeout(() => {
1083
+ logger.error(
1084
+ `[dev-server] ${name} did not settle within ${FATAL_EXIT_STEP_TIMEOUT_MS}ms before fatal exit; abandoning it and continuing`,
1085
+ );
1086
+ resolve();
1087
+ }, FATAL_EXIT_STEP_TIMEOUT_MS);
1088
+ }),
1089
+ ]);
1090
+ } catch (e) {
1091
+ logger.error(
1092
+ `[dev-server] ${name} failed before fatal exit; continuing`,
1093
+ getErrorMeta(e),
1094
+ );
1095
+ } finally {
1096
+ if (timer) clearTimeout(timer);
1097
+ }
1098
+ }
1099
+
1100
+ /**
1101
+ * The ordered teardown every fatal-exit path in `dev()` runs immediately before
1102
+ * `process.exit(1)`. Stated once, here, so the ordering guarantee cannot drift
1103
+ * between the two call sites.
1104
+ *
1105
+ * Order is load-bearing:
1106
+ * 1. `removeIntegrationCache` — ai-service state that must not outlive us.
1107
+ * 2. `shutdownLockService` — releases the AppBranchLock. Recovery-critical:
1108
+ * the heartbeat dies with the process, so skipping this makes the
1109
+ * replacement sandbox's `acquireLock()` fail with a ConflictError until
1110
+ * the server-side TTL expires, and the replacement is the entire point of
1111
+ * exiting.
1112
+ * 3. `flushTelemetryBeforeExit` — LAST, because it tears the telemetry
1113
+ * provider down (anything after it is unobservable) and can block for
1114
+ * `TELEMETRY_FLUSH_TIMEOUT_MS`, which would otherwise delay the lock
1115
+ * release above.
1116
+ *
1117
+ * Every step is individually guarded and individually bounded, so neither a
1118
+ * throw nor a hang in an earlier step can cost a later one. Never throws.
1119
+ *
1120
+ * This does NOT exit. The caller owns `process.exit(1)` from a `finally`, so
1121
+ * the exit still happens even if this function is somehow bypassed. That is
1122
+ * also why callers must drive the lock service through `shutdown()` and not
1123
+ * `shutdownAndExit()`: the latter ends in its own unconditional
1124
+ * `finally { process.exit(1) }`, and `process.exit` is synchronous, so
1125
+ * everything sequenced after it — including the flush — silently never ran.
1126
+ */
1127
+ export async function runFatalExitShutdown(steps: {
1128
+ logger: Logger;
1129
+ removeIntegrationCache?: FatalExitStep;
1130
+ shutdownLockService?: FatalExitStep;
1131
+ }): Promise<void> {
1132
+ const { logger } = steps;
1133
+ await runFatalExitStep(
1134
+ logger,
1135
+ "ai-service integration cache removal",
1136
+ steps.removeIntegrationCache,
1137
+ );
1138
+ await runFatalExitStep(
1139
+ logger,
1140
+ "lock service shutdown",
1141
+ steps.shutdownLockService,
1142
+ );
1143
+ await flushTelemetryBeforeExit(logger);
1144
+ }
1145
+
1146
+ /**
1147
+ * Upgrade-failure categories that are decided BEFORE npm can extract anything.
1148
+ *
1149
+ * npm builds the whole ideal tree from registry metadata before it writes a
1150
+ * single byte into `node_modules`, so a run that died because the registry
1151
+ * refused, could not be reached, could not be TLS-verified, or did not have the
1152
+ * requested version never reached the extraction phase — the on-disk tree and
1153
+ * the running esbuild service it feeds are provably untouched.
1154
+ *
1155
+ * These are also precisely the shape a registry/proxy outage takes. Treating
1156
+ * them as fatal would exit every live-edit pod in the fleet simultaneously and
1157
+ * hand SABS a queue of replacements that hit the same outage, converting a
1158
+ * degraded-but-serving fleet into an unavailable one.
1159
+ *
1160
+ * Typed against `DependencyUpgradeError["category"]` so a future category added
1161
+ * to that union has to be classified deliberately rather than drifting in.
1162
+ *
1163
+ * The union has exactly five members today, so holding the other four here
1164
+ * makes `upgradeCategoryCouldTaintTree` presently equivalent to
1165
+ * `category === "unknown"`. The set shape is kept anyway because the two forms
1166
+ * diverge the moment a sixth category is added: an allowlist of known-safe
1167
+ * categories fails CLOSED (a new, unclassified category exits, which is the
1168
+ * conservative side), whereas `=== "unknown"` fails OPEN (a new category
1169
+ * silently degrades, which is precisely the ENG-5297 zombie-pod bug). Naming
1170
+ * the four safe categories also forces whoever adds the sixth to decide which
1171
+ * side it belongs on.
1172
+ *
1173
+ * Residual risk, stated plainly — and this is the known hole in the gate, not a
1174
+ * theoretical one. These categories are derived from npm/pnpm's error text,
1175
+ * which carries no notion of install PHASE, so a later-phase failure can wear an
1176
+ * early-phase label: `ERR_PNPM_FETCH_<5xx>` is pnpm's TARBALL fetch error yet
1177
+ * maps to `registry_unreachable`, and `detectReasonFromText` matches
1178
+ * ECONNRESET/ETIMEDOUT/ENOTFOUND anywhere in stderr. A CDN 502 or a mid-download
1179
+ * reset therefore degrades even though extraction had begun. npm rolls the reify
1180
+ * back, but rollback restores FILES only — it does not restart the esbuild
1181
+ * service child process that died when its binary was replaced, which is the
1182
+ * actual harm chain in ENG-5297. So this gate does not close that case.
1183
+ *
1184
+ * The trade is still taken knowingly: closing it properly means stamping the
1185
+ * phase where it is actually known (a `treePossiblyMutated` flag set at the
1186
+ * throw site in `upgradeCliWithPackageManager`) or probing esbuild/Vite health
1187
+ * after an `UpgradeFailed`, both of which change the shared
1188
+ * `DependencyUpgradeError` contract and belong in their own change. Meanwhile
1189
+ * this gate is strictly better than the previous behavior, which degraded on
1190
+ * every upgrade failure without exception, and exiting on these categories
1191
+ * instead would amplify a registry outage into a fleet-wide restart.
1192
+ */
1193
+ const RESOLUTION_PHASE_UPGRADE_CATEGORIES: ReadonlySet<string> = new Set<
1194
+ DependencyUpgradeError["category"]
1195
+ >([
1196
+ "not_in_registry",
1197
+ "registry_auth_failed",
1198
+ "registry_unreachable",
1199
+ "tls_failed",
1200
+ ]);
1201
+
1202
+ /**
1203
+ * True when a failed auto-upgrade could have mutated the live dependency tree,
1204
+ * and therefore may have killed the running esbuild service out from under
1205
+ * Vite. Unknown/unclassified categories deliberately answer `true`: ENG-5297
1206
+ * arrived as `category: "unknown"`, and a category this function has never seen
1207
+ * must fail closed (exit) rather than inherit the degrade path by default.
1208
+ *
1209
+ * This reduces to `category === "unknown"` against today's five-member union —
1210
+ * see `RESOLUTION_PHASE_UPGRADE_CATEGORIES` for why the set is kept rather than
1211
+ * collapsed to that comparison.
1212
+ *
1213
+ * The parameter is `string`, not `DependencyUpgradeError["category"]`, because
1214
+ * that is the actual runtime contract: the payload is deserialized from the
1215
+ * socket, so the five-member union is a promise about our own writers rather
1216
+ * than a guarantee about the value — which is also why the set above is a
1217
+ * `ReadonlySet<string>`. It is also what lets the fail-closed default be
1218
+ * asserted for a category a FUTURE CLI adds; that case is not expressible
1219
+ * through an `UpgradeFailed` marker without an `as` cast, and it is the one this
1220
+ * gate most needs to keep.
1221
+ *
1222
+ * `"unknown"` is itself coarse; the two sub-cases sit on opposite sides of the
1223
+ * question this function asks, and only one of them is a false positive:
1224
+ *
1225
+ * no install command `resolveCommand` returned nothing, so `child_process.exec`
1226
+ * was never reached (automatic-upgrades.ts, the
1227
+ * `if (!installCommand)` early throw). The tree is
1228
+ * provably untouched and exiting is a false positive.
1229
+ * version validation `getCurrentCliVersion()` returned nothing / mismatched.
1230
+ * That check runs AFTER `await promise` resolves — i.e.
1231
+ * after the global install completed — so the tree
1232
+ * genuinely moved and exiting is correct.
1233
+ *
1234
+ * Only the first is a false positive, and it is not distinguishable from data
1235
+ * already on `serverError`: both arrive as `category: "unknown"` with no
1236
+ * `npmErrorCode`, and the only thing separating them is free-text English in
1237
+ * `rawError`. Keying the exit decision on that string would be the same
1238
+ * phase-blind text proxy this file already flags as the gate's weak point.
1239
+ * Splitting them properly means a flag stamped at the throw site on
1240
+ * `DependencyUpgradeError` — a change to a shared contract, deliberately not
1241
+ * made here. Until then both land on exit, the direction this function is meant
1242
+ * to err in.
1243
+ *
1244
+ * Note on cost if the false positive fires: SABS pods run `RestartPolicy: Never`,
1245
+ * so the retry is a pod REPLACEMENT driven by the orchestrator, not an in-pod
1246
+ * crash loop. A deterministic, environment-derived cause would therefore
1247
+ * re-trigger in each replacement, and no per-process bound inside this file
1248
+ * could stop that — a replacement is a fresh process with a fresh counter. Any
1249
+ * real bound belongs in the orchestrator that decides to replace.
1250
+ */
1251
+ export function upgradeCategoryCouldTaintTree(category: string): boolean {
1252
+ return !RESOLUTION_PHASE_UPGRADE_CATEGORIES.has(category);
1253
+ }
1254
+
904
1255
  /** Decide how the startup catch handles an error: degrade (record, keep Vite up)
905
1256
  * for an app-install failure (`InitialInstallFailed`) or a best-effort
906
1257
  * CLI/library auto-upgrade failure (`UpgradeFailed`), or exit for anything else
@@ -910,6 +1261,17 @@ export function handleStartupError(
910
1261
  error: unknown,
911
1262
  status: DevServerStatus,
912
1263
  logger: Logger,
1264
+ options?: {
1265
+ /**
1266
+ * True when this process was launched as a CSB sandbox — a SABS live-edit
1267
+ * pod OR the local `csb-mock-server` emulator, which sets the same
1268
+ * `SUPERBLOCKS_IS_CSB` variable. Decides whether a failed best-effort
1269
+ * upgrade may degrade or must be fatal — see the `UpgradeFailed` branch.
1270
+ * `isDisposableSandbox` documents why the local emulator is deliberately
1271
+ * included and what that costs a local dev.
1272
+ */
1273
+ disposableSandbox?: boolean;
1274
+ },
913
1275
  ): "degrade" | "exit" {
914
1276
  if (error instanceof InitialInstallFailed) {
915
1277
  status.serverErrors.push(error.serverError);
@@ -925,20 +1287,66 @@ export function handleStartupError(
925
1287
  return "degrade";
926
1288
  }
927
1289
  if (error instanceof UpgradeFailed) {
928
- // The auto-upgrade is best-effort and the pod is still running the current
929
- // version, so a failed upgrade degrades instead of crash-looping. Recording
930
- // here also makes the failure observable: the process survives to the next
931
- // metrics export tick, which the pre-exit `process.exit(1)` always lost.
932
1290
  status.serverErrors.push(error.serverError);
933
- logger.error(
934
- "[dev-server] dependency upgrade failed; keeping the current version",
935
- getErrorMeta(error),
936
- );
1291
+
1292
+ // The upgrade is an in-place `npm install` into the LIVE node_modules, and
1293
+ // its install manifest includes esbuild. A failure part-way through can
1294
+ // therefore have already killed the running esbuild service ("The service
1295
+ // was stopped"), which rejects vitePromise and leaves a dev server that can
1296
+ // never serve a request again. "Keeping the current version" is only true
1297
+ // when the tree survived, and we cannot cheaply prove that it did.
1298
+ //
1299
+ // So the decision turns on who owns the process AND on whether the failure
1300
+ // could have mutated the tree at all:
1301
+ // - disposable sandbox (SABS pod) + a failure that could have touched
1302
+ // `node_modules`: exit. The pod is cheap and SABS already reaps
1303
+ // terminal pods (they run RestartPolicy: Never), so exiting yields a
1304
+ // clean replacement. Degrading instead produced ENG-5297 — 85 minutes
1305
+ // of a Ready pod answering 500s.
1306
+ // - disposable sandbox + a resolution-phase failure: degrade. npm never
1307
+ // got as far as extraction, so the tree is intact and there is nothing
1308
+ // for a replacement pod to fix — see
1309
+ // `upgradeCategoryCouldTaintTree`, which also explains why exiting on
1310
+ // these would amplify a registry outage into a fleet-wide restart.
1311
+ // - anything not flagged as a CSB sandbox: degrade. Killing a dev server
1312
+ // the developer started themselves over a best-effort upgrade is worse
1313
+ // than running the older version. Note the carve-out is narrower than
1314
+ // "not in the cloud": the local `csb-mock-server` emulator also sets
1315
+ // `SUPERBLOCKS_IS_CSB=true`, so local code-mode dev servers exit here
1316
+ // too. That is intended (the emulator exists to behave like a pod), but
1317
+ // nothing in the emulator restarts the child — see
1318
+ // `isDisposableSandbox`.
1319
+ const outcome =
1320
+ options?.disposableSandbox &&
1321
+ upgradeCategoryCouldTaintTree(error.serverError.category)
1322
+ ? "exit"
1323
+ : "degrade";
937
1324
  devServerMetrics.recordUpgradeFailure({
938
1325
  category: error.serverError.category,
939
1326
  npmErrorCode: error.serverError.npmErrorCode,
940
1327
  hasAnyRegistryConfigured: error.serverError.hasAnyRegistryConfigured,
1328
+ outcome,
941
1329
  });
1330
+
1331
+ if (outcome === "exit") {
1332
+ logger.error(
1333
+ "[dev-server] dependency upgrade failed in a disposable sandbox; exiting so the sandbox is replaced (the live dependency tree may be tainted)",
1334
+ getErrorMeta(error),
1335
+ );
1336
+ // No `devServerMetrics.flush()` here: `record()` and `flush()` gate on
1337
+ // the same `isTelemetryInitialized()` check and nothing async runs
1338
+ // between them, so if the sample above was buffered, flush would bail on
1339
+ // the identical condition — and if it was not buffered, there is nothing
1340
+ // to drain. The caller's `flushTelemetryBeforeExit` (which awaits
1341
+ // `shutdownTelemetry()`) is what actually forces the export before
1342
+ // `process.exit(1)`.
1343
+ return "exit";
1344
+ }
1345
+
1346
+ logger.error(
1347
+ "[dev-server] dependency upgrade failed; keeping the current version",
1348
+ getErrorMeta(error),
1349
+ );
942
1350
  return "degrade";
943
1351
  }
944
1352
  return "exit";
@@ -1196,6 +1604,7 @@ export async function dev(options: {
1196
1604
  superblocksBaseUrl: tokenConfig.superblocksBaseUrl,
1197
1605
  rpcClient,
1198
1606
  logger,
1607
+ removeManualCommitsEnabled: featureFlags.removeManualCommitsEnabled(),
1199
1608
  });
1200
1609
 
1201
1610
  logger.info("Checking if local files are synced with the server");
@@ -1902,22 +2311,18 @@ export async function dev(options: {
1902
2311
  // which is why that runner reports zero samples in every env
1903
2312
  // despite real upgrades emitting it (spans survive because they
1904
2313
  // flush on the faster batch processor; the metric does not).
1905
- // shutdownTelemetry() never rejects; the catch is purely
1906
- // defensive. It resolves to `true` only when a flush actually
1907
- // ran `false` on internal shutdown error or when telemetry was
1908
- // disabled and the timeout branch leaves the flag false. So
1909
- // the log below reflects whether samples were really flushed, not
1910
- // merely that the race settled.
2314
+ // The timeout-raced flush is shared with the fatal-exit path
2315
+ // (`flushTelemetryWithTimeout`) this site used to carry its own
2316
+ // copy of the race, and that copy leaked its timer.
2317
+ // `flushTelemetryWithTimeout` resolves `true` only when a flush
2318
+ // actually ran `false` on internal shutdown error, when
2319
+ // telemetry was disabled, or when the timeout won — so the log
2320
+ // below reflects whether samples were really flushed, not merely
2321
+ // that the race settled. It rejects only if shutdownTelemetry()
2322
+ // breaks its never-reject contract, so the catch stays defensive.
1911
2323
  let telemetryFlushed = false;
1912
2324
  try {
1913
- await Promise.race([
1914
- shutdownTelemetry().then((flushed) => {
1915
- telemetryFlushed = flushed;
1916
- }),
1917
- new Promise<void>((resolve) =>
1918
- setTimeout(resolve, TELEMETRY_FLUSH_TIMEOUT_MS),
1919
- ),
1920
- ]);
2325
+ telemetryFlushed = await flushTelemetryWithTimeout();
1921
2326
  } catch (e) {
1922
2327
  logger.error(
1923
2328
  "Error flushing telemetry before CLI restart",
@@ -1938,20 +2343,59 @@ export async function dev(options: {
1938
2343
  });
1939
2344
  } catch (error: any) {
1940
2345
  if (
1941
- handleStartupError(error, devServerStatus, logger) === "degrade"
2346
+ handleStartupError(error, devServerStatus, logger, {
2347
+ disposableSandbox: isDisposableSandbox(),
2348
+ }) === "degrade"
1942
2349
  ) {
1943
2350
  // app-install failure: do NOT exit — fall through to Vite startup below.
1944
2351
  // upload + CLI-restart were already skipped (the rejecting join threw first).
1945
2352
  } else {
1946
2353
  logger.error(
1947
- "[dev-server] Startup failed during sync/lock/setup (exiting with code 1)",
2354
+ // Names the stage, because `buildFatalExitLog` below carries only
2355
+ // the generic `handler=startupFailure`. Two stages reach here now:
2356
+ // sync/lock/setup, and (since ENG-5297) a dependency upgrade that
2357
+ // failed in a CSB sandbox and may have tainted the live tree.
2358
+ "[dev-server] Startup failed during sync/lock/setup, or a dependency upgrade failed in a CSB sandbox (exiting with code 1)",
1948
2359
  getErrorMeta(error),
1949
2360
  );
2361
+ // Emitted BEFORE any teardown, and as a synchronous log line rather
2362
+ // than a counter, per the convention documented in
2363
+ // dev-utils/fatal-exit.mts: at exit time stdout is the only sink we
2364
+ // can rely on, so the reason has to land even if the flush below
2365
+ // never completes. The `outcome="exit"` metric is a complement to
2366
+ // this line, not a substitute for it.
2367
+ logger.error(
2368
+ buildFatalExitLog({
2369
+ handler: "startupFailure",
2370
+ exitCode: 1,
2371
+ isWarm: parseIsWarm(process.env.SUPERBLOCKS_WARM_STANDBY),
2372
+ }),
2373
+ );
1950
2374
  try {
1951
- await aiService?.removeIntegrationCache();
1952
- await lockService?.shutdownAndExit();
2375
+ // `shutdown()`, NOT `shutdownAndExit()`. The latter ends in an
2376
+ // unconditional `finally { process.exit(1) }`; `process.exit` is
2377
+ // synchronous, so every statement below it would be dead code
2378
+ // whenever `lockService` is set — which is the normal state in a
2379
+ // SABS pod, exactly the population this exit path targets. The
2380
+ // telemetry flush was silently never running. `shutdown()` does
2381
+ // the same teardown without owning the exit, matching how
2382
+ // `runGracefulShutdown` in dev-server.mts drives it; the
2383
+ // `finally` below performs the exit instead.
2384
+ //
2385
+ // Ordering, per-step error handling, and per-step timeouts all
2386
+ // live in `runFatalExitShutdown` so the two fatal paths cannot
2387
+ // drift apart.
2388
+ await runFatalExitShutdown({
2389
+ logger,
2390
+ removeIntegrationCache: () =>
2391
+ aiService?.removeIntegrationCache(),
2392
+ shutdownLockService: () =>
2393
+ lockService?.shutdown({ serverInitiated: false }),
2394
+ });
1953
2395
  } finally {
1954
- // this is redundant, but it's here to make sure the lock service is shutdown and the process exits
2396
+ // The only exit on this path now that the lock service no longer
2397
+ // owns one. `runFatalExitShutdown` never throws and bounds every
2398
+ // step, so this runs regardless of what teardown did.
1955
2399
  process.exit(1);
1956
2400
  }
1957
2401
  }
@@ -1983,17 +2427,39 @@ export async function dev(options: {
1983
2427
  try {
1984
2428
  await joinUpgradeThenInstall("before Vite startup");
1985
2429
  } catch (error) {
1986
- if (handleStartupError(error, devServerStatus, logger) === "exit") {
2430
+ if (
2431
+ handleStartupError(error, devServerStatus, logger, {
2432
+ disposableSandbox: isDisposableSandbox(),
2433
+ }) === "exit"
2434
+ ) {
1987
2435
  logger.error(
1988
2436
  "[dev-server] Startup failed during pre-Vite install join (exiting with code 1)",
1989
2437
  getErrorMeta(error),
1990
2438
  );
2439
+ // Same convention as the sync/lock catch above: a synchronous
2440
+ // `event=dev_server_fatal_exit` line, written before teardown, is the
2441
+ // thing oncall queries for this exit class.
2442
+ logger.error(
2443
+ buildFatalExitLog({
2444
+ handler: "startupFailure",
2445
+ exitCode: 1,
2446
+ isWarm: parseIsWarm(process.env.SUPERBLOCKS_WARM_STANDBY),
2447
+ }),
2448
+ );
1991
2449
  try {
1992
- await aiService?.removeIntegrationCache();
1993
- await lockService?.shutdownAndExit();
2450
+ // `shutdown()`, NOT `shutdownAndExit()` — same reason as the
2451
+ // sync/lock catch above: `shutdownAndExit()` exits the process
2452
+ // synchronously in its own `finally`, which made everything
2453
+ // sequenced after it unreachable.
2454
+ await runFatalExitShutdown({
2455
+ logger,
2456
+ removeIntegrationCache: () => aiService?.removeIntegrationCache(),
2457
+ shutdownLockService: () =>
2458
+ lockService?.shutdown({ serverInitiated: false }),
2459
+ });
1994
2460
  } finally {
1995
- // Redundant with `shutdownAndExit`; here so a thrown shutdown
1996
- // path can't leave the process hanging on a stuck handle.
2461
+ // The only exit on this path; here so a thrown shutdown path can't
2462
+ // leave the process hanging on a stuck handle.
1997
2463
  process.exit(1);
1998
2464
  }
1999
2465
  }
@@ -57,6 +57,23 @@ describe("fetchBillingUsageRecordsResponse", () => {
57
57
  source: "seat",
58
58
  },
59
59
  ],
60
+ usageGroupRows: [
61
+ {
62
+ actorId: "user-1",
63
+ actorType: "user",
64
+ applicationId: "00000000-0000-4000-8000-000000000001",
65
+ applicationName: "Operations",
66
+ checkpointId: "commit-abc123",
67
+ checkpointLabel: "Add revenue chart",
68
+ creditsUsed: 40,
69
+ date: "2026-03-24",
70
+ email: "alice@example.com",
71
+ name: "Alice",
72
+ onDemandCreditsUsed: 0,
73
+ recordedAt: "2026-03-24 10:15:00",
74
+ unbilledCreditsUsed: 0,
75
+ },
76
+ ],
60
77
  };
61
78
  mockAxios.mockResolvedValue({ data: { data: payload } });
62
79
 
@@ -69,5 +86,14 @@ describe("fetchBillingUsageRecordsResponse", () => {
69
86
  token: "test-token",
70
87
  }),
71
88
  ).resolves.toEqual(payload);
89
+
90
+ const response = await fetchBillingUsageRecordsResponse({
91
+ cliVersion: "1.2.3",
92
+ endDate: "2026-03-25",
93
+ startDate: "2026-03-24",
94
+ superblocksBaseUrl: "https://staging.superblocks.com",
95
+ token: "test-token",
96
+ });
97
+ expect(response.usageGroupRows?.[0]?.checkpointId).toBe("commit-abc123");
72
98
  });
73
99
  });