@superblocksteam/sdk 2.0.157-next.4 → 2.0.157-next.5

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 (67) 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 +4 -8
  4. package/dist/cli-replacement/automatic-upgrades.js.map +1 -1
  5. package/dist/cli-replacement/automatic-upgrades.test.js +2 -1
  6. package/dist/cli-replacement/automatic-upgrades.test.js.map +1 -1
  7. package/dist/cli-replacement/dev-s3-restore.test.mjs +6 -2
  8. package/dist/cli-replacement/dev-s3-restore.test.mjs.map +1 -1
  9. package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs +6 -2
  10. package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs.map +1 -1
  11. package/dist/cli-replacement/dev-startup-lock-acquisition.test.d.mts +2 -0
  12. package/dist/cli-replacement/dev-startup-lock-acquisition.test.d.mts.map +1 -0
  13. package/dist/cli-replacement/dev-startup-lock-acquisition.test.mjs +487 -0
  14. package/dist/cli-replacement/dev-startup-lock-acquisition.test.mjs.map +1 -0
  15. package/dist/cli-replacement/dev.d.mts +6 -6
  16. package/dist/cli-replacement/dev.d.mts.map +1 -1
  17. package/dist/cli-replacement/dev.interception.test.mjs +12 -12
  18. package/dist/cli-replacement/dev.interception.test.mjs.map +1 -1
  19. package/dist/cli-replacement/dev.mjs +100 -42
  20. package/dist/cli-replacement/dev.mjs.map +1 -1
  21. package/dist/dev-utils/dev-server-json-parser.test.d.mts +2 -0
  22. package/dist/dev-utils/dev-server-json-parser.test.d.mts.map +1 -0
  23. package/dist/dev-utils/dev-server-json-parser.test.mjs +60 -0
  24. package/dist/dev-utils/dev-server-json-parser.test.mjs.map +1 -0
  25. package/dist/dev-utils/dev-server.d.mts +36 -1
  26. package/dist/dev-utils/dev-server.d.mts.map +1 -1
  27. package/dist/dev-utils/dev-server.mjs +80 -73
  28. package/dist/dev-utils/dev-server.mjs.map +1 -1
  29. package/dist/dev-utils/dev-server.shutdown-lock.test.d.mts +2 -0
  30. package/dist/dev-utils/dev-server.shutdown-lock.test.d.mts.map +1 -0
  31. package/dist/dev-utils/dev-server.shutdown-lock.test.mjs +48 -0
  32. package/dist/dev-utils/dev-server.shutdown-lock.test.mjs.map +1 -0
  33. package/dist/dev-utils/fatal-process-barrier.d.mts +71 -0
  34. package/dist/dev-utils/fatal-process-barrier.d.mts.map +1 -0
  35. package/dist/dev-utils/fatal-process-barrier.mjs +161 -0
  36. package/dist/dev-utils/fatal-process-barrier.mjs.map +1 -0
  37. package/dist/dev-utils/fatal-process-barrier.spawn-fixture.d.mts +2 -0
  38. package/dist/dev-utils/fatal-process-barrier.spawn-fixture.d.mts.map +1 -0
  39. package/dist/dev-utils/fatal-process-barrier.spawn-fixture.mjs +9 -0
  40. package/dist/dev-utils/fatal-process-barrier.spawn-fixture.mjs.map +1 -0
  41. package/dist/dev-utils/fatal-process-barrier.test.d.mts +2 -0
  42. package/dist/dev-utils/fatal-process-barrier.test.d.mts.map +1 -0
  43. package/dist/dev-utils/fatal-process-barrier.test.mjs +281 -0
  44. package/dist/dev-utils/fatal-process-barrier.test.mjs.map +1 -0
  45. package/dist/telemetry/logging.js +1 -1
  46. package/dist/telemetry/logging.js.map +1 -1
  47. package/dist/telemetry/safe-stringify.d.ts.map +1 -1
  48. package/dist/telemetry/safe-stringify.js +25 -9
  49. package/dist/telemetry/safe-stringify.js.map +1 -1
  50. package/package.json +10 -6
  51. package/src/cli-replacement/automatic-upgrades.test.ts +2 -1
  52. package/src/cli-replacement/automatic-upgrades.ts +4 -11
  53. package/src/cli-replacement/dev-s3-restore.test.mts +6 -2
  54. package/src/cli-replacement/dev-startup-git-before-dbfs-order.test.mts +6 -2
  55. package/src/cli-replacement/dev-startup-lock-acquisition.test.mts +573 -0
  56. package/src/cli-replacement/dev.interception.test.mts +12 -12
  57. package/src/cli-replacement/dev.mts +112 -56
  58. package/src/dev-utils/dev-server-json-parser.test.mts +89 -0
  59. package/src/dev-utils/dev-server.mts +110 -93
  60. package/src/dev-utils/dev-server.shutdown-lock.test.mts +58 -0
  61. package/src/dev-utils/fatal-process-barrier.mts +262 -0
  62. package/src/dev-utils/fatal-process-barrier.spawn-fixture.mts +12 -0
  63. package/src/dev-utils/fatal-process-barrier.test.mts +346 -0
  64. package/src/telemetry/logging.ts +1 -1
  65. package/src/telemetry/safe-stringify.ts +25 -10
  66. package/test/safe-stringify.test.mts +25 -0
  67. package/tsconfig.tsbuildinfo +1 -1
