@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/cli-replacement/automatic-upgrades.d.ts.map +1 -1
- package/dist/cli-replacement/automatic-upgrades.js +8 -6
- package/dist/cli-replacement/automatic-upgrades.js.map +1 -1
- package/dist/cli-replacement/dev-s3-restore.test.mjs +1 -0
- package/dist/cli-replacement/dev-s3-restore.test.mjs.map +1 -1
- package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs +1 -0
- package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs.map +1 -1
- package/dist/cli-replacement/dev.d.mts +148 -1
- package/dist/cli-replacement/dev.d.mts.map +1 -1
- package/dist/cli-replacement/dev.interception.test.mjs +259 -4
- package/dist/cli-replacement/dev.interception.test.mjs.map +1 -1
- package/dist/cli-replacement/dev.mjs +429 -28
- package/dist/cli-replacement/dev.mjs.map +1 -1
- package/dist/client.billing-usage.test.js +25 -0
- package/dist/client.billing-usage.test.js.map +1 -1
- package/dist/client.d.ts +19 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js.map +1 -1
- package/dist/collect-sdk-apis.d.mts +61 -3
- package/dist/collect-sdk-apis.d.mts.map +1 -1
- package/dist/collect-sdk-apis.mjs +97 -6
- package/dist/collect-sdk-apis.mjs.map +1 -1
- package/dist/collect-sdk-apis.test.mjs +277 -1
- package/dist/collect-sdk-apis.test.mjs.map +1 -1
- package/dist/dev-utils/dev-server-metrics.d.mts +17 -0
- package/dist/dev-utils/dev-server-metrics.d.mts.map +1 -1
- package/dist/dev-utils/dev-server-metrics.dependency-failure.test.d.mts +2 -0
- package/dist/dev-utils/dev-server-metrics.dependency-failure.test.d.mts.map +1 -0
- package/dist/dev-utils/dev-server-metrics.dependency-failure.test.mjs +108 -0
- package/dist/dev-utils/dev-server-metrics.dependency-failure.test.mjs.map +1 -0
- package/dist/dev-utils/dev-server-metrics.mjs +16 -2
- package/dist/dev-utils/dev-server-metrics.mjs.map +1 -1
- package/dist/dev-utils/dev-server.d.mts +9 -0
- package/dist/dev-utils/dev-server.d.mts.map +1 -1
- package/dist/dev-utils/dev-server.mjs +44 -27
- package/dist/dev-utils/dev-server.mjs.map +1 -1
- package/dist/dev-utils/dev-server.status.test.mjs +31 -1
- package/dist/dev-utils/dev-server.status.test.mjs.map +1 -1
- package/dist/dev-utils/fatal-exit.d.mts +17 -4
- package/dist/dev-utils/fatal-exit.d.mts.map +1 -1
- package/dist/dev-utils/fatal-exit.mjs +4 -3
- package/dist/dev-utils/fatal-exit.mjs.map +1 -1
- package/dist/extract-api-integrations.d.mts +1 -1
- package/dist/extract-api-integrations.d.mts.map +1 -1
- package/dist/extract-api-integrations.mjs +1 -1
- package/dist/extract-api-integrations.mjs.map +1 -1
- package/dist/flag.d.ts +5 -0
- package/dist/flag.d.ts.map +1 -1
- package/dist/flag.js +7 -0
- package/dist/flag.js.map +1 -1
- package/dist/flag.test.js +18 -0
- package/dist/flag.test.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/types/common.d.ts +1 -0
- package/dist/types/common.d.ts.map +1 -1
- package/dist/types/common.js.map +1 -1
- package/dist/vite-plugin-generate-api-build-manifest.d.mts.map +1 -1
- package/dist/vite-plugin-generate-api-build-manifest.mjs +46 -0
- package/dist/vite-plugin-generate-api-build-manifest.mjs.map +1 -1
- package/package.json +6 -6
- package/src/cli-replacement/automatic-upgrades.ts +8 -6
- package/src/cli-replacement/dev-s3-restore.test.mts +1 -0
- package/src/cli-replacement/dev-startup-git-before-dbfs-order.test.mts +1 -0
- package/src/cli-replacement/dev.interception.test.mts +334 -10
- package/src/cli-replacement/dev.mts +499 -33
- package/src/client.billing-usage.test.ts +26 -0
- package/src/client.ts +21 -0
- package/src/collect-sdk-apis.mts +141 -5
- package/src/collect-sdk-apis.test.mts +380 -1
- package/src/dev-utils/dev-server-metrics.dependency-failure.test.mts +149 -0
- package/src/dev-utils/dev-server-metrics.mts +25 -1
- package/src/dev-utils/dev-server.mts +52 -29
- package/src/dev-utils/dev-server.status.test.mts +42 -0
- package/src/dev-utils/fatal-exit.mts +18 -4
- package/src/extract-api-integrations.mts +3 -0
- package/src/flag.test.ts +21 -0
- package/src/flag.ts +8 -0
- package/src/index.ts +1 -0
- package/src/types/common.ts +1 -0
- package/src/vite-plugin-generate-api-build-manifest.mts +74 -4
- 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 {
|
|
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
|
-
|
|
934
|
-
|
|
935
|
-
|
|
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
|
-
//
|
|
1906
|
-
//
|
|
1907
|
-
//
|
|
1908
|
-
//
|
|
1909
|
-
//
|
|
1910
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1952
|
-
|
|
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
|
-
//
|
|
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 (
|
|
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
|
-
|
|
1993
|
-
|
|
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
|
-
//
|
|
1996
|
-
//
|
|
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
|
});
|