@tridha643/hestia 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/daemon.js CHANGED
@@ -6947,7 +6947,7 @@ var require_public_api = __commonJS((exports) => {
6947
6947
  // packages/engine/src/daemon/main.ts
6948
6948
  import { chmodSync as chmodSync6, rmSync as rmSync11 } from "fs";
6949
6949
  import { randomBytes } from "crypto";
6950
- import { join as join25 } from "path";
6950
+ import { join as join26 } from "path";
6951
6951
 
6952
6952
  // packages/session-broker-core/src/limits.ts
6953
6953
  var MAX_HTTP_BODY_BYTES = 4 * 1024 * 1024;
@@ -7893,7 +7893,7 @@ function parsePidfile(source, path) {
7893
7893
  if (pidfile.schemaVersion !== undefined && pidfile.schemaVersion !== STATE_SCHEMA_VERSION) {
7894
7894
  throw new HestiaError("state-corrupt", `invalid pidfile ${path}: unsupported schemaVersion`, { path });
7895
7895
  }
7896
- if (typeof pidfile.name !== "string" || !Number.isInteger(pidfile.pid) || typeof pidfile.startTime !== "string" || typeof pidfile.logPath !== "string") {
7896
+ if (typeof pidfile.name !== "string" || !Number.isSafeInteger(pidfile.pid) || pidfile.pid <= 0 || typeof pidfile.startTime !== "string" || typeof pidfile.logPath !== "string") {
7897
7897
  throw new HestiaError("state-corrupt", `invalid pidfile ${path}: missing process identity`, { path });
7898
7898
  }
7899
7899
  if (pidfile.schemaVersion === STATE_SCHEMA_VERSION && typeof pidfile.specFingerprint !== "string") {
@@ -7911,12 +7911,25 @@ function removePidfile(worktreeRoot, name) {
7911
7911
  rmSync2(pidfilePath(worktreeRoot, name), { force: true });
7912
7912
  }
7913
7913
  function listPidfiles(dir) {
7914
+ const scan = scanPidfiles(dir);
7915
+ if (scan.errors[0] !== undefined)
7916
+ throw scan.errors[0];
7917
+ return scan.pidfiles;
7918
+ }
7919
+ function scanPidfiles(dir) {
7914
7920
  if (!existsSync(dir))
7915
- return [];
7916
- return readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => {
7917
- const path = join(dir, f);
7918
- return parsePidfile(readFileSync(path, "utf8"), path);
7919
- });
7921
+ return { pidfiles: [], errors: [] };
7922
+ const pidfiles = [];
7923
+ const errors = [];
7924
+ for (const file of readdirSync(dir).filter((candidate) => candidate.endsWith(".json"))) {
7925
+ const path = join(dir, file);
7926
+ try {
7927
+ pidfiles.push(parsePidfile(readFileSync(path, "utf8"), path));
7928
+ } catch (error) {
7929
+ errors.push(error);
7930
+ }
7931
+ }
7932
+ return { pidfiles, errors };
7920
7933
  }
7921
7934
  function procSpecFingerprint(spec) {
7922
7935
  const env = Object.entries(spec.env ?? {}).sort(([left], [right]) => left.localeCompare(right));
@@ -7929,23 +7942,28 @@ function procSpecFingerprint(spec) {
7929
7942
  backend: spec.backend ?? "proc",
7930
7943
  resolver: spec.varlock ? "varlock" : "direct",
7931
7944
  inspectorPort: spec.inspectorPort ?? null,
7945
+ healthPath: spec.healthPath ?? null,
7932
7946
  configPath: spec.configPath ?? null,
7933
7947
  originService: spec.originService ?? null,
7934
7948
  originEndpoint: spec.originEndpoint ?? null
7935
7949
  });
7936
7950
  return createHash2("sha256").update(canonical).digest("hex");
7937
7951
  }
7938
- function readProcessStartTime(pid, env) {
7952
+ function probeProcessStartTime(pid, env) {
7939
7953
  try {
7940
7954
  const out = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
7941
7955
  encoding: "utf8",
7942
7956
  env
7943
7957
  }).trim();
7944
- return out === "" ? null : out;
7958
+ return { status: "ok", value: out === "" ? null : out };
7945
7959
  } catch {
7946
- return null;
7960
+ return { status: "error" };
7947
7961
  }
7948
7962
  }
7963
+ function readProcessStartTime(pid, env) {
7964
+ const result = probeProcessStartTime(pid, env);
7965
+ return result.status === "ok" ? result.value : null;
7966
+ }
7949
7967
  function startTimeOf(pid) {
7950
7968
  return readProcessStartTime(pid, { ...process.env, LC_ALL: "C", LANG: "C" });
7951
7969
  }
@@ -8009,30 +8027,40 @@ function plausibleProcessLocales(canonicalStartTime, recordedStartTime) {
8009
8027
  });
8010
8028
  return [...new Set(plausible)];
8011
8029
  }
