@forgezero/agent 0.1.159 → 0.1.161

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.
@@ -1460,7 +1460,7 @@ var UPDATE_RETRY_BASE_MS = 5 * 60000;
1460
1460
  var UPDATE_RETRY_MAX_MS = 24 * 60 * 60000;
1461
1461
 
1462
1462
  // src/version.ts
1463
- var VERSION = "0.1.159";
1463
+ var VERSION = "0.1.161";
1464
1464
 
1465
1465
  // src/software.ts
1466
1466
  var PINNED_BUN_VERSION = "1.3.14";
@@ -3551,12 +3551,23 @@ function renderPlatformActivationFiles(input) {
3551
3551
  throw new Error("Activation root must be absolute and single-line.");
3552
3552
  if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(input.serviceUser))
3553
3553
  throw new Error("Invalid activation service user.");
3554
+ boundedInteger("publicPort", input.publicPort, 1024, 65535);
3554
3555
  boundedInteger("bluePort", input.bluePort, 1024, 65535);
3555
3556
  boundedInteger("greenPort", input.greenPort, 1024, 65535);
3556
- if (input.bluePort === input.greenPort)
3557
- throw new Error("Activation slot ports must differ.");
3558
- if (!/^[A-Za-z0-9.-]{1,253}$/.test(input.seedMeshAddress) || input.seedMeshAddress.startsWith(".") || input.seedMeshAddress.endsWith(".")) {
3559
- throw new Error("Activation seed mesh address is malformed.");
3557
+ if (new Set([input.publicPort, input.bluePort, input.greenPort]).size !== 3) {
3558
+ throw new Error("Activation public and slot ports must differ.");
3559
+ }
3560
+ const runtimeHandoff = input.runtimeHandoff;
3561
+ if (!/^[A-Za-z0-9.-]{1,253}$/.test(runtimeHandoff.address) || runtimeHandoff.address.startsWith(".") || runtimeHandoff.address.endsWith(".")) {
3562
+ throw new Error("Runtime handoff address is malformed.");
3563
+ }
3564
+ for (const [name, path] of Object.entries({
3565
+ residentPath: runtimeHandoff.residentPath,
3566
+ readyPath: runtimeHandoff.readyPath
3567
+ })) {
3568
+ if (!/^\/[A-Za-z0-9/_.-]{1,192}$/.test(path) || path.includes("..")) {
3569
+ throw new Error(`Runtime handoff ${name} is malformed.`);
3570
+ }
3560
3571
  }
3561
3572
  boundedInteger("keepReleases", input.keepReleases, 2, 100);
3562
3573
  const drainDeadlineMs = boundedInteger("drainDeadlineMs", input.drainDeadlineMs ?? 35000, 1000, 300000);
@@ -3566,16 +3577,17 @@ function renderPlatformActivationFiles(input) {
3566
3577
  const environment = [
3567
3578
  `FZ_DIR=${input.root}`,
3568
3579
  `FZ_USER=${input.serviceUser}`,
3580
+ `FZ_PUBLIC_PORT=${input.publicPort}`,
3569
3581
  `FZ_BLUE_PORT=${input.bluePort}`,
3570
3582
  `FZ_GREEN_PORT=${input.greenPort}`,
3571
- `FZ_SEED_MESH_ADDRESS=${input.seedMeshAddress}`,
3583
+ `FZ_RUNTIME_HANDOFF_ADDRESS=${runtimeHandoff.address}`,
3572
3584
  `FZ_HEALTH_PATH=${input.healthPath}`,
3573
3585
  `FZ_KEEP_RELEASES=${input.keepReleases}`,
3574
3586
  `FZ_DRAIN_DEADLINE_MS=${drainDeadlineMs}`
3575
3587
  ].join(`
3576
3588
  `) + `
3577
3589
  `;
3578
- const helper = `${JSON.stringify({ ...input, drainDeadlineMs }, null, 2)}
3590
+ const helper = `${JSON.stringify({ ...input, runtimeHandoff, drainDeadlineMs }, null, 2)}
3579
3591
  `;
3580
3592
  return {
3581
3593
  environment,
@@ -4456,7 +4468,7 @@ function interruptedPlatformReleaseIdentityDigests(config, releaseEvidence, boot
4456
4468
  return [bootstrapIdentityDigest(previous), legacyBootstrapIdentityDigest(previous)];
4457
4469
  }
4458
4470
  function storedPlatformCoordinatesMatch(state, config) {
4459
- return state.kind === "platform" && state.profile === config.profile && state.nodeHostname === config.nodeHostname && state.apiUrl === config.apiUrl && state.environment === config.environment && state.databaseRole === config.database.role && state.databaseAgency === config.database.agency && state.databaseReadPreferred === config.database.readPreferred && state.databaseServerMode === config.database.serverMode && (state.databaseAddress ?? null) === (config.database.address ?? null) && JSON.stringify(state.databaseCoordinators ?? []) === JSON.stringify(config.database.coordinators) && state.collectorUnit === config.runtime.environment.otlpCollectorUnit && state.publicApiPort === config.runtime.environment.publicApiPort && state.healthPath === config.runtime.healthPath;
4471
+ return state.kind === "platform" && state.profile === config.profile && state.nodeHostname === config.nodeHostname && state.apiUrl === config.apiUrl && state.environment === config.environment && state.databaseRole === config.database.role && state.databaseAgency === config.database.agency && state.databaseReadPreferred === config.database.readPreferred && state.databaseServerMode === config.database.serverMode && (state.databaseAddress ?? null) === (config.database.address ?? null) && JSON.stringify(state.databaseCoordinators ?? []) === JSON.stringify(config.database.coordinators) && state.collectorUnit === config.runtime.environment.otlpCollectorUnit && state.publicApiPort === config.runtime.environment.publicApiPort && (state.bluePort === undefined || state.bluePort === config.runtime.bluePort) && (state.greenPort === undefined || state.greenPort === config.runtime.greenPort) && (state.slotsDirectory === undefined || state.slotsDirectory === config.runtime.slotsDirectory) && (state.seedMeshAddress === undefined || state.seedMeshAddress === (config.runtime.environment.databaseAddress ?? config.database.address)) && state.healthPath === config.runtime.healthPath;
4460
4472
  }
4461
4473
  function parseStoredState(raw) {
4462
4474
  let value;
@@ -4535,6 +4547,12 @@ function stateFor(config, cloudflare, previousCloudflareTunnelId) {
4535
4547
  databaseModeEvidence: config.database.role === "none" ? undefined : DB_MODE_EVIDENCE,
4536
4548
  collectorUnit: config.runtime.environment.otlpCollectorUnit,
4537
4549
  publicApiPort: config.runtime.environment.publicApiPort,
4550
+ bluePort: config.runtime.bluePort,
4551
+ greenPort: config.runtime.greenPort,
4552
+ slotsDirectory: config.runtime.slotsDirectory,
4553
+ seedMeshAddress: config.runtime.environment.databaseAddress ?? config.database.address,
4554
+ runtimeHandoffResidentPath: "/_internal/forgezero/seed-mesh/resident",
4555
+ runtimeHandoffReadyPath: "/_internal/forgezero/seed-mesh/ready",
4538
4556
  healthPath: config.runtime.healthPath,
4539
4557
  cloudflare: config.runtime.environment.cloudflare,
4540
4558
  realtime: config.runtime.environment.realtime,
@@ -4616,6 +4634,50 @@ async function bootstrapStatus(host = localBootstrapHost()) {
4616
4634
  services["forgezero@active.service"] = blue.exitCode === 0 || green.exitCode === 0;
4617
4635
  if (!services["forgezero@active.service"])
4618
4636
  problems.push("neither API slot is active");
4637
+ const activeSlots = [
4638
+ ...blue.exitCode === 0 ? ["blue"] : [],
4639
+ ...green.exitCode === 0 ? ["green"] : []
4640
+ ];
4641
+ const activeSlotFile = state.slotsDirectory ? `${state.slotsDirectory}/.active` : "";
4642
+ const recordedSlot = activeSlotFile && host.exists(activeSlotFile) ? host.read(activeSlotFile).trim() : "";
4643
+ const coordinatesValid = Number.isSafeInteger(state.bluePort) && Number.isSafeInteger(state.greenPort) && Boolean(state.slotsDirectory) && Boolean(state.seedMeshAddress) && Boolean(state.healthPath) && Boolean(state.runtimeHandoffResidentPath) && Boolean(state.runtimeHandoffReadyPath);
4644
+ services["platform-active-slot-consistent"] = coordinatesValid && activeSlots.length === 1 && (recordedSlot === "blue" || recordedSlot === "green") && activeSlots[0] === recordedSlot;
4645
+ if (!services["platform-active-slot-consistent"]) {
4646
+ problems.push("active slot marker, service state, or runtime coordinates disagree");
4647
+ }
4648
+ if (services["platform-active-slot-consistent"]) {
4649
+ const slotPort = recordedSlot === "blue" ? state.bluePort : state.greenPort;
4650
+ const directHealth = await host.exec([
4651
+ "curl",
4652
+ "--fail",
4653
+ "--silent",
4654
+ "--show-error",
4655
+ "--max-time",
4656
+ "5",
4657
+ `http://127.0.0.1:${slotPort}${state.healthPath}`
4658
+ ]);
4659
+ services["platform-active-slot-health"] = directHealth.exitCode === 0;
4660
+ if (directHealth.exitCode !== 0)
4661
+ problems.push("active API slot health check failed");
4662
+ const expectedUpstream = `upstream forgezero { server 127.0.0.1:${slotPort}; }
4663
+ `;
4664
+ const upstream = "/etc/nginx/conf.d/forgezero-upstream.conf";
4665
+ services["platform-nginx-active-slot"] = host.exists(upstream) && host.read(upstream) === expectedUpstream;
4666
+ if (!services["platform-nginx-active-slot"])
4667
+ problems.push("nginx upstream does not match the active API slot");
4668
+ const resident = await host.exec([
4669
+ "curl",
4670
+ "--fail",
4671
+ "--silent",
4672
+ "--show-error",
4673
+ "--max-time",
4674
+ "5",
4675
+ `http://${state.seedMeshAddress}:${slotPort}${state.runtimeHandoffResidentPath}`
4676
+ ]);
4677
+ services["platform-vault-resident"] = resident.exitCode === 0;
4678
+ if (resident.exitCode !== 0)
4679
+ problems.push("active API slot has no resident Vault seed");
4680
+ }
4619
4681
  if (!Number.isSafeInteger(state.publicApiPort) || !state.healthPath) {
4620
4682
  problems.push("platform health coordinates are missing from bootstrap state");
4621
4683
  } else {
@@ -4947,9 +5009,14 @@ async function applyBootstrap(input, host = localBootstrapHost(), secrets, depen
4947
5009
  const activation = renderPlatformActivationFiles({
4948
5010
  root: config.deployRoot ?? "/opt/forgezero",
4949
5011
  serviceUser: runtime.serviceUser,
5012
+ publicPort: runtime.environment.publicApiPort,
4950
5013
  bluePort: runtime.bluePort,
4951
5014
  greenPort: runtime.greenPort,
4952
- seedMeshAddress,
5015
+ runtimeHandoff: {
5016
+ address: seedMeshAddress,
5017
+ residentPath: "/_internal/forgezero/seed-mesh/resident",
5018
+ readyPath: "/_internal/forgezero/seed-mesh/ready"
5019
+ },
4953
5020
  healthPath: runtime.healthPath,
4954
5021
  keepReleases: runtime.keepReleases,
4955
5022
  drainDeadlineMs: runtime.environment.drainDeadlineMs
@@ -174,10 +174,19 @@ export declare function planPlatformActivation(releasesDirectory: string, releas
174
174
  export interface PlatformActivationConfig {
175
175
  root: string;
176
176
  serviceUser: string;
177
+ /** Stable loopback listener served by nginx/cloudflared. */
178
+ publicPort: number;
177
179
  bluePort: number;
178
180
  greenPort: number;
179
- /** Private address used by the seed mesh; never exposed through nginx. */
180
- seedMeshAddress: string;
181
+ /**
182
+ * Runtime-owned handoff acknowledgement endpoints. The Agent never reads or
183
+ * transfers runtime state; it only gates traffic switching on these probes.
184
+ */
185
+ runtimeHandoff: {
186
+ address: string;
187
+ residentPath: string;
188
+ readyPath: string;
189
+ };
181
190
  healthPath: string;
182
191
  keepReleases: number;
183
192
  drainDeadlineMs?: number;
@@ -202,6 +211,8 @@ export declare function activatePlatformRelease(config: PlatformActivationConfig
202
211
  exec?: PlatformActivationExec;
203
212
  fetch?: typeof fetch;
204
213
  sleep?: (ms: number) => Promise<void>;
214
+ /** Test seam; production always uses the root-owned nginx include. */
215
+ upstreamPath?: string;
205
216
  }): Promise<{
206
217
  release: string;
207
218
  slot: ApiSlot;
@@ -1164,12 +1164,23 @@ function renderPlatformActivationFiles(input) {
1164
1164
  throw new Error("Activation root must be absolute and single-line.");
1165
1165
  if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(input.serviceUser))
1166
1166
  throw new Error("Invalid activation service user.");
1167
+ boundedInteger("publicPort", input.publicPort, 1024, 65535);
1167
1168
  boundedInteger("bluePort", input.bluePort, 1024, 65535);
1168
1169
  boundedInteger("greenPort", input.greenPort, 1024, 65535);
1169
- if (input.bluePort === input.greenPort)
1170
- throw new Error("Activation slot ports must differ.");
1171
- if (!/^[A-Za-z0-9.-]{1,253}$/.test(input.seedMeshAddress) || input.seedMeshAddress.startsWith(".") || input.seedMeshAddress.endsWith(".")) {
1172
- throw new Error("Activation seed mesh address is malformed.");
1170
+ if (new Set([input.publicPort, input.bluePort, input.greenPort]).size !== 3) {
1171
+ throw new Error("Activation public and slot ports must differ.");
1172
+ }
1173
+ const runtimeHandoff = input.runtimeHandoff;
1174
+ if (!/^[A-Za-z0-9.-]{1,253}$/.test(runtimeHandoff.address) || runtimeHandoff.address.startsWith(".") || runtimeHandoff.address.endsWith(".")) {
1175
+ throw new Error("Runtime handoff address is malformed.");
1176
+ }
1177
+ for (const [name, path2] of Object.entries({
1178
+ residentPath: runtimeHandoff.residentPath,
1179
+ readyPath: runtimeHandoff.readyPath
1180
+ })) {
1181
+ if (!/^\/[A-Za-z0-9/_.-]{1,192}$/.test(path2) || path2.includes("..")) {
1182
+ throw new Error(`Runtime handoff ${name} is malformed.`);
1183
+ }
1173
1184
  }
1174
1185
  boundedInteger("keepReleases", input.keepReleases, 2, 100);
1175
1186
  const drainDeadlineMs = boundedInteger("drainDeadlineMs", input.drainDeadlineMs ?? 35000, 1000, 300000);
@@ -1179,16 +1190,17 @@ function renderPlatformActivationFiles(input) {
1179
1190
  const environment = [
1180
1191
  `FZ_DIR=${input.root}`,
1181
1192
  `FZ_USER=${input.serviceUser}`,
1193
+ `FZ_PUBLIC_PORT=${input.publicPort}`,
1182
1194
  `FZ_BLUE_PORT=${input.bluePort}`,
1183
1195
  `FZ_GREEN_PORT=${input.greenPort}`,
1184
- `FZ_SEED_MESH_ADDRESS=${input.seedMeshAddress}`,
1196
+ `FZ_RUNTIME_HANDOFF_ADDRESS=${runtimeHandoff.address}`,
1185
1197
  `FZ_HEALTH_PATH=${input.healthPath}`,
1186
1198
  `FZ_KEEP_RELEASES=${input.keepReleases}`,
1187
1199
  `FZ_DRAIN_DEADLINE_MS=${drainDeadlineMs}`
1188
1200
  ].join(`
1189
1201
  `) + `
1190
1202
  `;
1191
- const helper = `${JSON.stringify({ ...input, drainDeadlineMs }, null, 2)}
1203
+ const helper = `${JSON.stringify({ ...input, runtimeHandoff, drainDeadlineMs }, null, 2)}
1192
1204
  `;
1193
1205
  return {
1194
1206
  environment,
@@ -1266,27 +1278,66 @@ async function activatePlatformRelease(config, requestedRelease, options = {}) {
1266
1278
  mkdirSync2(slots, { recursive: true, mode: 493 });
1267
1279
  const slotFile = join2(slots, ".active");
1268
1280
  let previousSlot = existsSync2(slotFile) ? readFileSync2(slotFile, "utf8").trim() : undefined;
1269
- if (previousSlot !== "blue" && previousSlot !== "green") {
1281
+ if (previousSlot !== "blue" && previousSlot !== "green")
1270
1282
  previousSlot = undefined;
1271
- for (const candidate of ["blue", "green"]) {
1272
- const active = await exec(["/usr/bin/systemctl", "is-active", "--quiet", `forgezero@${candidate}.service`]);
1273
- if (active.exitCode === 0) {
1274
- previousSlot = candidate;
1275
- break;
1276
- }
1283
+ const slotPort = (slot) => slot === "blue" ? normalized.bluePort : normalized.greenPort;
1284
+ const healthy = async (port2) => {
1285
+ try {
1286
+ const response = await request(`http://127.0.0.1:${port2}${normalized.healthPath}`, {
1287
+ signal: AbortSignal.timeout(2000)
1288
+ });
1289
+ return response.ok;
1290
+ } catch {
1291
+ return false;
1277
1292
  }
1293
+ };
1294
+ const runningHealthySlots = [];
1295
+ for (const candidate of ["blue", "green"]) {
1296
+ const active = await exec(["/usr/bin/systemctl", "is-active", "--quiet", `forgezero@${candidate}.service`]);
1297
+ if (active.exitCode === 0 && await healthy(slotPort(candidate)))
1298
+ runningHealthySlots.push(candidate);
1299
+ }
1300
+ if (previousSlot && !runningHealthySlots.includes(previousSlot)) {
1301
+ throw new Error("active slot evidence disagrees with the running healthy API slot");
1302
+ }
1303
+ if (!previousSlot) {
1304
+ if (runningHealthySlots.length !== 1) {
1305
+ throw new Error("exactly one running healthy API slot is required before promotion");
1306
+ }
1307
+ previousSlot = runningHealthySlots[0];
1278
1308
  }
1279
1309
  const target = previousSlot === "blue" ? "green" : "blue";
1280
- const port = target === "blue" ? normalized.bluePort : normalized.greenPort;
1281
- const meshProbe = async (slotPort, path2) => {
1310
+ const previousPort = slotPort(previousSlot);
1311
+ const port = slotPort(target);
1312
+ const handoffProbe = async (slotPort2, path2) => {
1282
1313
  try {
1283
- const response = await request(`http://${normalized.seedMeshAddress}:${slotPort}/_internal/forgezero/seed-mesh/${path2}`, { signal: AbortSignal.timeout(2000) });
1314
+ const response = await request(`http://${normalized.runtimeHandoff.address}:${slotPort2}${path2}`, { signal: AbortSignal.timeout(2000) });
1284
1315
  return response.ok;
1285
1316
  } catch {
1286
1317
  return false;
1287
1318
  }
1288
1319
  };
1289
- const previousHeldSeed = previousSlot ? await meshProbe(previousSlot === "blue" ? normalized.bluePort : normalized.greenPort, "resident") : false;
1320
+ const upstream = options.upstreamPath ?? "/etc/nginx/conf.d/forgezero-upstream.conf";
1321
+ const renderUpstream = (upstreamPort) => `upstream forgezero { server 127.0.0.1:${upstreamPort}; }
1322
+ `;
1323
+ const reloadNginx = async (upstreamPort) => {
1324
+ const pending = `${upstream}.next`;
1325
+ writeFileSync2(pending, renderUpstream(upstreamPort), { mode: 420, flush: true });
1326
+ renameSync2(pending, upstream);
1327
+ const test = await exec(["/usr/sbin/nginx", "-t"]);
1328
+ const reload = test.exitCode === 0 ? await exec(["/usr/sbin/nginx", "-s", "reload"]) : test;
1329
+ if (reload.exitCode !== 0) {
1330
+ const refusal = (reload.output || test.output).trim().slice(0, 2000);
1331
+ throw new Error(`nginx refused the active-slot upstream${refusal ? `: ${refusal}` : ""}`);
1332
+ }
1333
+ };
1334
+ if (!existsSync2(upstream) || readFileSync2(upstream, "utf8") !== renderUpstream(previousPort)) {
1335
+ await reloadNginx(previousPort);
1336
+ }
1337
+ if (!await healthy(normalized.publicPort)) {
1338
+ throw new Error("stable public listener does not reach the active API slot");
1339
+ }
1340
+ const previousHeldSeed = previousSlot ? await handoffProbe(previousSlot === "blue" ? normalized.bluePort : normalized.greenPort, normalized.runtimeHandoff.residentPath) : false;
1290
1341
  if (previousSlot && !previousHeldSeed) {
1291
1342
  throw new Error("active slot has no resident Vault seed; unlock custody before promotion");
1292
1343
  }
@@ -1307,13 +1358,10 @@ async function activatePlatformRelease(config, requestedRelease, options = {}) {
1307
1358
  replaceLink(targetLink, previousTarget);
1308
1359
  throw new Error("target slot failed to start");
1309
1360
  }
1310
- let healthy = false;
1361
+ let targetHealthy = false;
1311
1362
  for (let attempt = 0;attempt < 30; attempt += 1) {
1312
- try {
1313
- const response = await request(`http://127.0.0.1:${port}${normalized.healthPath}`, { signal: AbortSignal.timeout(2000) });
1314
- healthy = response.ok;
1315
- } catch {}
1316
- if (healthy)
1363
+ targetHealthy = await healthy(port);
1364
+ if (targetHealthy)
1317
1365
  break;
1318
1366
  await sleep(1000);
1319
1367
  }
@@ -1323,14 +1371,18 @@ async function activatePlatformRelease(config, requestedRelease, options = {}) {
1323
1371
  if (previousSlot)
1324
1372
  await exec(["/usr/bin/systemctl", "start", `forgezero@${previousSlot}.service`]);
1325
1373
  };
1326
- if (!healthy) {
1374
+ if (!targetHealthy) {
1327
1375
  await stopTarget();
1328
1376
  throw new Error("target slot failed its health deadline");
1329
1377
  }
1330
1378
  if (previousHeldSeed) {
1331
1379
  let seedReady = false;
1332
1380
  for (let attempt = 0;attempt < 30; attempt += 1) {
1333
- seedReady = await meshProbe(port, "ready");
1381
+ const [resident, converged] = await Promise.all([
1382
+ handoffProbe(port, normalized.runtimeHandoff.residentPath),
1383
+ handoffProbe(port, normalized.runtimeHandoff.readyPath)
1384
+ ]);
1385
+ seedReady = resident && converged;
1334
1386
  if (seedReady)
1335
1387
  break;
1336
1388
  await sleep(1000);
@@ -1340,18 +1392,24 @@ async function activatePlatformRelease(config, requestedRelease, options = {}) {
1340
1392
  throw new Error("target slot failed RAM-seed handover deadline");
1341
1393
  }
1342
1394
  }
1343
- const upstream = "/etc/nginx/conf.d/forgezero-upstream.conf";
1344
1395
  const backup = `${upstream}.forgezero-backup`;
1345
1396
  if (existsSync2(upstream))
1346
1397
  copyFileSync2(upstream, backup);
1347
1398
  else
1348
1399
  rmSync2(backup, { force: true });
1349
- writeFileSync2(upstream, `upstream forgezero { server 127.0.0.1:${port}; }
1350
- `, { mode: 420 });
1351
- const test = await exec(["/usr/sbin/nginx", "-t"]);
1352
- const reload = test.exitCode === 0 ? await exec(["/usr/sbin/nginx", "-s", "reload"]) : test;
1353
- if (reload.exitCode !== 0) {
1354
- const refusal = (reload.output || test.output).trim().slice(0, 2000);
1400
+ try {
1401
+ await reloadNginx(port);
1402
+ } catch (cause) {
1403
+ if (existsSync2(backup))
1404
+ renameSync2(backup, upstream);
1405
+ else
1406
+ rmSync2(upstream, { force: true });
1407
+ await exec(["/usr/sbin/nginx", "-t"]);
1408
+ await exec(["/usr/sbin/nginx", "-s", "reload"]);
1409
+ await stopTarget();
1410
+ throw cause;
1411
+ }
1412
+ if (!await healthy(normalized.publicPort)) {
1355
1413
  if (existsSync2(backup))
1356
1414
  renameSync2(backup, upstream);
1357
1415
  else
@@ -1359,14 +1417,19 @@ async function activatePlatformRelease(config, requestedRelease, options = {}) {
1359
1417
  await exec(["/usr/sbin/nginx", "-t"]);
1360
1418
  await exec(["/usr/sbin/nginx", "-s", "reload"]);
1361
1419
  await stopTarget();
1362
- throw new Error(`nginx refused the promoted upstream${refusal ? `: ${refusal}` : ""}`);
1420
+ throw new Error("stable public listener failed after the slot switch");
1363
1421
  }
1364
1422
  rmSync2(backup, { force: true });
1365
1423
  writeFileSync2(slotFile, `${target}
1366
1424
  `, { mode: 420 });
1367
1425
  if (previousSlot && previousSlot !== target) {
1368
- await sleep(normalized.drainDeadlineMs);
1369
- await exec(["/usr/bin/systemctl", "stop", `forgezero@${previousSlot}.service`]);
1426
+ const previousService = `forgezero@${previousSlot}.service`;
1427
+ const stopped = await exec(["/usr/bin/systemctl", "stop", previousService]);
1428
+ const inactive = await exec(["/usr/bin/systemctl", "is-active", "--quiet", previousService]);
1429
+ const status = await exec(["/usr/bin/systemctl", "show", "--property=ExecMainStatus", "--value", previousService]);
1430
+ if (stopped.exitCode !== 0 || inactive.exitCode === 0 || status.exitCode !== 0 || status.output.trim() !== "0") {
1431
+ throw new Error("old API slot failed its clean request and job drain; target remains active");
1432
+ }
1370
1433
  }
1371
1434
  const old = readdirSync(releases, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join2(releases, entry.name)).sort((left, right) => statSync2(right).mtimeMs - statSync2(left).mtimeMs).slice(normalized.keepReleases);
1372
1435
  for (const path2 of old)