@@ -315,7 +315,7 @@ describe("runFatalExitShutdown", () => {
315
315
  removeIntegrationCache: async () => {
316
316
  calls.push("cache");
317
317
  },
318
- shutdownLockService: async () => {
318
+ relinquishLock: async () => {
319
319
  calls.push("lock");
320
320
  },
321
321
  });
@@ -323,36 +323,36 @@ describe("runFatalExitShutdown", () => {
323
323
  });
324
324
 
325
325
  it("still releases the lock when cache removal rejects", async () => {
326
- const shutdownLockService = vi.fn(async () => {});
326
+ const relinquishLock = vi.fn(async () => {});
327
327
  await expect(
328
328
  runFatalExitShutdown({
329
329
  logger: makeLogger(),
330
330
  removeIntegrationCache: async () => {
331
331
  throw new Error("ai-service IPC is gone");
332
332
  },
333
- shutdownLockService,
333
+ relinquishLock,
334
334
  }),
335
335
  ).resolves.toBeUndefined();
336
- expect(shutdownLockService).toHaveBeenCalledOnce();
336
+ expect(relinquishLock).toHaveBeenCalledOnce();
337
337
  });
338
338
 
339
339
  it("still releases the lock when cache removal throws synchronously", async () => {
340
- const shutdownLockService = vi.fn(async () => {});
340
+ const relinquishLock = vi.fn(async () => {});
341
341
  await runFatalExitShutdown({
342
342
  logger: makeLogger(),
343
343
  removeIntegrationCache: () => {
344
344
  throw new Error("sync boom");
345
345
  },
346
- shutdownLockService,
346
+ relinquishLock,
347
347
  });
348
- expect(shutdownLockService).toHaveBeenCalledOnce();
348
+ expect(relinquishLock).toHaveBeenCalledOnce();
349
349
  });
350
350
 
351
351
  it("never rejects when the lock release itself rejects", async () => {
352
352
  await expect(
353
353
  runFatalExitShutdown({
354
354
  logger: makeLogger(),
355
- shutdownLockService: async () => {
355
+ relinquishLock: async () => {
356
356
  throw new Error("lock server unreachable");
357
357
  },
358
358
  }),
@@ -370,20 +370,20 @@ describe("runFatalExitShutdown", () => {
370
370
  it("abandons a step that never settles and still runs the later steps", async () => {
371
371
  vi.useFakeTimers();
372
372
  try {
373
- const shutdownLockService = vi.fn(async () => {});
373
+ const relinquishLock = vi.fn(async () => {});
374
374
  const settled = vi.fn();
375
375
  const done = runFatalExitShutdown({
376
376
  logger: makeLogger(),
377
377
  removeIntegrationCache: () => new Promise<void>(() => {}),
378
- shutdownLockService,
378
+ relinquishLock,
379
379
  }).then(settled);
380
380
 
381
381
  await vi.advanceTimersByTimeAsync(stepTimeoutMs - 1);
382
- expect(shutdownLockService).not.toHaveBeenCalled();
382
+ expect(relinquishLock).not.toHaveBeenCalled();
383
383
 
384
384
  await vi.advanceTimersByTimeAsync(2);
385
385
  await done;
386
- expect(shutdownLockService).toHaveBeenCalledOnce();
386
+ expect(relinquishLock).toHaveBeenCalledOnce();
387
387
  expect(settled).toHaveBeenCalledOnce();
388
388
  } finally {
389
389
  vi.useRealTimers();
@@ -16,10 +16,10 @@ import type {
16
16
  DependencyUpgradeError,
17
17
  ServerError,
18
18
  } from "@superblocksteam/library-shared/types";
19
+ import type { ConflictError } from "@superblocksteam/shared";
19
20
  import {
20
21
  buildGithubSuperblocksSyncWorkflow,
21
22
  buildGithubSuperblocksSyncWorkflowFromBaseUrl,
22
- ConflictError,
23
23
  DEFAULT_NON_GIT_BRANCH,
24
24
  isGitAuthErrorMessage,
25
25
  isGitHubRemoteUrl,
@@ -176,6 +176,19 @@ async function handleInactiveLockTakeover(
176
176
  throw new Error("Lock takeover prompt shown");
177
177
  }
178
178
 
179
+ // Only ask if somebody can answer. `promptUser` reads stdin, which on a
180
+ // sandbox nothing is attached to — it would wait forever for a keystroke
181
+ // that cannot come. Reachable now that conflicts surface when a client
182
+ // attaches rather than only during a CLI startup.
183
+ if (!process.stdin.isTTY) {
184
+ logger.warn(`${warningMessage} Run with --force-takeover to take it.`);
185
+ span.setStatus({
186
+ code: SpanStatusCode.ERROR,
187
+ message: "Lock held by an inactive user, nobody available to prompt",
188
+ });
189
+ throw new Error(warningMessage);
190
+ }
191
+
179
192
  // Prompt user to force takeover (CLI only)
180
193
  logger.warn(warningMessage);
181
194
  const shouldTakeover = await getBooleanUserResponse(prompt);
@@ -1019,12 +1032,12 @@ export async function flushTelemetryWithTimeout(): Promise<boolean> {
1019
1032
  * MUST BE CALLED LAST in any shutdown sequence. This does not merely flush: it
1020
1033
  * awaits `shutdownTelemetry()`, which tears the provider down, so every signal
1021
1034
  * emitted after it goes into a dead pipeline. It can also block for up to
1022
- * `TELEMETRY_FLUSH_TIMEOUT_MS`, and delaying `lockService.shutdown()` by that
1023
- * much delays the lock release that lets a replacement sandbox take over —
1035
+ * `TELEMETRY_FLUSH_TIMEOUT_MS`, and delaying `lockService.relinquishLock()` by
1036
+ * that much delays the lock release that lets a replacement sandbox take over —
1024
1037
  * which is the entire recovery story this exit path exists to enable.
1025
1038
  *
1026
1039
  * "Last" is only reachable because the callers drive the lock service with
1027
- * `shutdown()` rather than `shutdownAndExit()`. The latter ends in an
1040
+ * `relinquishLock()` rather than `shutdownAndExit()`. The latter ends in an
1028
1041
  * unconditional `finally { process.exit(1) }`, and `process.exit` is
1029
1042
  * synchronous, so anything sequenced after it never runs at all. See the two
1030
1043
  * fatal-exit blocks in `dev()`.
@@ -1075,8 +1088,8 @@ const FATAL_EXIT_STEP_TIMEOUT_MS = 5000;
1075
1088
 
1076
1089
  /**
1077
1090
  * One teardown step. Returning `undefined` is allowed so callers can pass the
1078
- * optional-chained call directly (`() => lockService?.shutdown(...)`) without
1079
- * having to re-narrow a mutable binding at the call site.
1091
+ * optional-chained call directly (`() => lockService?.relinquishLock()`)
1092
+ * without having to re-narrow a mutable binding at the call site.
1080
1093
  */
1081
1094
  type FatalExitStep = () => Promise<unknown> | undefined;
1082
1095
 
@@ -1130,7 +1143,7 @@ async function runFatalExitStep(
1130
1143
  *
1131
1144
  * Order is load-bearing:
1132
1145
  * 1. `removeIntegrationCache` — ai-service state that must not outlive us.
1133
- * 2. `shutdownLockService` — releases the AppBranchLock. Recovery-critical:
1146
+ * 2. `relinquishLock` — hands the AppBranchLock back. Recovery-critical:
1134
1147
  * the heartbeat dies with the process, so skipping this makes the
1135
1148
  * replacement sandbox's `acquireLock()` fail with a ConflictError until
1136
1149
  * the server-side TTL expires, and the replacement is the entire point of
@@ -1145,15 +1158,15 @@ async function runFatalExitStep(
1145
1158
  *
1146
1159
  * This does NOT exit. The caller owns `process.exit(1)` from a `finally`, so
1147
1160
  * the exit still happens even if this function is somehow bypassed. That is
1148
- * also why callers must drive the lock service through `shutdown()` and not
1149
- * `shutdownAndExit()`: the latter ends in its own unconditional
1161
+ * also why callers must drive the lock service through `relinquishLock()` and
1162
+ * not `shutdownAndExit()`: the latter ends in its own unconditional
1150
1163
  * `finally { process.exit(1) }`, and `process.exit` is synchronous, so
1151
1164
  * everything sequenced after it — including the flush — silently never ran.
1152
1165
  */
1153
1166
  export async function runFatalExitShutdown(steps: {
1154
1167
  logger: Logger;
1155
1168
  removeIntegrationCache?: FatalExitStep;
1156
- shutdownLockService?: FatalExitStep;
1169
+ relinquishLock?: FatalExitStep;
1157
1170
  }): Promise<void> {
1158
1171
  const { logger } = steps;
1159
1172
  await runFatalExitStep(
@@ -1161,11 +1174,7 @@ export async function runFatalExitShutdown(steps: {
1161
1174
  "ai-service integration cache removal",
1162
1175
  steps.removeIntegrationCache,
1163
1176
  );
1164
- await runFatalExitStep(
1165
- logger,
1166
- "lock service shutdown",
1167
- steps.shutdownLockService,
1168
- );
1177
+ await runFatalExitStep(logger, "lock hand-back", steps.relinquishLock);
1169
1178
  await flushTelemetryBeforeExit(logger);
1170
1179
  }
1171
1180
 
@@ -1635,35 +1644,84 @@ export async function dev(options: {
1635
1644
 
1636
1645
  logger.info("Checking if local files are synced with the server");
1637
1646
 
1647
+ const lockSvc = lockService;
1648
+
1649
+ // Nobody takes the lock to boot: nothing startup does is gated on
1650
+ // holding one, and a lock taken before any client exists is held on
1651
+ // behalf of nobody. `LockService` takes it when the first client
1652
+ // attaches, and tells us here if somebody else already has it —
1653
+ // this is the `ConflictError` catch that used to wrap the boot-time
1654
+ // acquire, moved to where acquisition actually happens (ENG-6090).
1655
+ lockSvc.setOnLockConflict(async (error) => {
1656
+ let stopForExtension = false;
1657
+ await tracer.startActiveSpan("handlingLockConflict", async (span) => {
1658
+ try {
1659
+ await handleLockConflict(error, lockSvc, span, logger);
1660
+ } catch (conflictError) {
1661
+ // Reporting is all this owes the caller. The holder is someone
1662
+ // else's live session and the pod is still serving preview, so
1663
+ // there is nothing to tear down — the lock does not decide how
1664
+ // long this process lives (ENG-6032).
1665
+ logger.warn(
1666
+ `Could not take the live-edit lock: ${conflictError instanceof Error ? conflictError.message : String(conflictError)}`,
1667
+ );
1668
+ // Except under the VS Code extension, which reads the `cliprompt`
1669
+ // above, asks the user, and answers by restarting this command
1670
+ // with `--force-takeover`. That hand-off has always ended with
1671
+ // this process gone: it used to abort startup before anything
1672
+ // bound a port. Staying up now would leave the replacement
1673
+ // fighting us for :5173.
1674
+ if (process.env.SUPERBLOCKS_VSCODE === "true") {
1675
+ logger.info(
1676
+ "Stopping so the extension can restart with --force-takeover",
1677
+ );
1678
+ stopForExtension = true;
1679
+ }
1680
+ } finally {
1681
+ span.end();
1682
+ }
1683
+ });
1684
+ if (!stopForExtension) {
1685
+ return;
1686
+ }
1687
+ // Outside the span, so `span.end()` above has already run and the
1688
+ // flush below can carry it. This is the same teardown the two
1689
+ // fatal-exit blocks in `dev()` use, and for the same reason: the
1690
+ // `shutdownAndExit()` that used to be inside the `catch` exits
1691
+ // synchronously from its own `finally`, which made both the
1692
+ // `span.end()` and the telemetry flush unreachable — the trace for
1693
+ // the one event this handler exists to record never left the process.
1694
+ try {
1695
+ await runFatalExitShutdown({
1696
+ logger,
1697
+ removeIntegrationCache: () => aiService?.removeIntegrationCache(),
1698
+ relinquishLock: () => lockSvc.relinquishLock(),
1699
+ });
1700
+ } finally {
1701
+ process.exit(1);
1702
+ }
1703
+ });
1704
+
1638
1705
  try {
1639
1706
  await maskUnixSignals(async () => {
1640
- if (lockService) {
1641
- const lockSvc = lockService;
1707
+ // `--force-takeover` is the one thing that still acquires at boot:
1708
+ // it is an explicit "take it from them now" rather than a pod
1709
+ // helping itself to a lock nobody asked it to hold.
1710
+ if (options.forceTakeover) {
1642
1711
  await tracer.startActiveSpan(
1643
- "acquiringInitialLock",
1712
+ "forcingInitialLockTakeover",
1644
1713
  async (span) => {
1645
1714
  try {
1646
- // If --force-takeover flag is set, immediately force takeover instead of acquiring
1647
- if (options.forceTakeover) {
1648
- logger.info(
1649
- "Force takeover requested, taking over lock...",
1650
- );
1651
- await lockSvc.forceTakeover();
1652
- logger.info(
1653
- "Lock acquired successfully via force takeover.",
1654
- );
1655
- } else {
1656
- await lockSvc.acquireLock();
1657
- }
1715
+ logger.info(
1716
+ "Force takeover requested, taking over lock...",
1717
+ );
1718
+ await lockSvc.forceTakeover();
1719
+ logger.info(
1720
+ "Lock acquired successfully via force takeover.",
1721
+ );
1658
1722
  } catch (error) {
1659
- // If we got a conflict error (409), inspect the lock for more information
1660
- if (error instanceof ConflictError) {
1661
- await handleLockConflict(error, lockSvc, span, logger);
1662
- return;
1663
- }
1664
-
1665
1723
  logger.error(
1666
- "Failed to acquire lock on application",
1724
+ "Failed to force takeover of lock on application",
1667
1725
  getErrorMeta(error),
1668
1726
  );
1669
1727
  span.setStatus({
@@ -2337,7 +2395,7 @@ export async function dev(options: {
2337
2395
  try {
2338
2396
  logger.info("Releasing lock before restarting the dev server");
2339
2397
  await aiService?.removeIntegrationCache();
2340
- await lockService?.shutdown({ serverInitiated: false });
2398
+ await lockService?.relinquishLock();
2341
2399
  } catch (e) {
2342
2400
  logger.error(
2343
2401
  "Error releasing lock before restarting the dev server",
@@ -2414,13 +2472,13 @@ export async function dev(options: {
2414
2472
  }),
2415
2473
  );
2416
2474
  try {
2417
- // `shutdown()`, NOT `shutdownAndExit()`. The latter ends in an
2418
- // unconditional `finally { process.exit(1) }`; `process.exit` is
2419
- // synchronous, so every statement below it would be dead code
2475
+ // `relinquishLock()`, NOT `shutdownAndExit()`. The latter ends in
2476
+ // an unconditional `finally { process.exit(1) }`; `process.exit`
2477
+ // is synchronous, so every statement below it would be dead code
2420
2478
  // whenever `lockService` is set — which is the normal state in a
2421
2479
  // SABS pod, exactly the population this exit path targets. The
2422
- // telemetry flush was silently never running. `shutdown()` does
2423
- // the same teardown without owning the exit, matching how
2480
+ // telemetry flush was silently never running. `relinquishLock()`
2481
+ // does the same teardown without owning the exit, matching how
2424
2482
  // `runGracefulShutdown` in dev-server.mts drives it; the
2425
2483
  // `finally` below performs the exit instead.
2426
2484
  //
@@ -2431,8 +2489,7 @@ export async function dev(options: {
2431
2489
  logger,
2432
2490
  removeIntegrationCache: () =>
2433
2491
  aiService?.removeIntegrationCache(),
2434
- shutdownLockService: () =>
2435
- lockService?.shutdown({ serverInitiated: false }),
2492
+ relinquishLock: () => lockService?.relinquishLock(),
2436
2493
  });
2437
2494
  } finally {
2438
2495
  // The only exit on this path now that the lock service no longer
@@ -2489,15 +2546,14 @@ export async function dev(options: {
2489
2546
  }),
2490
2547
  );
2491
2548
  try {
2492
- // `shutdown()`, NOT `shutdownAndExit()` — same reason as the
2549
+ // `relinquishLock()`, NOT `shutdownAndExit()` — same reason as the
2493
2550
  // sync/lock catch above: `shutdownAndExit()` exits the process
2494
2551
  // synchronously in its own `finally`, which made everything
2495
2552
  // sequenced after it unreachable.
2496
2553
  await runFatalExitShutdown({
2497
2554
  logger,
2498
2555
  removeIntegrationCache: () => aiService?.removeIntegrationCache(),
2499
- shutdownLockService: () =>
2500
- lockService?.shutdown({ serverInitiated: false }),
2556
+ relinquishLock: () => lockService?.relinquishLock(),
2501
2557
  });
2502
2558
  } finally {
2503
2559
  // The only exit on this path; here so a thrown shutdown path can't
@@ -2695,7 +2751,8 @@ export async function dev(options: {
2695
2751
  logger.warn(`Error removing integration cache: ${error}`);
2696
2752
  });
2697
2753
  lockService?.shutdownAndExit().catch(() => {
2698
- // this is redundant, but it's here to make sure the lock service is shutdown and the process exits
2754
+ // Redundant `shutdownAndExit` exits from its own `finally` but
2755
+ // kept so the process still goes away if that ever stops being true.
2699
2756
  process.exit(1);
2700
2757
  });
2701
2758
  });
@@ -3094,7 +3151,7 @@ async function ensureRuntimeDbfsBranchConsistency({
3094
3151
  currentBranchName: string;
3095
3152
  }): Promise<string> {
3096
3153
  interface BranchSwitchingLockService {
3097
- isLocked: boolean;
3154
+ isLockedWhenSettled(): Promise<boolean>;
3098
3155
  switchBranch(
3099
3156
  nextBranchName: string,
3100
3157
  options?: { reacquireLock?: boolean },
@@ -3124,7 +3181,8 @@ async function ensureRuntimeDbfsBranchConsistency({
3124
3181
 
3125
3182
  const lockSvc = lockService as unknown as BranchSwitchingLockService;
3126
3183
  const syncSvc = syncService as unknown as BranchSwitchingSyncService;
3127
- const wasLocked = lockSvc.isLocked;
3184
+ // Retain the pre-switch state for explicit fallback and rollback paths.
3185
+ const wasLocked = await lockSvc.isLockedWhenSettled();
3128
3186
  logger.info(
3129
3187
  `Switching runtime DBFS branch context from '${currentBranchName}' to '${targetBranchName}'`,
3130
3188
  );
@@ -3132,14 +3190,12 @@ async function ensureRuntimeDbfsBranchConsistency({
3132
3190
  try {
3133
3191
  syncSvc.setBranchName(targetBranchName);
3134
3192
  try {
3135
- await lockSvc.switchBranch(targetBranchName, {
3136
- reacquireLock: wasLocked,
3137
- });
3193
+ await lockSvc.switchBranch(targetBranchName);
3138
3194
  } catch (switchError) {
3139
3195
  // Disconnecting native git can invalidate the previous live branch
3140
3196
  // context. Fall back to explicit release/reacquire on the target branch.
3141
3197
  logger.warn(
3142
- `Lock branch transfer failed for '${currentBranchName}' -> '${targetBranchName}', performing shutdown-style lock reset: ${switchError instanceof Error ? switchError.message : String(switchError)}`,
3198
+ `Lock branch transfer failed for '${currentBranchName}' -> '${targetBranchName}', performing an explicit release and reacquire: ${switchError instanceof Error ? switchError.message : String(switchError)}`,
3143
3199
  );
3144
3200
  await lockSvc.switchBranch(targetBranchName, { reacquireLock: false });
3145
3201
  if (wasLocked) {
@@ -3156,7 +3212,7 @@ async function ensureRuntimeDbfsBranchConsistency({
3156
3212
  syncSvc.setBranchName(currentBranchName);
3157
3213
  try {
3158
3214
  await lockSvc.switchBranch(currentBranchName, {
3159
- reacquireLock: lockSvc.isLocked || wasLocked,
3215
+ reacquireLock: (await lockSvc.isLockedWhenSettled()) || wasLocked,
3160
3216
  });
3161
3217
  } catch (rollbackError) {
3162
3218
  logger.warn(
@@ -0,0 +1,89 @@
1
+ import { createServer, type Server } from "node:http";
2
+
3
+ import express from "express";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+
6
+ import { mountDevServerJsonParsers } from "./dev-server.mjs";
7
+
8
+ describe("dev server JSON parser", () => {
9
+ let server: Server | undefined;
10
+
11
+ afterEach(
12
+ () =>
13
+ new Promise<void>((resolve, reject) => {
14
+ if (!server) {
15
+ resolve();
16
+ return;
17
+ }
18
+ server.close((error) => (error ? reject(error) : resolve()));
19
+ }),
20
+ );
21
+
22
+ async function listenWithParsers(
23
+ registerRoute: (app: express.Express) => void,
24
+ ): Promise<number> {
25
+ const app = express();
26
+ mountDevServerJsonParsers(app);
27
+ registerRoute(app);
28
+
29
+ server = createServer(app);
30
+ await new Promise<void>((resolve) => server?.listen(0, resolve));
31
+ const address = server.address();
32
+ if (!address || typeof address === "string") {
33
+ throw new Error("Expected a TCP listener");
34
+ }
35
+ return address.port;
36
+ }
37
+
38
+ it("accepts policy gate assignment bodies larger than 100 KiB", async () => {
39
+ const port = await listenWithParsers((app) => {
40
+ app.post("/_sb_policy_gates/v1/execute", (_req, res) =>
41
+ res.status(204).end(),
42
+ );
43
+ });
44
+
45
+ const response = await fetch(
46
+ `http://127.0.0.1:${port}/_sb_policy_gates/v1/execute`,
47
+ {
48
+ body: JSON.stringify({ scanScopeJson: "x".repeat(256 * 1024) }),
49
+ headers: { "Content-Type": "application/json" },
50
+ method: "POST",
51
+ },
52
+ );
53
+
54
+ expect(response.status).toBe(204);
55
+ });
56
+
57
+ it("rejects policy gate JSON bodies larger than 1 MiB", async () => {
58
+ const port = await listenWithParsers((app) => {
59
+ app.post("/_sb_policy_gates/v1/execute", (_req, res) =>
60
+ res.status(204).end(),
61
+ );
62
+ });
63
+
64
+ const response = await fetch(
65
+ `http://127.0.0.1:${port}/_sb_policy_gates/v1/execute`,
66
+ {
67
+ body: JSON.stringify({ scanScopeJson: "x".repeat(1024 * 1024) }),
68
+ headers: { "Content-Type": "application/json" },
69
+ method: "POST",
70
+ },
71
+ );
72
+
73
+ expect(response.status).toBe(413);
74
+ });
75
+
76
+ it("keeps the 100 KiB default limit on non-policy-gate JSON posts", async () => {
77
+ const port = await listenWithParsers((app) => {
78
+ app.post("/_sb_ai/v1/chat", (_req, res) => res.status(204).end());
79
+ });
80
+
81
+ const response = await fetch(`http://127.0.0.1:${port}/_sb_ai/v1/chat`, {
82
+ body: JSON.stringify({ prompt: "x".repeat(256 * 1024) }),
83
+ headers: { "Content-Type": "application/json" },
84
+ method: "POST",
85
+ });
86
+
87
+ expect(response.status).toBe(413);
88
+ });
89
+ });