8012
- function legacyLocalizedStartTimeMatches(pid, canonicalStartTime, recordedStartTime) {
8030
+ function probeLegacyLocalizedStartTime(pid, canonicalStartTime, recordedStartTime) {
8013
8031
  const cacheKey = `${pid}\x00${canonicalStartTime}\x00${recordedStartTime}`;
8014
8032
  const cached = legacyStartTimeMatches.get(cacheKey);
8015
8033
  if (cached !== undefined)
8016
- return cached;
8034
+ return cached ? "live" : "dead";
8017
8035
  let matched = false;
8036
+ let readFailed = false;
8018
8037
  for (const locale of plausibleProcessLocales(canonicalStartTime, recordedStartTime)) {
8019
- const localized = readProcessStartTime(pid, {
8038
+ const read = probeProcessStartTime(pid, {
8020
8039
  ...process.env,
8021
8040
  LC_ALL: locale,
8022
8041
  LANG: locale
8023
8042
  });
8024
- if (localized !== null && processStartTimeMatches(localized, recordedStartTime)) {
8043
+ if (read.status === "error") {
8044
+ readFailed = true;
8045
+ continue;
8046
+ }
8047
+ if (read.value !== null && processStartTimeMatches(read.value, recordedStartTime)) {
8025
8048
  matched = true;
8026
8049
  break;
8027
8050
  }
8028
8051
  }
8052
+ if (!matched && readFailed)
8053
+ return "unknown";
8029
8054
  if (legacyStartTimeMatches.size >= 512) {
8030
8055
  const oldest = legacyStartTimeMatches.keys().next().value;
8031
8056
  if (oldest !== undefined)
8032
8057
  legacyStartTimeMatches.delete(oldest);
8033
8058
  }
8034
8059
  legacyStartTimeMatches.set(cacheKey, matched);
8035
- return matched;
8060
+ return matched ? "live" : "dead";
8061
+ }
8062
+ function legacyLocalizedStartTimeMatches(pid, canonicalStartTime, recordedStartTime) {
8063
+ return probeLegacyLocalizedStartTime(pid, canonicalStartTime, recordedStartTime) === "live";
8036
8064
  }
8037
8065
  function processStartTimeMatches(current, recorded) {
8038
8066
  if (current === recorded)
@@ -8054,6 +8082,27 @@ function isLive(pf) {
8054
8082
  return true;
8055
8083
  return current !== null && legacyLocalizedStartTimeMatches(pf.pid, current, pf.startTime);
8056
8084
  }
8085
+ function probeProcessIdentity(pf) {
8086
+ try {
8087
+ process.kill(pf.pid, 0);
8088
+ } catch (error) {
8089
+ return error.code === "ESRCH" ? "dead" : "unknown";
8090
+ }
8091
+ let current;
8092
+ try {
8093
+ current = execFileSync("ps", ["-o", "lstart=", "-p", String(pf.pid)], {
8094
+ encoding: "utf8",
8095
+ env: { ...process.env, LC_ALL: "C", LANG: "C" }
8096
+ }).trim();
8097
+ } catch {
8098
+ return "unknown";
8099
+ }
8100
+ if (current === "")
8101
+ return "dead";
8102
+ if (processStartTimeMatches(current, pf.startTime))
8103
+ return "live";
8104
+ return probeLegacyLocalizedStartTime(pf.pid, current, pf.startTime);
8105
+ }
8057
8106
 
8058
8107
  // packages/engine/src/proc/lock.ts
8059
8108
  var RETRY_MS = 100;
@@ -8208,6 +8257,13 @@ function readMirrorState(project) {
8208
8257
  chmodSync3(p, 384);
8209
8258
  return record;
8210
8259
  }
8260
+ function readMirrorStateSafe(project) {
8261
+ try {
8262
+ return { status: "ok", record: readMirrorState(project) };
8263
+ } catch (error) {
8264
+ return { status: "error", error };
8265
+ }
8266
+ }
8211
8267
  function clearState(worktreeRoot, project) {
8212
8268
  const p = statePath(worktreeRoot);
8213
8269
  if (existsSync2(p))
@@ -8221,7 +8277,7 @@ function clearState(worktreeRoot, project) {
8221
8277
  import { existsSync as existsSync21, rmSync as rmSync10 } from "fs";
8222
8278
  import { execFile as execFile10 } from "child_process";
8223
8279
  import { promisify as promisify9 } from "util";
8224
- import { join as join24, resolve as resolve2 } from "path";
8280
+ import { join as join25, resolve as resolve3 } from "path";
8225
8281
  import { createHash as createHash7 } from "crypto";
8226
8282
  import { resolve4, resolve6, resolveCname } from "dns/promises";
8227
8283
 
@@ -8543,6 +8599,27 @@ import { existsSync as existsSync4, readFileSync as readFileSync6 } from "fs";
8543
8599
  import { join as join6 } from "path";
8544
8600
  var WORKLOAD_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
8545
8601
  var BINDING = /^(?:main|[1-9][0-9]{0,4})\/(?:tcp|udp)$/;
8602
+ var ENVIRONMENT_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
8603
+ function parseEnvironment(value, path) {
8604
+ if (value === undefined)
8605
+ return {};
8606
+ const environment = {};
8607
+ for (const [name, rawValue] of Object.entries(table(value, path))) {
8608
+ if (!ENVIRONMENT_KEY.test(name))
8609
+ throw new Error(`${path} has invalid environment key ${JSON.stringify(name)}`);
8610
+ if (typeof rawValue === "string") {
8611
+ environment[name] = rawValue;
8612
+ continue;
8613
+ }
8614
+ const fileValue = table(rawValue, `${path}.${name}`);
8615
+ knownKeys(fileValue, ["file"], `${path}.${name}`);
8616
+ if (typeof fileValue.file !== "string" || !/^\.hestia\//.test(fileValue.file) || fileValue.file.includes("..")) {
8617
+ throw new Error(`${path}.${name}.file must be a path below .hestia/`);
8618
+ }
8619
+ environment[name] = { file: fileValue.file };
8620
+ }
8621
+ return environment;
8622
+ }
8546
8623
  function emptyConfig() {
8547
8624
  return { version: 1, workloads: {} };
8548
8625
  }
@@ -8602,7 +8679,19 @@ function parseRepositoryWorkloadConfig(source, path) {
8602
8679
  if (!WORKLOAD_NAME.test(name))
8603
8680
  throw new Error(`invalid workload name ${JSON.stringify(name)}`);
8604
8681
  const workload = table(rawWorkload, `workloads.${name}`);
8605
- knownKeys(workload, ["source", "compose_service", "dockerfile", "command", "wrangler_config", "port", "endpoints"], `workloads.${name}`);
8682
+ knownKeys(workload, [
8683
+ "source",
8684
+ "compose_service",
8685
+ "dockerfile",
8686
+ "command",
8687
+ "wrangler_config",
8688
+ "port",
8689
+ "cwd",
8690
+ "varlock",
8691
+ "health_path",
8692
+ "env",
8693
+ "endpoints"
8694
+ ], `workloads.${name}`);
8606
8695
  if (!["compose", "dockerfile", "proc", "wrangler"].includes(workload.source)) {
8607
8696
  throw new Error(`workloads.${name}.source must be compose, dockerfile, proc, or wrangler`);
8608
8697
  }
@@ -8618,6 +8707,18 @@ function parseRepositoryWorkloadConfig(source, path) {
8618
8707
  throw new Error(`workloads.${name}.${key} must be a string`);
8619
8708
  }
8620
8709
  }
8710
+ if (workload.cwd !== undefined && typeof workload.cwd !== "string") {
8711
+ throw new Error(`workloads.${name}.cwd must be a string`);
8712
+ }
8713
+ if (workload.varlock !== undefined && typeof workload.varlock !== "boolean") {
8714
+ throw new Error(`workloads.${name}.varlock must be a boolean`);
8715
+ }
8716
+ if (workload.health_path !== undefined && (typeof workload.health_path !== "string" || !workload.health_path.startsWith("/"))) {
8717
+ throw new Error(`workloads.${name}.health_path must start with /`);
8718
+ }
8719
+ if (workload.health_path !== undefined && workload.port === "none") {
8720
+ throw new Error(`workloads.${name}.health_path requires a port`);
8721
+ }
8621
8722
  if (sourceKind === "compose" && typeof workload.compose_service !== "string") {
8622
8723
  throw new Error(`workloads.${name}.compose_service is required for compose`);
8623
8724
  }
@@ -8631,6 +8732,10 @@ function parseRepositoryWorkloadConfig(source, path) {
8631
8732
  command: workload.command,
8632
8733
  wranglerConfig: workload.wrangler_config,
8633
8734
  port: workload.port,
8735
+ cwd: workload.cwd,
8736
+ varlock: workload.varlock,
8737
+ healthPath: workload.health_path,
8738
+ env: parseEnvironment(workload.env, `workloads.${name}.env`),
8634
8739
  endpoints: parseEndpoints(workload.endpoints, `workloads.${name}.endpoints`)
8635
8740
  };
8636
8741
  }
@@ -8689,7 +8794,12 @@ function sameEndpoint(left, right) {
8689
8794
  return left.binding === right.binding && left.kind === right.kind && left.local === right.local;
8690
8795
  }
8691
8796
  function cloneWorkload(workload) {
8692
- return { ...workload, command: workload.command?.slice(), endpoints: { ...workload.endpoints } };
8797
+ return {
8798
+ ...workload,
8799
+ command: workload.command?.slice(),
8800
+ env: { ...workload.env },
8801
+ endpoints: { ...workload.endpoints }
8802
+ };
8693
8803
  }
8694
8804
  function mergeConfigLayers(repository, machine) {
8695
8805
  const workloads = {};
@@ -8715,6 +8825,10 @@ function mergeConfigLayers(repository, machine) {
8715
8825
  conflicts.push(`machine workload ${name} cannot replace committed source ${committed.source} with ${overlay.source}`);
8716
8826
  continue;
8717
8827
  }
8828
+ committed.cwd = overlay.cwd ?? committed.cwd;
8829
+ committed.varlock = overlay.varlock ?? committed.varlock;
8830
+ committed.healthPath = overlay.healthPath ?? committed.healthPath;
8831
+ committed.env = { ...committed.env, ...overlay.env };
8718
8832
  for (const [alias, endpoint] of Object.entries(overlay.endpoints)) {
8719
8833
  const existing = committed.endpoints[alias];
8720
8834
  if (existing !== undefined && !sameEndpoint(existing, endpoint)) {
@@ -8993,7 +9107,7 @@ async function composeUp(ctx, services) {
8993
9107
  async function composeDown(ctx, destroy) {
8994
9108
  const rest = ["down", "--remove-orphans"];
8995
9109
  if (destroy)
8996
- rest.push("-v");
9110
+ rest.push("-v", "--rmi", "local");
8997
9111
  await docker(ctx, rest);
8998
9112
  }
8999
9113
  async function composePs(ctx) {
@@ -9074,7 +9188,7 @@ function allocatePort() {
9074
9188
  });
9075
9189
  });
9076
9190
  }
9077
- function psSnapshot() {
9191
+ function tryPsSnapshot() {
9078
9192
  try {
9079
9193
  const out = execFileSync2("ps", ["-A", "-o", "pid=,pgid=,ppid="], {
9080
9194
  encoding: "utf8",
@@ -9089,11 +9203,10 @@ function psSnapshot() {
9089
9203
  }
9090
9204
  return rows;
9091
9205
  } catch {
9092
- return [];
9206
+ return null;
9093
9207
  }
9094
9208
  }
9095
- function processTree(rootPid) {
9096
- const rows = psSnapshot();
9209
+ function processTreeFromSnapshot(rootPid, rows) {
9097
9210
  const byParent = new Map;
9098
9211
  for (const r of rows) {
9099
9212
  const list = byParent.get(r.ppid) ?? [];
@@ -9116,6 +9229,9 @@ function processTree(rootPid) {
9116
9229
  }
9117
9230
  return tree;
9118
9231
  }
9232
+ function processTree(rootPid) {
9233
+ return processTreeFromSnapshot(rootPid, tryPsSnapshot() ?? []);
9234
+ }
9119
9235
  function parseLsof(out) {
9120
9236
  const listeners = [];
9121
9237
  let pid = 0;
@@ -9152,12 +9268,12 @@ async function allListeners() {
9152
9268
  return parseLsof(stdout);
9153
9269
  } catch (err) {
9154
9270
  const e = err;
9155
- if (typeof e.stdout === "string" && e.code !== "ENOENT") {
9271
+ if (typeof e.stdout === "string" && e.code === 1 && (e.stderr ?? "").trim() === "") {
9156
9272
  tool = "lsof";
9157
9273
  return parseLsof(e.stdout);
9158
9274
  }
9159
- if (tool === "lsof")
9160
- return [];
9275
+ if (e.code !== "ENOENT")
9276
+ throw err;
9161
9277
  }
9162
9278
  }
9163
9279
  try {
@@ -9166,16 +9282,24 @@ async function allListeners() {
9166
9282
  });
9167
9283
  tool = "ss";
9168
9284
  return parseSs(stdout);
9169
- } catch {
9285
+ } catch (error) {
9286
+ if (error.code !== "ENOENT")
9287
+ throw error;
9170
9288
  throw new HestiaError("ownership-tool-missing", "neither lsof nor ss is available \u2014 cannot verify port ownership " + "(required: tools like next dev silently bind a different port when " + "theirs is taken, so a bare listen-check would report the wrong port healthy)");
9171
9289
  }
9172
9290
  }
9173
9291
  async function inspectPort(rootPid, port) {
9174
- const members = new Set(processTree(rootPid).map((r) => r.pid));
9292
+ const firstSnapshot = tryPsSnapshot();
9293
+ if (firstSnapshot === null)
9294
+ throw new Error("Port ownership process-tree inspection failed: ps unavailable");
9295
+ const members = new Set(processTreeFromSnapshot(rootPid, firstSnapshot).map((r) => r.pid));
9175
9296
  const listeners = await allListeners();
9176
9297
  const owner = listeners.find((l) => l.port === port);
9177
9298
  if (owner !== undefined && !members.has(owner.pid)) {
9178
- for (const member of processTree(rootPid))
9299
+ const secondSnapshot = tryPsSnapshot();
9300
+ if (secondSnapshot === null)
9301
+ throw new Error("Port ownership process-tree recheck failed: ps unavailable");
9302
+ for (const member of processTreeFromSnapshot(rootPid, secondSnapshot))
9179
9303
  members.add(member.pid);
9180
9304
  }
9181
9305
  return {
@@ -9237,19 +9361,32 @@ async function stopProcTree(t, graceMs = GRACE_MS) {
9237
9361
 
9238
9362
  // packages/engine/src/proc/resolver.ts
9239
9363
  import { existsSync as existsSync6 } from "fs";
9240
- import { join as join8 } from "path";
9241
- function detectVarlock(worktreeRoot) {
9242
- const schema = join8(worktreeRoot, ".env.schema");
9243
- const bin = join8(worktreeRoot, "node_modules", ".bin", "varlock");
9244
- return existsSync6(schema) && existsSync6(bin) ? bin : null;
9364
+ import { dirname as dirname3, join as join8, resolve as resolve2 } from "path";
9365
+ function detectVarlock(worktreeRoot, workingDirectory = worktreeRoot) {
9366
+ const root = resolve2(worktreeRoot);
9367
+ const cwd = resolve2(workingDirectory);
9368
+ if (!existsSync6(join8(cwd, ".env.schema")))
9369
+ return null;
9370
+ let directory = cwd;
9371
+ for (;; ) {
9372
+ const bin = join8(directory, "node_modules", ".bin", "varlock");
9373
+ if (existsSync6(bin))
9374
+ return bin;
9375
+ if (directory === root)
9376
+ return null;
9377
+ const parent = dirname3(directory);
9378
+ if (parent === directory || !parent.startsWith(root))
9379
+ return null;
9380
+ directory = parent;
9381
+ }
9245
9382
  }
9246
9383
  function wrapWithVarlock(bin, argv) {
9247
9384
  return [bin, "run", "--no-redact-stdout", "--", ...argv];
9248
9385
  }
9249
- function requireVarlock(worktreeRoot) {
9250
- const bin = detectVarlock(worktreeRoot);
9386
+ function requireVarlock(worktreeRoot, workingDirectory = worktreeRoot) {
9387
+ const bin = detectVarlock(worktreeRoot, workingDirectory);
9251
9388
  if (bin === null) {
9252
- throw new HestiaError("varlock-missing", `--varlock requires both ${join8(worktreeRoot, ".env.schema")} and a local ` + `node_modules/.bin/varlock in the worktree`);
9389
+ throw new HestiaError("varlock-missing", `--varlock requires ${join8(workingDirectory, ".env.schema")} and a local ` + `node_modules/.bin/varlock between that directory and the worktree root`);
9253
9390
  }
9254
9391
  return bin;
9255
9392
  }
@@ -9281,7 +9418,7 @@ function spawnOnce(argv, cwd, env, logPath, attempt) {
9281
9418
  closeSync2(openProcAttemptLog(logPath, attempt));
9282
9419
  const relaySpec = Buffer.from(JSON.stringify({ argv, cwd, env, logPath })).toString("base64");
9283
9420
  const relayFd = openSync2(logPath, "a", 384);
9284
- return new Promise((resolve2, reject) => {
9421
+ return new Promise((resolve3, reject) => {
9285
9422
  const child = spawn(process.execPath, [PROC_RELAY_ENTRYPOINT], {
9286
9423
  cwd,
9287
9424
  env: { ...process.env, [RELAY_SPEC_ENV]: relaySpec },
@@ -9295,7 +9432,7 @@ function spawnOnce(argv, cwd, env, logPath, attempt) {
9295
9432
  child.once("spawn", () => {
9296
9433
  closeSync2(relayFd);
9297
9434
  child.unref();
9298
- resolve2(child.pid);
9435
+ resolve3(child.pid);
9299
9436
  });
9300
9437
  });
9301
9438
  }
@@ -9312,18 +9449,22 @@ async function startProc(worktreeRoot, spec, stackEnv, mirrorPidfile2) {
9312
9449
  const cwd = spec.cwd ? join9(worktreeRoot, spec.cwd) : worktreeRoot;
9313
9450
  const wantPort = spec.port !== "none";
9314
9451
  const readyTimeoutMs = spec.readyTimeoutMs ?? READY_TIMEOUT_MS;
9315
- const varlockBin = spec.varlock ? requireVarlock(worktreeRoot) : null;
9452
+ const varlockBin = spec.varlock ? requireVarlock(worktreeRoot, cwd) : null;
9316
9453
  let lastFailure = "";
9317
9454
  for (let attempt = 1;attempt <= MAX_ATTEMPTS; attempt++) {
9318
9455
  const port = wantPort ? await allocatePort() : undefined;
9319
9456
  let argv = port === undefined ? spec.argv : substitutePort(spec.argv, port);
9320
9457
  if (varlockBin !== null)
9321
9458
  argv = wrapWithVarlock(varlockBin, argv);
9459
+ const resolvedSpecEnvironment = port === undefined ? spec.env : Object.fromEntries(Object.entries(spec.env ?? {}).map(([name, value]) => [
9460
+ name,
9461
+ substitutePort([value], port)[0]
9462
+ ]));
9322
9463
  const env = {
9323
9464
  ...process.env,
9324
9465
  ...stackEnv,
9325
9466
  ...port !== undefined ? { PORT: String(port), [`HESTIA_${envKey(spec.name)}_PORT`]: String(port) } : {},
9326
- ...spec.env
9467
+ ...resolvedSpecEnvironment
9327
9468
  };
9328
9469
  const pid = await spawnOnce(argv, cwd, env, logPath, attempt);
9329
9470
  const startTime = startTimeOf(pid) ?? "";
@@ -9343,7 +9484,7 @@ async function startProc(worktreeRoot, spec, stackEnv, mirrorPidfile2) {
9343
9484
  };
9344
9485
  writePidfile(worktreeRoot, pf);
9345
9486
  mirrorPidfile2?.(pf);
9346
- const outcome = await waitUntilReady(pf, port, readyTimeoutMs);
9487
+ const outcome = await waitUntilReady(pf, port, readyTimeoutMs, spec.healthPath);
9347
9488
  const record = {
9348
9489
  name: spec.name,
9349
9490
  backend: pf.backend,
@@ -9379,7 +9520,7 @@ async function startProc(worktreeRoot, spec, stackEnv, mirrorPidfile2) {
9379
9520
  continue;
9380
9521
  case "timeout": {
9381
9522
  record.state = "unhealthy";
9382
- const bound = outcome.memberPorts.length > 0 ? `it is listening on port(s) ${outcome.memberPorts.join(", ")} instead` : `it never opened a listening socket (if it isn't a server, use --no-port)`;
9523
+ const bound = outcome.healthPending ? `it owned port ${port}, but ${spec.healthPath} never returned a 2xx response` : outcome.memberPorts.length > 0 ? `it is listening on port(s) ${outcome.memberPorts.join(", ")} instead` : `it never opened a listening socket (if it isn't a server, use --no-port)`;
9383
9524
  return {
9384
9525
  record,
9385
9526
  pidfile: pf,
@@ -9390,33 +9531,46 @@ async function startProc(worktreeRoot, spec, stackEnv, mirrorPidfile2) {
9390
9531
  }
9391
9532
  throw new HestiaError("port-allocation-failed", `could not hold a port for "${spec.name}" after ${MAX_ATTEMPTS} attempts (${lastFailure})`);
9392
9533
  }
9393
- async function waitUntilReady(pf, port, timeoutMs) {
9534
+ async function waitUntilReady(pf, port, timeoutMs, healthPath) {
9394
9535
  const deadline = Date.now() + timeoutMs;
9395
9536
  if (port === undefined) {
9396
9537
  await new Promise((r) => setTimeout(r, NO_PORT_GRACE_MS));
9397
9538
  return isLive(pf) ? { kind: "ready" } : { kind: "exited" };
9398
9539
  }
9399
9540
  let memberPorts = [];
9541
+ let ownershipProven = false;
9400
9542
  while (Date.now() < deadline) {
9401
9543
  if (!isLive(pf))
9402
9544
  return { kind: "exited" };
9403
- const view = await inspectPort(pf.pgid, port);
9404
- memberPorts = view.memberPorts;
9405
- if (view.ownerIsMember)
9406
- return { kind: "ready" };
9407
- if (view.owner !== undefined) {
9408
- return { kind: "stolen", byPid: view.owner.pid };
9545
+ if (!ownershipProven) {
9546
+ const view = await inspectPort(pf.pgid, port);
9547
+ memberPorts = view.memberPorts;
9548
+ if (view.ownerIsMember)
9549
+ ownershipProven = true;
9550
+ else if (view.owner !== undefined)
9551
+ return { kind: "stolen", byPid: view.owner.pid };
9552
+ }
9553
+ if (ownershipProven) {
9554
+ if (healthPath === undefined)
9555
+ return { kind: "ready" };
9556
+ try {
9557
+ const response = await fetch(`http://127.0.0.1:${port}${healthPath}`);
9558
+ if (response.ok)
9559
+ return { kind: "ready" };
9560
+ } catch {}
9409
9561
  }
9410
9562
  await new Promise((r) => setTimeout(r, POLL_MS2));
9411
9563
  }
9412
- return { kind: "timeout", memberPorts };
9564
+ return {
9565
+ kind: "timeout",
9566
+ memberPorts,
9567
+ healthPending: healthPath !== undefined && ownershipProven
9568
+ };
9413
9569
  }
9414
9570
 
9415
9571
  // packages/engine/src/router/local-http-router.ts
9416
- import { execFile as execFile7 } from "child_process";
9417
9572
  import { Agent, createServer as createServer2, request as httpRequest } from "http";
9418
9573
  import { createConnection, createServer as createNetServer } from "net";
9419
- import { promisify as promisify6 } from "util";
9420
9574
  import { existsSync as existsSync14, readFileSync as readFileSync14, readdirSync as readdirSync5 } from "fs";
9421
9575
  import { chmodSync as chmodSync5, lstatSync, mkdirSync as mkdirSync7, rmSync as rmSync9 } from "fs";
9422
9576
  import { tmpdir } from "os";
@@ -9630,7 +9784,7 @@ function resolveLocalRouteHostnames(records, config = readHestiaMachineConfig().
9630
9784
  import { execFile as execFile6, spawnSync } from "child_process";
9631
9785
  import { existsSync as existsSync12, readFileSync as readFileSync12, rmSync as rmSync7, statSync } from "fs";
9632
9786
  import { promisify as promisify5 } from "util";
9633
- import { dirname as dirname4, join as join15 } from "path";
9787
+ import { dirname as dirname5, join as join15 } from "path";
9634
9788
 
9635
9789
  // packages/engine/src/daemon/ensure.ts
9636
9790
  import { closeSync as closeSync3, openSync as openSync3 } from "fs";
@@ -9693,12 +9847,12 @@ function resolveMaxStacks() {
9693
9847
  }
9694
9848
  return { maxStacks: DEFAULT_MAX_STACKS, warnings };
9695
9849
  }
9696
- var dockerProbe = (project) => new Promise((resolve2) => {
9850
+ var dockerProbe = (project) => new Promise((resolve3) => {
9697
9851
  execFile5("docker", ["ps", "-q", "--filter", `label=${LABELS.stack}=${project}`], { timeout: DOCKER_PROBE_TIMEOUT_MS }, (err, stdout) => {
9698
9852
  if (err)
9699
- resolve2(null);
9853
+ resolve3(null);
9700
9854
  else
9701
- resolve2(stdout.trim() === "" ? "dead" : "live");
9855
+ resolve3(stdout.trim() === "" ? "dead" : "live");
9702
9856
  });
9703
9857
  });
9704
9858
 
@@ -9929,7 +10083,7 @@ async function releaseSharedForProject(port, project, service) {
9929
10083
  import { existsSync as existsSync11, mkdirSync as mkdirSync6, readFileSync as readFileSync11, rmSync as rmSync6 } from "fs";
9930
10084
  import { execFileSync as execFileSync3 } from "child_process";
9931
10085
  import { homedir as homedir2 } from "os";
9932
- import { dirname as dirname3, join as join13 } from "path";
10086
+ import { dirname as dirname4, join as join13 } from "path";
9933
10087
  import { fileURLToPath } from "url";
9934
10088
  var LAUNCHD_LABEL = "dev.hestia.daemon";
9935
10089
  function launchAgentsDir() {
@@ -9939,7 +10093,7 @@ function plistPath() {
9939
10093
  return join13(launchAgentsDir(), `${LAUNCHD_LABEL}.plist`);
9940
10094
  }
9941
10095
  function daemonMainPath() {
9942
- const bundled = join13(dirname3(fileURLToPath(import.meta.url)), "daemon.js");
10096
+ const bundled = join13(dirname4(fileURLToPath(import.meta.url)), "daemon.js");
9943
10097
  if (existsSync11(bundled))
9944
10098
  return bundled;
9945
10099
  return fileURLToPath(new URL("./main.ts", import.meta.url));
@@ -9976,10 +10130,10 @@ function kickstart() {
9976
10130
  }
9977
10131
 
9978
10132
  // packages/engine/src/daemon/routes.ts
9979
- var HESTIAD_PROTOCOL_VERSION = 5;
10133
+ var HESTIAD_PROTOCOL_VERSION = 6;
9980
10134
  var MAX_JSON_BODY_BYTES = 16 * 1024;
9981
10135
  var MAX_LOG_STREAMS = 16;
9982
- var MAX_LOG_TAIL = 200;
10136
+ var MAX_LOG_TAIL = 2000;
9983
10137
  function admissionIdentity(identity2) {
9984
10138
  if (typeof identity2 !== "string")
9985
10139
  return identity2;
@@ -10016,14 +10170,14 @@ class Admission {
10016
10170
  const first = await this.#locked(() => this.#try(identity2, holder));
10017
10171
  if (first.granted || waitMs <= 0)
10018
10172
  return first;
10019
- return new Promise((resolve2) => {
10173
+ return new Promise((resolve3) => {
10020
10174
  const waiter = {
10021
10175
  identity: identity2,
10022
10176
  holder,
10023
- resolve: resolve2,
10177
+ resolve: resolve3,
10024
10178
  timer: setTimeout(() => {
10025
10179
  this.#queue = this.#queue.filter((candidate) => candidate !== waiter);
10026
- resolve2({ granted: false, live: first.live });
10180
+ resolve3({ granted: false, live: first.live });
10027
10181
  }, waitMs)
10028
10182
  };
10029
10183
  this.#queue.push(waiter);
@@ -10085,11 +10239,11 @@ class Admission {
10085
10239
  return { granted: false, live: occupancy.live };
10086
10240
  }
10087
10241
  async#pump() {
10088
- if (this.#queue.length === 0)
10089
- return;
10090
10242
  const { maxStacks } = resolveMaxStacks();
10091
10243
  const occupancy = await this.ledger.occupancy();
10092
10244
  this.#cache(occupancy);
10245
+ if (this.#queue.length === 0)
10246
+ return;
10093
10247
  let used = occupancy.live.length + occupancy.reserved.length;
10094
10248
  const granted = [];
10095
10249
  for (const waiter of this.#queue) {
@@ -10716,8 +10870,86 @@ async function updateSharedHostname(name, mutate) {
10716
10870
  });
10717
10871
  }
10718
10872
 
10719
- // packages/engine/src/router/local-http-router.ts
10873
+ // packages/engine/src/router/origin-liveness.ts
10874
+ import { execFile as execFile7 } from "child_process";
10875
+ import { promisify as promisify6 } from "util";
10720
10876
  var pexec6 = promisify6(execFile7);
10877
+ async function probeDockerOrigin(target, port) {
10878
+ try {
10879
+ const service = target.service;
10880
+ const args = [
10881
+ "ps",
10882
+ "--no-trunc",
10883
+ "--format",
10884
+ "{{.ID}}\t{{.Ports}}",
10885
+ "--filter",
10886
+ `label=dev.hestia.stack=${target.project}`,
10887
+ "--filter",
10888
+ `label=com.docker.compose.service=${service.name}`
10889
+ ];
10890
+ const { stdout } = await pexec6("docker", args, { timeout: 2000 });
10891
+ const matches = stdout.split(`
10892
+ `).some((line) => {
10893
+ const [id, ports = ""] = line.split("\t");
10894
+ const identityMatches = service.containerId === undefined || id?.startsWith(service.containerId);
10895
+ return identityMatches && new RegExp(`(?:127\\.0\\.0\\.1|0\\.0\\.0\\.0):${port}->`).test(ports);
10896
+ });
10897
+ return matches ? "live" : "dead";
10898
+ } catch {
10899
+ return "unknown";
10900
+ }
10901
+ }
10902
+ async function probeProcOrigin(service, port) {
10903
+ if (service.pid === undefined || service.startTime === undefined)
10904
+ return "dead";
10905
+ if (!Number.isSafeInteger(service.pid) || service.pid <= 0 || typeof service.startTime !== "string") {
10906
+ throw new Error("Route origin has an invalid process identity");
10907
+ }
10908
+ const identity2 = probeProcessIdentity({ pid: service.pid, startTime: service.startTime });
10909
+ if (identity2 !== "live")
10910
+ return identity2;
10911
+ try {
10912
+ return (await inspectPort(service.pid, port)).ownerIsMember ? "live" : "dead";
10913
+ } catch {
10914
+ return "unknown";
10915
+ }
10916
+ }
10917
+ async function probeLocalRouterTarget(target) {
10918
+ const service = target.service;
10919
+ const port = service?.publishedPort;
10920
+ if (service === undefined || port === undefined || service.backend === "tunnel")
10921
+ return "dead";
10922
+ return service.backend === "docker" ? await probeDockerOrigin(target, port) : await probeProcOrigin(service, port);
10923
+ }
10924
+ async function verifyLocalRouterTarget(target) {
10925
+ const port = target.service?.publishedPort;
10926
+ try {
10927
+ return port !== undefined && await probeLocalRouterTarget(target) === "live" ? port : null;
10928
+ } catch {
10929
+ return null;
10930
+ }
10931
+ }
10932
+ async function verifyStackServiceOrigin(record, service, publishedPort = service.publishedPort) {
10933
+ return await verifyLocalRouterTarget({
10934
+ project: record.project,
10935
+ service: { ...service, publishedPort }
10936
+ }) !== null;
10937
+ }
10938
+ function resolveSharedContractOrigin(mirror, contractService) {
10939
+ const endpoint = mirror.endpoints.find((candidate) => (candidate.alias ?? candidate.name) === contractService);
10940
+ const service = mirror.services.find((candidate) => candidate.name === (endpoint?.workload ?? endpoint?.name));
10941
+ const binding = service?.bindings?.find((candidate) => `${candidate.target}/${candidate.protocol}` === endpoint?.binding);
10942
+ const publishedPort = binding?.publishedPort ?? service?.publishedPort;
10943
+ return service === undefined || service.backend === "tunnel" || publishedPort === undefined ? undefined : { ...service, publishedPort };
10944
+ }
10945
+ async function probeSharedHolderOrigin(mirror, contractService) {
10946
+ const service = resolveSharedContractOrigin(mirror, contractService);
10947
+ if (service === undefined)
10948
+ return "unknown";
10949
+ return await probeLocalRouterTarget({ project: mirror.project, service });
10950
+ }
10951
+
10952
+ // packages/engine/src/router/local-http-router.ts
10721
10953
  var ROUTE_REFRESH_MS = 1000;
10722
10954
  var MAX_MIRROR_BYTES = 2 * 1024 * 1024;
10723
10955
  var MAX_REQUEST_HEADER_BYTES = 64 * 1024;
@@ -10800,55 +11032,6 @@ function forwardedHeaders(request, originPort) {
10800
11032
  headers["x-forwarded-for"] = remote;
10801
11033
  return headers;
10802
11034
  }
10803
- async function verifyDockerRouteTarget(target, port) {
10804
- try {
10805
- const service = target.service;
10806
- const args = [
10807
- "ps",
10808
- "--no-trunc",
10809
- "--format",
10810
- "{{.ID}}\t{{.Ports}}",
10811
- "--filter",
10812
- `label=dev.hestia.stack=${target.project}`,
10813
- "--filter",
10814
- `label=com.docker.compose.service=${service.name}`
10815
- ];
10816
- const { stdout } = await pexec6("docker", args, { timeout: 2000 });
10817
- return stdout.split(`
10818
- `).some((line) => {
10819
- const [id, ports = ""] = line.split("\t");
10820
- const identityMatches = service.containerId === undefined || id?.startsWith(service.containerId);
10821
- return identityMatches && new RegExp(`(?:127\\.0\\.0\\.1|0\\.0\\.0\\.0):${port}->`).test(ports);
10822
- });
10823
- } catch {
10824
- return false;
10825
- }
10826
- }
10827
- async function verifyLocalRouterTarget(target) {
10828
- const service = target.service;
10829
- const port = service?.publishedPort;
10830
- if (service === undefined || port === undefined || service.backend === "tunnel")
10831
- return null;
10832
- if (service.backend === "docker") {
10833
- return await verifyDockerRouteTarget(target, port) ? port : null;
10834
- }
10835
- if (service.pid === undefined || service.startTime === undefined)
10836
- return null;
10837
- if (!isLive({ pid: service.pid, startTime: service.startTime }))
10838
- return null;
10839
- try {
10840
- return (await inspectPort(service.pid, port)).ownerIsMember ? port : null;
10841
- } catch {
10842
- return null;
10843
- }
10844
- }
10845
- async function verifyStackServiceOrigin(record, service, publishedPort = service.publishedPort) {
10846
- return await verifyLocalRouterTarget({
10847
- hostname: "",
10848
- project: record.project,
10849
- service: { ...service, publishedPort }
10850
- }) !== null;
10851
- }
10852
11035
  function targetIdentity(target) {
10853
11036
  const service = target.service;
10854
11037
  return [
@@ -10936,27 +11119,27 @@ class HestiaLocalHttpRouter {
10936
11119
  #sharedRoutes = new Map;
10937
11120
  #timer;
10938
11121
  async start() {
10939
- await new Promise((resolve2, reject) => {
11122
+ await new Promise((resolve3, reject) => {
10940
11123
  this.#server.once("error", reject);
10941
11124
  this.#server.listen(0, "127.0.0.1", () => {
10942
11125
  this.#server.off("error", reject);
10943
- resolve2();
11126
+ resolve3();
10944
11127
  });
10945
11128
  });
10946
11129
  prepareGatewaySocket(this.socketPath);
10947
- await new Promise((resolve2, reject) => {
11130
+ await new Promise((resolve3, reject) => {
10948
11131
  this.#unixServer.once("error", reject);
10949
11132
  this.#unixServer.listen(this.socketPath, () => {
10950
11133
  this.#unixServer.off("error", reject);
10951
11134
  chmodSync5(this.socketPath, 384);
10952
- resolve2();
11135
+ resolve3();
10953
11136
  });
10954
11137
  });
10955
- await new Promise((resolve2, reject) => {
11138
+ await new Promise((resolve3, reject) => {
10956
11139
  this.#frontServer.once("error", reject);
10957
11140
  this.#frontServer.listen(0, "127.0.0.1", () => {
10958
11141
  this.#frontServer.off("error", reject);
10959
- resolve2();
11142
+ resolve3();
10960
11143
  });
10961
11144
  });
10962
11145
  await this.refreshRoutes();
@@ -11045,14 +11228,12 @@ class HestiaLocalHttpRouter {
11045
11228
  let target;
11046
11229
  if (holder !== undefined) {
11047
11230
  const stack = records.find((record) => record.project === holder.project);
11048
- const endpoint = stack?.endpoints.find((candidate2) => (candidate2.alias ?? candidate2.name) === holder.service);
11049
- const service = stack?.services.find((candidate2) => candidate2.name === (endpoint?.workload ?? endpoint?.name));
11050
- const binding = service?.bindings?.find((candidate2) => `${candidate2.target}/${candidate2.protocol}` === endpoint?.binding);
11051
- const publishedPort = binding?.publishedPort ?? service?.publishedPort;
11231
+ const service = stack === undefined ? undefined : resolveSharedContractOrigin(stack, holder.service);
11052
11232
  const candidate = {
11053
11233
  hostname,
11054
11234
  project: holder.project,
11055
- service: service === undefined || service.backend === "tunnel" || publishedPort === undefined ? undefined : { ...service, publishedPort }
11235
+ sharedHolder: holder.project,
11236
+ service
11056
11237
  };
11057
11238
  const previous = this.#sharedRoutes.get(hostname)?.find((entry) => entry.name === shared.name)?.target;
11058
11239
  target = previous !== undefined && targetIdentity(previous) === targetIdentity(candidate) ? previous : candidate;
@@ -11154,7 +11335,10 @@ Connection: close\r
11154
11335
  };
11155
11336
  }
11156
11337
  if (entry.target?.service?.publishedPort === undefined) {
11157
- return { status: 503, message: "Hestia route origin unavailable" };
11338
+ return {
11339
+ status: 503,
11340
+ message: `Hestia route origin unavailable \u2014 held by ${entry.target?.project ?? "unknown"}`
11341
+ };
11158
11342
  }
11159
11343
  return { target: entry.target };
11160
11344
  }
@@ -11176,7 +11360,8 @@ Connection: close\r
11176
11360
  const target = match.target;
11177
11361
  const port = target.service.publishedPort;
11178
11362
  if (await verifyLocalRouterTarget(target) !== port) {
11179
- return sendRouterResponse(response, 503, "Hestia route origin unavailable");
11363
+ const suffix = target.sharedHolder === undefined ? "" : ` \u2014 held by ${target.sharedHolder}`;
11364
+ return sendRouterResponse(response, 503, `Hestia route origin unavailable${suffix}`);
11180
11365
  }
11181
11366
  const proxy = httpRequest({
11182
11367
  hostname: "127.0.0.1",
@@ -11198,7 +11383,8 @@ Connection: close\r
11198
11383
  proxy.on("error", (error) => {
11199
11384
  if (!response.headersSent) {
11200
11385
  const ownershipFailure = error instanceof OriginOwnershipError;
11201
- sendRouterResponse(response, ownershipFailure ? 503 : 502, ownershipFailure ? "Hestia route origin unavailable" : "Hestia route origin failed");
11386
+ const suffix = target.sharedHolder === undefined ? "" : ` \u2014 held by ${target.sharedHolder}`;
11387
+ sendRouterResponse(response, ownershipFailure ? 503 : 502, ownershipFailure ? `Hestia route origin unavailable${suffix}` : "Hestia route origin failed");
11202
11388
  } else
11203
11389
  response.destroy();
11204
11390
  });
@@ -11276,16 +11462,54 @@ Connection: close\r
11276
11462
  }
11277
11463
  }
11278
11464
 
11465
+ // packages/engine/src/configured-workload-environment.ts
11466
+ import { readFileSync as readFileSync15 } from "fs";
11467
+ import { join as join18 } from "path";
11468
+ var ENDPOINT_TEMPLATE = /\$\{endpoint:([^}]+)\.(host|port|url)\}/g;
11469
+ function endpointTemplateValue(endpoint, field) {
11470
+ const value = endpoint[field];
11471
+ if (value === undefined) {
11472
+ throw new HestiaError("config-invalid", `endpoint template requests ${endpoint.name}.${field}, but that endpoint has no ${field}`);
11473
+ }
11474
+ return String(value);
11475
+ }
11476
+ function resolveEndpointTemplate(template, record) {
11477
+ const resolved = template.replace(ENDPOINT_TEMPLATE, (_match, endpointName, field) => {
11478
+ const endpoint = record.endpoints.find((candidate) => candidate.name === endpointName);
11479
+ if (endpoint === undefined) {
11480
+ throw new HestiaError("config-invalid", `endpoint template references ${JSON.stringify(endpointName)} before that endpoint is available`);
11481
+ }
11482
+ return endpointTemplateValue(endpoint, field);
11483
+ });
11484
+ if (resolved.includes("${endpoint:")) {
11485
+ throw new HestiaError("config-invalid", `malformed endpoint template ${JSON.stringify(template)}`);
11486
+ }
11487
+ return resolved;
11488
+ }
11489
+ function readIgnoredEnvironmentFile(worktreeRoot, file) {
11490
+ try {
11491
+ return readFileSync15(join18(worktreeRoot, file), "utf8").replace(/\r?\n$/, "");
11492
+ } catch (error) {
11493
+ throw new HestiaError("config-invalid", `cannot read configured environment file ${file}: ${error.message}`, { path: join18(worktreeRoot, file) });
11494
+ }
11495
+ }
11496
+ function resolveConfiguredEnvironment(worktreeRoot, configured, record) {
11497
+ return Object.fromEntries(Object.entries(configured).map(([name, value]) => {
11498
+ const raw = typeof value === "string" ? value : readIgnoredEnvironmentFile(worktreeRoot, value.file);
11499
+ return [name, resolveEndpointTemplate(raw, record)];
11500
+ }));
11501
+ }
11502
+
11279
11503
  // packages/engine/src/wrangler/adapter.ts
11280
- import { existsSync as existsSync15, readFileSync as readFileSync15, statSync as statSync2 } from "fs";
11504
+ import { existsSync as existsSync15, readFileSync as readFileSync16, statSync as statSync2 } from "fs";
11281
11505
  import { homedir as homedir3 } from "os";
11282
- import { dirname as dirname5, join as join18, relative as relative3 } from "path";
11506
+ import { dirname as dirname6, join as join19, relative as relative3 } from "path";
11283
11507
  import { connect } from "net";
11284
11508
  function privateRegistryDir(worktreeRoot) {
11285
- return join18(worktreeRoot, ".hestia", "wrangler-registry");
11509
+ return join19(worktreeRoot, ".hestia", "wrangler-registry");
11286
11510
  }
11287
11511
  function globalRegistryDir() {
11288
- return process.env.WRANGLER_REGISTRY_PATH ?? join18(homedir3(), ".wrangler", "config", "registry");
11512
+ return process.env.WRANGLER_REGISTRY_PATH ?? join19(homedir3(), ".wrangler", "config", "registry");
11289
11513
  }
11290
11514
  var HEARTBEAT_FRESH_MS = 60000;
11291
11515
  function tcpConnectable(address, timeoutMs = 500) {
@@ -11293,15 +11517,15 @@ function tcpConnectable(address, timeoutMs = 500) {
11293
11517
  const port = Number(portStr);
11294
11518
  if (!host || !Number.isFinite(port))
11295
11519
  return Promise.resolve(false);
11296
- return new Promise((resolve2) => {
11520
+ return new Promise((resolve3) => {
11297
11521
  const sock = connect({ host, port, timeout: timeoutMs });
11298
11522
  sock.once("connect", () => {
11299
11523
  sock.destroy();
11300
- resolve2(true);
11524
+ resolve3(true);
11301
11525
  });
11302
11526
  const dead = () => {
11303
11527
  sock.destroy();
11304
- resolve2(false);
11528
+ resolve3(false);
11305
11529
  };
11306
11530
  sock.once("error", dead);
11307
11531
  sock.once("timeout", dead);
@@ -11314,14 +11538,14 @@ async function preflightForeignSessions(workers) {
11314
11538
  for (const w of workers) {
11315
11539
  if (w.name === null)
11316
11540
  continue;
11317
- const entryPath = join18(dir, w.name);
11541
+ const entryPath = join19(dir, w.name);
11318
11542
  if (!existsSync15(entryPath))
11319
11543
  continue;
11320
11544
  const freshMs = Date.now() - statSync2(entryPath).mtimeMs;
11321
11545
  let live = freshMs < HEARTBEAT_FRESH_MS;
11322
11546
  if (!live) {
11323
11547
  try {
11324
- const entry = JSON.parse(readFileSync15(entryPath, "utf8"));
11548
+ const entry = JSON.parse(readFileSync16(entryPath, "utf8"));
11325
11549
  live = entry.debugPortAddress ? await tcpConnectable(entry.debugPortAddress) : false;
11326
11550
  } catch {
11327
11551
  live = false;
@@ -11342,14 +11566,14 @@ async function planWorkers(worktreeRoot, opts) {
11342
11566
  throw new HestiaError("no-workers-found", opts.filter.length > 0 ? `no wrangler configs match --workers=${opts.filter.join(",")}` : `no wrangler.{jsonc,json,toml} found in ${worktreeRoot}`);
11343
11567
  }
11344
11568
  const wranglerBinFor = (configPath) => {
11345
- let dir = dirname5(configPath);
11569
+ let dir = dirname6(configPath);
11346
11570
  for (;; ) {
11347
- const candidate = join18(dir, "node_modules", ".bin", "wrangler");
11571
+ const candidate = join19(dir, "node_modules", ".bin", "wrangler");
11348
11572
  if (existsSync15(candidate))
11349
11573
  return candidate;
11350
11574
  if (dir === worktreeRoot)
11351
11575
  return null;
11352
- const parent = dirname5(dir);
11576
+ const parent = dirname6(dir);
11353
11577
  if (parent === dir)
11354
11578
  return null;
11355
11579
  dir = parent;
@@ -11381,6 +11605,7 @@ async function planWorkers(worktreeRoot, opts) {
11381
11605
  throw new HestiaError("wrangler-missing", `no local wrangler for ${relative3(worktreeRoot, w.configPath)} ` + `(searched node_modules/.bin from the config dir up to the worktree ` + `root) \u2014 hestia never uses a global install`);
11382
11606
  }
11383
11607
  const inspectorPort = await allocatePort();
11608
+ const workerCwd = dirname6(w.configPath);
11384
11609
  specs.push({
11385
11610
  name: w.name,
11386
11611
  argv: [
@@ -11398,9 +11623,10 @@ async function planWorkers(worktreeRoot, opts) {
11398
11623
  WRANGLER_REGISTRY_PATH: registry,
11399
11624
  MINIFLARE_REGISTRY_PATH: registry
11400
11625
  },
11626
+ cwd: relative3(worktreeRoot, workerCwd),
11401
11627
  port: "auto",
11402
11628
  signal: "int",
11403
- varlock: opts.varlock,
11629
+ varlock: opts.varlock && detectVarlock(worktreeRoot, workerCwd) !== null,
11404
11630
  backend: "wrangler",
11405
11631
  inspectorPort,
11406
11632
  configPath: w.configPath
@@ -11441,14 +11667,14 @@ import { execFile as execFile8 } from "child_process";
11441
11667
  import { promisify as promisify7 } from "util";
11442
11668
  import { existsSync as existsSync17 } from "fs";
11443
11669
  import { homedir as homedir4 } from "os";
11444
- import { join as join19 } from "path";
11670
+ import { join as join20 } from "path";
11445
11671
  var pexec7 = promisify7(execFile8);
11446
11672
  var BIN = "cloudflared";
11447
11673
  function cloudflaredHome() {
11448
- return process.env.HESTIA_CLOUDFLARED_HOME ?? join19(homedir4(), ".cloudflared");
11674
+ return process.env.HESTIA_CLOUDFLARED_HOME ?? join20(homedir4(), ".cloudflared");
11449
11675
  }
11450
11676
  function credFilePath(uuid) {
11451
- return process.env.TUNNEL_CRED_FILE ?? join19(cloudflaredHome(), `${uuid}.json`);
11677
+ return process.env.TUNNEL_CRED_FILE ?? join20(cloudflaredHome(), `${uuid}.json`);
11452
11678
  }
11453
11679
  async function run(args, timeoutMs = 30000) {
11454
11680
  try {
@@ -11485,8 +11711,8 @@ async function adoptTunnel(name) {
11485
11711
 
11486
11712
  // packages/engine/src/tunnel/ingress.ts
11487
11713
  import { createHash as createHash6 } from "crypto";
11488
- import { existsSync as existsSync18, readFileSync as readFileSync16 } from "fs";
11489
- import { join as join20 } from "path";
11714
+ import { existsSync as existsSync18, readFileSync as readFileSync17 } from "fs";
11715
+ import { join as join21 } from "path";
11490
11716
  var LABEL_MAX = 63;
11491
11717
  var HASH_LEN = 6;
11492
11718
  function hostnameFor(tunnelName, branch, service, zone) {
@@ -11506,12 +11732,12 @@ function inferZone(baseRules) {
11506
11732
  return zones.size === 1 ? [...zones][0] : undefined;
11507
11733
  }
11508
11734
  function importBaseRules(uuid, name) {
11509
- const p = join20(cloudflaredHome(), "config.yml");
11735
+ const p = join21(cloudflaredHome(), "config.yml");
11510
11736
  if (!existsSync18(p))
11511
11737
  return [];
11512
11738
  let parsed;
11513
11739
  try {
11514
- parsed = $parse(readFileSync16(p, "utf8"));
11740
+ parsed = $parse(readFileSync17(p, "utf8"));
11515
11741
  } catch {
11516
11742
  return [];
11517
11743
  }
@@ -11576,16 +11802,16 @@ function assertDisjoint(base, dynamic, shared) {
11576
11802
  import {
11577
11803
  existsSync as existsSync19,
11578
11804
  mkdirSync as mkdirSync8,
11579
- readFileSync as readFileSync17,
11805
+ readFileSync as readFileSync18,
11580
11806
  readdirSync as readdirSync7
11581
11807
  } from "fs";
11582
- import { join as join22 } from "path";
11808
+ import { join as join23 } from "path";
11583
11809
 
11584
11810
  // packages/engine/src/tunnel/orphans.ts
11585
11811
  import { execFileSync as execFileSync4 } from "child_process";
11586
- import { join as join21 } from "path";
11812
+ import { join as join22 } from "path";
11587
11813
  function hestiaTunnelMarker(uuid) {
11588
- return join21(hestiaHome(), "tunnel", uuid);
11814
+ return join22(hestiaHome(), "tunnel", uuid);
11589
11815
  }
11590
11816
  function parseLocalHestiaConnectors(psOutput, marker) {
11591
11817
  const rows = [];
@@ -11706,13 +11932,13 @@ async function quickTunnelUrl(metricsPort, timeoutMs) {
11706
11932
  var CONNECTOR = "connector";
11707
11933
  var READY_TIMEOUT_MS2 = 30000;
11708
11934
  function tunnelDir(uuid) {
11709
- return join22(hestiaHome(), "tunnel", uuid);
11935
+ return join23(hestiaHome(), "tunnel", uuid);
11710
11936
  }
11711
11937
  function configPath(uuid) {
11712
- return join22(tunnelDir(uuid), "config.yml");
11938
+ return join23(tunnelDir(uuid), "config.yml");
11713
11939
  }
11714
11940
  function adoptedMarker(uuid) {
11715
- return join22(tunnelDir(uuid), "adopted.json");
11941
+ return join23(tunnelDir(uuid), "adopted.json");
11716
11942
  }
11717
11943
  function isAdopted(uuid) {
11718
11944
  return existsSync19(adoptedMarker(uuid));
@@ -11723,7 +11949,7 @@ function readAdopted(uuid) {
11723
11949
  return null;
11724
11950
  let marker;
11725
11951
  try {
11726
- marker = JSON.parse(readFileSync17(p, "utf8"));
11952
+ marker = JSON.parse(readFileSync18(p, "utf8"));
11727
11953
  } catch {
11728
11954
  marker = {};
11729
11955
  }
@@ -11731,14 +11957,14 @@ function readAdopted(uuid) {
11731
11957
  return { uuid, name: marker.name, credFile: marker.credFile, reconstructed: false };
11732
11958
  }
11733
11959
  let name;
11734
- const stacksDir = join22(hestiaHome(), "stacks");
11960
+ const stacksDir = join23(hestiaHome(), "stacks");
11735
11961
  if (existsSync19(stacksDir)) {
11736
11962
  for (const project of readdirSync7(stacksDir)) {
11737
- const sp = join22(stacksDir, project, "stack.json");
11963
+ const sp = join23(stacksDir, project, "stack.json");
11738
11964
  if (!existsSync19(sp))
11739
11965
  continue;
11740
11966
  try {
11741
- const record = parseStackRecord(readFileSync17(sp, "utf8"), sp);
11967
+ const record = parseStackRecord(readFileSync18(sp, "utf8"), sp);
11742
11968
  if (record.tunnel?.uuid === uuid) {
11743
11969
  name = record.tunnel.name;
11744
11970
  break;
@@ -11754,24 +11980,24 @@ function readAdopted(uuid) {
11754
11980
  };
11755
11981
  }
11756
11982
  function listAdopted() {
11757
- const root = join22(hestiaHome(), "tunnel");
11983
+ const root = join23(hestiaHome(), "tunnel");
11758
11984
  if (!existsSync19(root))
11759
11985
  return [];
11760
11986
  return readdirSync7(root).filter((uuid) => isAdopted(uuid));
11761
11987
  }
11762
11988
  function collectDynamicRules(uuid) {
11763
- const stacksDir = join22(hestiaHome(), "stacks");
11989
+ const stacksDir = join23(hestiaHome(), "stacks");
11764
11990
  const rules = [];
11765
11991
  const dropped = [];
11766
11992
  if (!existsSync19(stacksDir))
11767
11993
  return { rules, dropped };
11768
11994
  for (const project of readdirSync7(stacksDir)) {
11769
- const p = join22(stacksDir, project, "stack.json");
11995
+ const p = join23(stacksDir, project, "stack.json");
11770
11996
  if (!existsSync19(p))
11771
11997
  continue;
11772
11998
  let record;
11773
11999
  try {
11774
- record = parseStackRecord(readFileSync17(p, "utf8"), p);
12000
+ record = parseStackRecord(readFileSync18(p, "utf8"), p);
11775
12001
  } catch {
11776
12002
  continue;
11777
12003
  }
@@ -11815,7 +12041,7 @@ async function reconcileTunnel(ref, opts) {
11815
12041
  if (reapedGroups > 0) {
11816
12042
  warnings.push(`reaped ${reapedGroups} orphan hestia connector process group(s) for this tunnel`);
11817
12043
  }
11818
- const currentConfig = existsSync19(cfgPath) ? readFileSync17(cfgPath, "utf8") : null;
12044
+ const currentConfig = existsSync19(cfgPath) ? readFileSync18(cfgPath, "utf8") : null;
11819
12045
  if (live && currentConfig === nextConfig) {
11820
12046
  return { restarted: false, metricsPort: pf.port, ready: true, warnings };
11821
12047
  }
@@ -11968,7 +12194,7 @@ async function* composeLogLines(project, service, options = {}) {
11968
12194
  args.push("--follow");
11969
12195
  args.push(service);
11970
12196
  const child = spawn3("docker", args, { stdio: ["ignore", "pipe", "pipe"] });
11971
- const completion = new Promise((resolve2) => child.once("close", resolve2));
12197
+ const completion = new Promise((resolve3) => child.once("close", resolve3));
11972
12198
  let stderr = "";
11973
12199
  let spawnErrorMessage;
11974
12200
  child.stderr.setEncoding("utf8");
@@ -12059,12 +12285,12 @@ function readLastLineEventsBeforeOffset(path, count, endOffset) {
12059
12285
  function waitForLogPoll(signal) {
12060
12286
  if (signal?.aborted)
12061
12287
  return Promise.resolve();
12062
- return new Promise((resolve2) => {
12288
+ return new Promise((resolve3) => {
12063
12289
  const timer = setTimeout(done, LOG_POLL_MS);
12064
12290
  function done() {
12065
12291
  clearTimeout(timer);
12066
12292
  signal?.removeEventListener("abort", done);
12067
- resolve2();
12293
+ resolve3();
12068
12294
  }
12069
12295
  signal?.addEventListener("abort", done, { once: true });
12070
12296
  });
@@ -12167,10 +12393,10 @@ class BoundedLogMergeQueue {
12167
12393
  }
12168
12394
  async push(item, signal) {
12169
12395
  while (!this.#closed && !signal?.aborted && this.#items.length >= this.capacity) {
12170
- await new Promise((resolve2) => {
12396
+ await new Promise((resolve3) => {
12171
12397
  const done = () => {
12172
12398
  signal?.removeEventListener("abort", done);
12173
- resolve2();
12399
+ resolve3();
12174
12400
  };
12175
12401
  this.#capacityWaiters.push(done);
12176
12402
  signal?.addEventListener("abort", done, { once: true });
@@ -12193,7 +12419,7 @@ class BoundedLogMergeQueue {
12193
12419
  }
12194
12420
  if (this.#closed)
12195
12421
  return Promise.resolve({ done: true });
12196
- return new Promise((resolve2) => this.#waiters.push(resolve2));
12422
+ return new Promise((resolve3) => this.#waiters.push(resolve3));
12197
12423
  }
12198
12424
  close() {
12199
12425
  if (this.#closed)
@@ -12305,12 +12531,47 @@ async function* streamStackLogs(record, options = {}) {
12305
12531
  }
12306
12532
  }
12307
12533
  // packages/engine/src/daemon/shared-arbiter.ts
12534
+ var ORIGIN_DEAD_RELEASE_STRIKES = 3;
12535
+ function probeMirrorProcessOccupancy(mirror) {
12536
+ const identities = [];
12537
+ let malformed = false;
12538
+ for (const service of [...mirror.services, ...mirror.auxiliary ?? []].filter((candidate) => candidate.backend === "proc" || candidate.backend === "wrangler")) {
12539
+ if (service.pid === undefined && service.startTime === undefined)
12540
+ continue;
12541
+ if (!Number.isSafeInteger(service.pid) || service.pid <= 0 || typeof service.startTime !== "string") {
12542
+ malformed = true;
12543
+ continue;
12544
+ }
12545
+ identities.push({ pid: service.pid, startTime: service.startTime });
12546
+ }
12547
+ const pidfileScan = scanPidfiles(mirrorProcsDir(mirror.project));
12548
+ malformed ||= pidfileScan.errors.length > 0;
12549
+ identities.push(...pidfileScan.pidfiles.filter((pidfile) => pidfile.backend !== "tunnel").map((pidfile) => ({ pid: pidfile.pid, startTime: pidfile.startTime })));
12550
+ let unknown = false;
12551
+ for (const identityRecord of identities) {
12552
+ const identity2 = probeProcessIdentity(identityRecord);
12553
+ if (identity2 === "live")
12554
+ return "live";
12555
+ if (identity2 === "unknown")
12556
+ unknown = true;
12557
+ }
12558
+ if (unknown)
12559
+ return "unknown";
12560
+ if (malformed)
12561
+ throw new Error("Shared holder mirror has an invalid process identity");
12562
+ return "dead";
12563
+ }
12564
+
12308
12565
  class SharedArbiter {
12309
- onChange;
12310
12566
  #mutex = Promise.resolve();
12311
12567
  #polls = new Map;
12312
- constructor(onChange) {
12313
- this.onChange = onChange;
12568
+ #deadOriginStrikes = new Map;
12569
+ #onChange;
12570
+ #probeHolderOrigin;
12571
+ constructor(onChangeOrOptions) {
12572
+ const options = typeof onChangeOrOptions === "function" ? { onChange: onChangeOrOptions } : onChangeOrOptions;
12573
+ this.#onChange = options?.onChange;
12574
+ this.#probeHolderOrigin = options?.probeHolderOrigin ?? probeSharedHolderOrigin;
12314
12575
  }
12315
12576
  #locked(fn) {
12316
12577
  const next = this.#mutex.then(fn, fn);
@@ -12319,7 +12580,7 @@ class SharedArbiter {
12319
12580
  }
12320
12581
  async#notify() {
12321
12582
  try {
12322
- await this.onChange?.();
12583
+ await this.#onChange?.();
12323
12584
  } catch {}
12324
12585
  }
12325
12586
  #result(record, granted) {
@@ -12342,7 +12603,8 @@ class SharedArbiter {
12342
12603
  delete next.holder;
12343
12604
  return next;
12344
12605
  }
12345
- if (readMirrorState(head.project) === null)
12606
+ const mirror = readMirrorStateSafe(head.project);
12607
+ if (mirror.status === "ok" && mirror.record === null)
12346
12608
  continue;
12347
12609
  return {
12348
12610
  ...record,
@@ -12400,11 +12662,11 @@ class SharedArbiter {
12400
12662
  }
12401
12663
  if (waitMs <= 0)
12402
12664
  return this.#result(outcome.record, false);
12403
- return new Promise((resolve2) => {
12665
+ return new Promise((resolve3) => {
12404
12666
  const key = `${name}\x00${requester.project}`;
12405
12667
  const polls = this.#polls.get(key) ?? [];
12406
12668
  const poll = {
12407
- resolve: resolve2,
12669
+ resolve: resolve3,
12408
12670
  timer: setTimeout(() => {
12409
12671
  const remaining = (this.#polls.get(key) ?? []).filter((candidate) => candidate !== poll);
12410
12672
  if (remaining.length === 0)
@@ -12412,7 +12674,7 @@ class SharedArbiter {
12412
12674
  else
12413
12675
  this.#polls.set(key, remaining);
12414
12676
  const record = readSharedHostname(name);
12415
- resolve2(record === null ? { granted: false, queued: [] } : this.#result(record, record.holder?.project === requester.project));
12677
+ resolve3(record === null ? { granted: false, queued: [] } : this.#result(record, record.holder?.project === requester.project));
12416
12678
  }, waitMs)
12417
12679
  };
12418
12680
  polls.push(poll);
@@ -12494,35 +12756,135 @@ class SharedArbiter {
12494
12756
  }
12495
12757
  async sweep(occupied) {
12496
12758
  let changed = false;
12759
+ const releases = [];
12497
12760
  for (const record of listSharedHostnames()) {
12498
- const holderDead = record.holder !== undefined && !occupied.has(record.holder.project) && readMirrorState(record.holder.project) === null;
12499
- const deadWaiters = (record.queue ?? []).filter((waiter) => readMirrorState(waiter.project) === null);
12500
- if (!holderDead && deadWaiters.length === 0)
12761
+ const holder = record.holder;
12762
+ const holderMirror = holder === undefined ? undefined : readMirrorStateSafe(holder.project);
12763
+ const deadWaiterProjects = new Set((record.queue ?? []).flatMap((waiter) => {
12764
+ const mirror = readMirrorStateSafe(waiter.project);
12765
+ return mirror.status === "ok" && mirror.record === null ? [waiter.project] : [];
12766
+ }));
12767
+ const deadWaiters = (record.queue ?? []).filter((waiter) => deadWaiterProjects.has(waiter.project));
12768
+ let releaseReason;
12769
+ if (holder !== undefined && holderMirror !== undefined) {
12770
+ const holderKey = `${holder.project}\x00${holder.at}`;
12771
+ const holderOccupied = occupied.has(holder.project);
12772
+ if (holderMirror.status === "error") {
12773
+ if (this.#recordDeadStrike(record.name, holderKey))
12774
+ releaseReason = "stack-dead";
12775
+ } else {
12776
+ try {
12777
+ const mirror = holderMirror.record;
12778
+ if (mirror === null) {
12779
+ if (!holderOccupied)
12780
+ releaseReason = "stack-gone";
12781
+ else
12782
+ this.#deadOriginStrikes.delete(record.name);
12783
+ } else {
12784
+ const protectedState = mirror.state === "starting" || mirror.state === "queued";
12785
+ if (!protectedState && mirror.starter !== undefined && (typeof mirror.starter !== "object" || mirror.starter === null || !Number.isSafeInteger(mirror.starter.pid) || mirror.starter.pid <= 0 || typeof mirror.starter.startTime !== "string")) {
12786
+ throw new Error("Shared holder mirror has an invalid starter identity");
12787
+ }
12788
+ const starterLiveness = protectedState || mirror.starter === undefined ? "dead" : probeProcessIdentity(mirror.starter);
12789
+ const protectedStartup = protectedState || starterLiveness === "live";
12790
+ if (protectedStartup) {
12791
+ this.#deadOriginStrikes.delete(record.name);
12792
+ } else if (starterLiveness === "unknown") {
12793
+ this.#retainStrikeForHolder(record.name, holderKey);
12794
+ } else if (!holderOccupied) {
12795
+ const contractOrigin = resolveSharedContractOrigin(mirror, holder.service);
12796
+ if (contractOrigin === undefined) {
12797
+ const processOccupancy = probeMirrorProcessOccupancy(mirror);
12798
+ if (processOccupancy === "live") {
12799
+ this.#deadOriginStrikes.delete(record.name);
12800
+ } else if (processOccupancy === "unknown") {
12801
+ this.#retainStrikeForHolder(record.name, holderKey);
12802
+ } else if (this.#recordDeadStrike(record.name, holderKey)) {
12803
+ releaseReason = "stack-dead";
12804
+ }
12805
+ } else {
12806
+ const origin = await this.#probeHolderOrigin(mirror, holder.service);
12807
+ if (origin === "live") {
12808
+ this.#deadOriginStrikes.delete(record.name);
12809
+ } else if (origin === "dead") {
12810
+ if (this.#recordDeadStrike(record.name, holderKey))
12811
+ releaseReason = "stack-dead";
12812
+ } else {
12813
+ this.#retainStrikeForHolder(record.name, holderKey);
12814
+ }
12815
+ }
12816
+ } else {
12817
+ const origin = await this.#probeHolderOrigin(mirror, holder.service);
12818
+ if (origin === "live") {
12819
+ this.#deadOriginStrikes.delete(record.name);
12820
+ } else if (origin === "dead") {
12821
+ if (this.#recordDeadStrike(record.name, holderKey))
12822
+ releaseReason = "origin-dead";
12823
+ } else {
12824
+ this.#retainStrikeForHolder(record.name, holderKey);
12825
+ }
12826
+ }
12827
+ }
12828
+ } catch {
12829
+ if (this.#recordDeadStrike(record.name, holderKey))
12830
+ releaseReason = "stack-dead";
12831
+ }
12832
+ }
12833
+ } else {
12834
+ this.#deadOriginStrikes.delete(record.name);
12835
+ }
12836
+ if (releaseReason === undefined && deadWaiters.length === 0)
12501
12837
  continue;
12838
+ let released = false;
12502
12839
  const updated = await this.#locked(() => updateSharedHostname(record.name, (candidate) => {
12503
12840
  const pruned = {
12504
12841
  ...candidate,
12505
- queue: (candidate.queue ?? []).filter((waiter) => readMirrorState(waiter.project) !== null)
12842
+ queue: (candidate.queue ?? []).filter((waiter) => {
12843
+ if (!deadWaiterProjects.has(waiter.project))
12844
+ return true;
12845
+ const freshMirror = readMirrorStateSafe(waiter.project);
12846
+ return freshMirror.status === "error" || freshMirror.record !== null;
12847
+ })
12506
12848
  };
12507
- const currentHolderDead = pruned.holder !== undefined && !occupied.has(pruned.holder.project) && readMirrorState(pruned.holder.project) === null;
12508
- return currentHolderDead ? this.#grantNext(pruned) : pruned;
12849
+ const holderMatches = releaseReason !== undefined && pruned.holder?.project === holder?.project && pruned.holder?.at === holder?.at;
12850
+ if (!holderMatches)
12851
+ return pruned;
12852
+ released = true;
12853
+ return this.#grantNext(pruned);
12509
12854
  }));
12510
12855
  if (updated === null)
12511
12856
  continue;
12512
- changed = true;
12857
+ changed ||= released || deadWaiters.length > 0;
12858
+ if (released && holder !== undefined && releaseReason !== undefined) {
12859
+ releases.push({ name: record.name, project: holder.project, reason: releaseReason });
12860
+ this.#deadOriginStrikes.delete(record.name);
12861
+ }
12513
12862
  if (updated.holder !== undefined && updated.holder.project !== record.holder?.project) {
12514
12863
  this.#wake(updated.name, updated.holder.project, this.#result(updated, true));
12515
12864
  }
12516
12865
  }
12517
12866
  if (changed)
12518
12867
  await this.#notify();
12868
+ return releases;
12869
+ }
12870
+ #recordDeadStrike(name, holderKey) {
12871
+ const previous = this.#deadOriginStrikes.get(name);
12872
+ const strikes = previous?.holderKey === holderKey ? previous.strikes + 1 : 1;
12873
+ this.#deadOriginStrikes.set(name, { holderKey, strikes });
12874
+ return strikes >= ORIGIN_DEAD_RELEASE_STRIKES;
12875
+ }
12876
+ #retainStrikeForHolder(name, holderKey) {
12877
+ const previous = this.#deadOriginStrikes.get(name);
12878
+ if (previous?.holderKey !== holderKey) {
12879
+ this.#deadOriginStrikes.set(name, { holderKey, strikes: 0 });
12880
+ }
12519
12881
  }
12520
12882
  }
12521
12883
  // packages/engine/src/daemon/fleet-monitor.ts
12522
12884
  import { execFile as execFile9 } from "child_process";
12523
12885
  import { promisify as promisify8 } from "util";
12524
- import { existsSync as existsSync20, readFileSync as readFileSync18, readdirSync as readdirSync8 } from "fs";
12525
- import { join as join23 } from "path";
12886
+ import { existsSync as existsSync20, readFileSync as readFileSync19, readdirSync as readdirSync8 } from "fs";
12887
+ import { join as join24 } from "path";
12526
12888
  var pexec8 = promisify8(execFile9);
12527
12889
  var DEFAULT_REFRESH_MS = 1000;
12528
12890
  var DEFAULT_HEARTBEAT_MS = 15000;
@@ -12553,8 +12915,8 @@ class LatestFleetChannel {
12553
12915
  }
12554
12916
  if (this.#closed)
12555
12917
  return Promise.resolve({ value: undefined, done: true });
12556
- return new Promise((resolve2) => {
12557
- this.#waiter = resolve2;
12918
+ return new Promise((resolve3) => {
12919
+ this.#waiter = resolve3;
12558
12920
  });
12559
12921
  }
12560
12922
  close() {
@@ -12569,13 +12931,13 @@ class LatestFleetChannel {
12569
12931
  function readManagedMirrors() {
12570
12932
  const records = [];
12571
12933
  const warnings = [];
12572
- const stacksDirectory = join23(hestiaHome(), "stacks");
12934
+ const stacksDirectory = join24(hestiaHome(), "stacks");
12573
12935
  if (!existsSync20(stacksDirectory))
12574
12936
  return { records, warnings };
12575
12937
  for (const project of readdirSync8(stacksDirectory).sort()) {
12576
- const path = join23(stacksDirectory, project, "stack.json");
12938
+ const path = join24(stacksDirectory, project, "stack.json");
12577
12939
  try {
12578
- const source = readFileSync18(path);
12940
+ const source = readFileSync19(path);
12579
12941
  if (source.byteLength > MAX_MIRROR_BYTES2) {
12580
12942
  warnings.push(`Fleet mirror too large for ${project}`);
12581
12943
  continue;
@@ -13051,7 +13413,7 @@ async function assertHestiaStateIgnored(worktreeRoot) {
13051
13413
  try {
13052
13414
  await pexec9("git", ["-C", worktreeRoot, "check-ignore", "-q", ".hestia/"], { timeout: 5000 });
13053
13415
  } catch {
13054
- throw new HestiaError("state-not-ignored", `.hestia is not ignored in ${worktreeRoot}; add the line ".hestia/" to ${join24(worktreeRoot, ".gitignore")}`, { remedy: `.hestia/`, path: join24(worktreeRoot, ".gitignore") });
13416
+ throw new HestiaError("state-not-ignored", `.hestia is not ignored in ${worktreeRoot}; add the line ".hestia/" to ${join25(worktreeRoot, ".gitignore")}`, { remedy: `.hestia/`, path: join25(worktreeRoot, ".gitignore") });
13055
13417
  }
13056
13418
  }
13057
13419
  async function prepareCompose(worktreeRoot, project, repo, branch, opts, configuredBaseFile) {
@@ -13068,7 +13430,7 @@ async function prepareCompose(worktreeRoot, project, repo, branch, opts, configu
13068
13430
  services
13069
13431
  });
13070
13432
  ensureDir(hestiaDir(worktreeRoot));
13071
- const overridePath = join24(hestiaDir(worktreeRoot), OVERRIDE_FILE);
13433
+ const overridePath = join25(hestiaDir(worktreeRoot), OVERRIDE_FILE);
13072
13434
  writeAtomicTextFile(overridePath, yaml);
13073
13435
  return {
13074
13436
  ctx: {
@@ -13101,7 +13463,7 @@ function writeDockerfileComposeModel(worktreeRoot, workloads, conventionalCompos
13101
13463
  ...conventionalComposeFile === undefined ? {} : { include: [{ path: conventionalComposeFile }] },
13102
13464
  services
13103
13465
  };
13104
- const path = join24(hestiaDir(worktreeRoot), DOCKERFILE_COMPOSE_FILE);
13466
+ const path = join25(hestiaDir(worktreeRoot), DOCKERFILE_COMPOSE_FILE);
13105
13467
  writeAtomicTextFile(path, $stringify(model));
13106
13468
  return path;
13107
13469
  }
@@ -13121,9 +13483,9 @@ function freshRecord(project, repoId, repo, branch, worktree) {
13121
13483
  };
13122
13484
  }
13123
13485
  function assertCurrentStackIdentity(record, current) {
13124
- const path = join24(hestiaDir(current.worktreeRoot), "stack.json");
13486
+ const path = join25(hestiaDir(current.worktreeRoot), "stack.json");
13125
13487
  assertMutableStackRecord(record, path);
13126
- const matches = record.repoId === current.repoId && record.repo === current.repo && record.branch === current.branch && resolve2(record.worktree) === resolve2(current.worktreeRoot) && record.project === projectName(current.repoId, current.repo, current.branch, current.worktreeRoot);
13488
+ const matches = record.repoId === current.repoId && record.repo === current.repo && record.branch === current.branch && resolve3(record.worktree) === resolve3(current.worktreeRoot) && record.project === projectName(current.repoId, current.repo, current.branch, current.worktreeRoot);
13127
13489
  if (!matches) {
13128
13490
  throw new HestiaError("stack-identity-changed", `this checkout no longer matches stack ${record.project}; run hestia down before starting or changing it`, {
13129
13491
  recorded: {
@@ -13301,7 +13663,7 @@ function matchesExpectedStack(record, expected) {
13301
13663
  return record !== null && (record.repoId === undefined || record.repoId === expected.repoId) && record.worktree === expected.worktree && record.createdAt === expected.createdAt;
13302
13664
  }
13303
13665
  function projectMutationRoot(project) {
13304
- return join24(hestiaHome(), "project-locks", project);
13666
+ return join25(hestiaHome(), "project-locks", project);
13305
13667
  }
13306
13668
  async function assertNamedTunnelDns(hostname, tunnelUuid) {
13307
13669
  const expected = `${tunnelUuid}.cfargotunnel.com`;
@@ -13558,12 +13920,25 @@ class ComposeEngine {
13558
13920
  }
13559
13921
  }
13560
13922
  }
13923
+ applyConfiguredEndpoints(record, configuredWorkloads);
13924
+ if (opts?.workers || configuredWorkers.length > 0) {
13925
+ const configuredFilters = configuredWorkers.map(([name, workload]) => workload.wranglerConfig ?? name);
13926
+ const explicitFilters = Array.isArray(opts?.workers) ? opts.workers : [];
13927
+ await this.#upWorkers(worktreeRoot, record, configuredWorkloads, {
13928
+ ...opts,
13929
+ workers: opts?.workers === true ? true : [...new Set([...explicitFilters, ...configuredFilters])]
13930
+ });
13931
+ }
13932
+ applyConfiguredEndpoints(record, configuredWorkloads);
13561
13933
  for (const [name, workload] of configuredProcs) {
13562
- const command = workload.command;
13563
13934
  const configuredSpec = {
13564
13935
  name,
13565
- argv: command,
13936
+ argv: workload.command,
13937
+ cwd: workload.cwd,
13938
+ env: resolveConfiguredEnvironment(worktreeRoot, workload.env, record),
13566
13939
  port: workload.port ?? "auto",
13940
+ varlock: workload.varlock,
13941
+ healthPath: workload.healthPath,
13567
13942
  backend: "proc"
13568
13943
  };
13569
13944
  const clash = record.services.find((service) => service.name === name && service.backend === "docker");
@@ -13584,19 +13959,11 @@ class ComposeEngine {
13584
13959
  }
13585
13960
  const result = await startProc(worktreeRoot, configuredSpec, record.env, (pidfile2) => mirrorPidfile(record.project, pidfile2));
13586
13961
  recordProc(record, result.record);
13962
+ applyConfiguredEndpoints(record, configuredWorkloads);
13587
13963
  writeState(worktreeRoot, record);
13588
13964
  if (result.error !== undefined)
13589
13965
  throw result.error;
13590
13966
  }
13591
- if (opts?.workers || configuredWorkers.length > 0) {
13592
- const configuredFilters = configuredWorkers.map(([name, workload]) => workload.wranglerConfig ?? name);
13593
- const explicitFilters = Array.isArray(opts?.workers) ? opts.workers : [];
13594
- await this.#upWorkers(worktreeRoot, record, {
13595
- ...opts,
13596
- workers: opts?.workers === true ? true : [...new Set([...explicitFilters, ...configuredFilters])]
13597
- });
13598
- }
13599
- applyConfiguredEndpoints(record, configuredWorkloads);
13600
13967
  record.state = "up";
13601
13968
  delete record.starter;
13602
13969
  setTunnelDirty(syncExposures(record));
@@ -13604,17 +13971,27 @@ class ComposeEngine {
13604
13971
  return record;
13605
13972
  }));
13606
13973
  }
13607
- async#upWorkers(worktreeRoot, record, opts) {
13974
+ async#upWorkers(worktreeRoot, record, configuredWorkloads, opts) {
13608
13975
  const plan = await planWorkers(worktreeRoot, {
13609
13976
  filter: Array.isArray(opts.workers) ? opts.workers : [],
13610
13977
  allowRemote: opts.allowRemote ?? false,
13611
13978
  force: opts.force ?? false,
13612
- varlock: !opts.noVarlock && detectVarlock(worktreeRoot) !== null
13979
+ varlock: !opts.noVarlock
13613
13980
  });
13614
13981
  for (const w of plan.warnings)
13615
13982
  process.stderr.write(`warning: ${w}
13616
13983
  `);
13617
13984
  for (const spec of plan.specs) {
13985
+ const configured = Object.entries(configuredWorkloads).find(([name, workload]) => workload.source === "wrangler" && (name === spec.name || workload.wranglerConfig !== undefined && resolve3(worktreeRoot, workload.wranglerConfig) === spec.configPath))?.[1];
13986
+ if (configured !== undefined && configured.source === "wrangler") {
13987
+ spec.cwd = configured.cwd ?? spec.cwd;
13988
+ spec.varlock = configured.varlock ?? spec.varlock;
13989
+ spec.healthPath = configured.healthPath;
13990
+ spec.env = {
13991
+ ...spec.env,
13992
+ ...resolveConfiguredEnvironment(worktreeRoot, configured.env, record)
13993
+ };
13994
+ }
13618
13995
  const clash = record.services.find((s) => s.name === spec.name && s.backend === "docker");
13619
13996
  if (clash !== undefined) {
13620
13997
  throw new HestiaError("name-conflict", `worker "${spec.name}" collides with a compose service name`);
@@ -13908,13 +14285,13 @@ class ComposeEngine {
13908
14285
  const composeFile = record?.composeFile ?? tryLoadConfig(worktreeRoot)?.composeFile;
13909
14286
  if (composeFile !== undefined) {
13910
14287
  const project2 = record?.project ?? projectName(repoId, repo, branch, worktreeRoot);
13911
- const overrideFile = record?.overrideFile ?? join24(hestiaDir(worktreeRoot), OVERRIDE_FILE);
14288
+ const overrideFile = record?.overrideFile ?? join25(hestiaDir(worktreeRoot), OVERRIDE_FILE);
13912
14289
  if (existsSync21(overrideFile)) {
13913
14290
  await composeDown({ project: project2, baseFile: composeFile, overrideFile, cwd: worktreeRoot }, opts?.destroy ?? false);
13914
14291
  } else if (record?.composeFile !== undefined) {
13915
14292
  const rest = ["compose", "-p", project2, "down", "--remove-orphans"];
13916
14293
  if (opts?.destroy)
13917
- rest.push("-v");
14294
+ rest.push("-v", "--rmi", "local");
13918
14295
  await pexec9("docker", rest, { timeout: 180000 });
13919
14296
  }
13920
14297
  }
@@ -13971,7 +14348,7 @@ class ComposeEngine {
13971
14348
  try {
13972
14349
  const rest = ["compose", "-p", project, "down", "--remove-orphans"];
13973
14350
  if (opts?.destroy)
13974
- rest.push("-v");
14351
+ rest.push("-v", "--rmi", "local");
13975
14352
  await pexec9("docker", rest, { timeout: 180000 });
13976
14353
  } catch (err) {
13977
14354
  if (fresh?.composeFile !== undefined) {
@@ -13982,7 +14359,7 @@ class ComposeEngine {
13982
14359
  };
13983
14360
  if (record !== null && existsSync21(record.worktree)) {
13984
14361
  const info = await getRepoInfo(record.worktree);
13985
- const identityMatches = resolve2(info.worktreeRoot) === resolve2(record.worktree) && (record.repoId === undefined ? record.repo === info.repo : record.repoId === info.repoId);
14362
+ const identityMatches = resolve3(info.worktreeRoot) === resolve3(record.worktree) && (record.repoId === undefined ? record.repo === info.repo : record.repoId === info.repoId);
13986
14363
  const localRecord = readState(record.worktree);
13987
14364
  if (identityMatches && localRecord?.project === record.project) {
13988
14365
  await this.down(record.worktree, opts);
@@ -14141,7 +14518,7 @@ class ComposeEngine {
14141
14518
  const alias = selection.endpoint.alias ?? selection.endpoint.name;
14142
14519
  const authority = internalEndpointAuthority(record.project, alias);
14143
14520
  const name = `aux-quick-${createHash7("sha256").update(alias).digest("hex").slice(0, 10)}`;
14144
- const quickCfg = join24(hestiaDir(worktreeRoot), `quick-${name}.yml`);
14521
+ const quickCfg = join25(hestiaDir(worktreeRoot), `quick-${name}.yml`);
14145
14522
  writeAtomicTextFile(quickCfg, `ingress:
14146
14523
  - service: ${JSON.stringify(`unix:${publicGatewaySocketPath()}`)}
14147
14524
  ` + ` originRequest:
@@ -14512,9 +14889,28 @@ class ComposeEngine {
14512
14889
  var engine = new ComposeEngine;
14513
14890
 
14514
14891
  // packages/engine/src/daemon/duties.ts
14515
- var SWEEP_INTERVAL_MS = 15000;
14892
+ var DEFAULT_SWEEP_INTERVAL_MS = 15000;
14893
+ var MAX_TIMER_INTERVAL_MS = 2147483647;
14894
+ function resolveSweepIntervalMs() {
14895
+ const value = process.env.HESTIA_SWEEP_INTERVAL_MS;
14896
+ if (value === undefined)
14897
+ return { intervalMs: DEFAULT_SWEEP_INTERVAL_MS, warnings: [] };
14898
+ const parsed = Number(value);
14899
+ if (Number.isInteger(parsed) && parsed >= 100 && parsed <= MAX_TIMER_INTERVAL_MS) {
14900
+ return { intervalMs: parsed, warnings: [] };
14901
+ }
14902
+ return {
14903
+ intervalMs: DEFAULT_SWEEP_INTERVAL_MS,
14904
+ warnings: [
14905
+ `invalid HESTIA_SWEEP_INTERVAL_MS=${JSON.stringify(value)} \u2014 using default ${DEFAULT_SWEEP_INTERVAL_MS}`
14906
+ ]
14907
+ };
14908
+ }
14516
14909
  function startDuties(admission, opts) {
14517
14910
  const log = opts?.log ?? ((line) => console.error(line));
14911
+ const cadence = opts?.intervalMs === undefined ? resolveSweepIntervalMs() : { intervalMs: opts.intervalMs, warnings: [] };
14912
+ for (const warning of cadence.warnings)
14913
+ log(`sweep: ${warning}`);
14518
14914
  let running = false;
14519
14915
  const tick = async () => {
14520
14916
  if (running)
@@ -14529,7 +14925,10 @@ function startDuties(admission, opts) {
14529
14925
  if (opts?.shared !== undefined) {
14530
14926
  try {
14531
14927
  const state = admission.healthSnapshot();
14532
- await opts.shared.sweep(new Set([...state.live, ...state.reserved]));
14928
+ const releases = await opts.shared.sweep(new Set([...state.live, ...state.reserved]));
14929
+ for (const release of releases) {
14930
+ log(`sweep: auto-released shared hostname "${release.name}" \u2014 holder ` + `${release.project} ${release.reason}, granting queue head`);
14931
+ }
14533
14932
  } catch (err) {
14534
14933
  log(`sweep: shared-hostname sweep failed: ${err.message}`);
14535
14934
  }
@@ -14567,7 +14966,7 @@ function startDuties(admission, opts) {
14567
14966
  running = false;
14568
14967
  }
14569
14968
  };
14570
- const timer = setInterval(tick, opts?.intervalMs ?? SWEEP_INTERVAL_MS);
14969
+ const timer = setInterval(tick, cadence.intervalMs);
14571
14970
  tick();
14572
14971
  return () => clearInterval(timer);
14573
14972
  }
@@ -14629,11 +15028,11 @@ async function main() {
14629
15028
  port: "none",
14630
15029
  backend: "proc"
14631
15030
  }),
14632
- logPath: join25(root, "daemon.log"),
15031
+ logPath: join26(root, "daemon.log"),
14633
15032
  signal: "term",
14634
15033
  backend: "proc"
14635
15034
  });
14636
- writeAtomicJsonFile(join25(root, "daemon.json"), {
15035
+ writeAtomicJsonFile(join26(root, "daemon.json"), {
14637
15036
  schemaVersion: STATE_SCHEMA_VERSION,
14638
15037
  pid: process.pid,
14639
15038
  port: server.port,
@@ -14649,7 +15048,7 @@ async function main() {
14649
15048
  fleet.stop();
14650
15049
  localRouter.stop();
14651
15050
  removePidfile(root, PIDFILE_NAME2);
14652
- rmSync11(join25(root, "daemon.json"), { force: true });
15051
+ rmSync11(join26(root, "daemon.json"), { force: true });
14653
15052
  server.stop(true);
14654
15053
  process.exit(0);
14655
15054
  };
@@ -14664,4 +15063,4 @@ async function main() {
14664
15063
  }
14665
15064
  main();
14666
15065
 
14667
- //# debugId=E5997ABC0405B8AE64756E2164756E21
15066
+ //# debugId=928DC10BC59C1FF964756E2164756E21