dahrk-node 0.1.17 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/main.js +123 -23
  2. package/package.json +1 -1
package/dist/main.js CHANGED
@@ -3884,6 +3884,9 @@ function createStageRunner(deps) {
3884
3884
 
3885
3885
  // ../../packages/edge/src/ws-client.ts
3886
3886
  var ENROLMENT_REJECTED_EXIT_CODE = 78;
3887
+ var RECONNECT_BASE_MS = 500;
3888
+ var RECONNECT_MAX_MS = 3e4;
3889
+ var PARK_POLL_MS = 6e4;
3887
3890
  function classifyError(e) {
3888
3891
  const msg = e instanceof Error ? e.message : String(e);
3889
3892
  if (/\bgit\b|worktree|clone|fetch|branch|checkout|remote/i.test(msg)) return "git";
@@ -3919,7 +3922,11 @@ async function startEdgeNode(opts) {
3919
3922
  let lastPongAt = 0;
3920
3923
  let shuttingDown = false;
3921
3924
  let connectCount = 0;
3925
+ let enrolToken = opts.enrolToken;
3926
+ let reconnectAttempts = 0;
3927
+ let parkTimer;
3922
3928
  let onFatal;
3929
+ let onParked;
3923
3930
  const send = (msg) => {
3924
3931
  if (ws && ws.readyState === WebSocket.OPEN) ws.send(encode(msg));
3925
3932
  };
@@ -4042,7 +4049,7 @@ async function startEdgeNode(opts) {
4042
4049
  const sendHello = () => {
4043
4050
  send({
4044
4051
  type: "hello",
4045
- enrolToken: opts.enrolToken ?? "",
4052
+ enrolToken: enrolToken ?? "",
4046
4053
  detectedRuntimes: currentRuntimes,
4047
4054
  servesRepoIds: opts.servesRepoIds ?? [],
4048
4055
  ...opts.credentialModeExplicit && opts.credentialMode ? { credentialMode: opts.credentialMode } : {},
@@ -4102,6 +4109,7 @@ async function startEdgeNode(opts) {
4102
4109
  const msg = decode(raw);
4103
4110
  if (msg.type === "welcome") {
4104
4111
  stageDeps.tenantId = msg.tenantId;
4112
+ reconnectAttempts = 0;
4105
4113
  if (opts.retention === void 0 && msg.retention) stageDeps.retention = msg.retention;
4106
4114
  if (opts.heartbeatMs === void 0 && msg.heartbeatMs > 0 && ws) {
4107
4115
  startHeartbeat(ws, msg.heartbeatMs);
@@ -4286,19 +4294,59 @@ async function startEdgeNode(opts) {
4286
4294
  if (heartbeat) clearInterval(heartbeat);
4287
4295
  heartbeat = void 0;
4288
4296
  if (isEnrolmentRejection(code)) {
4289
- shuttingDown = true;
4290
4297
  const detail = reason.toString() || "enrolment rejected";
4291
- log.error({ closeCode: code, detail, fatal: true }, `EDGE_REJECTED:${code} ${detail}`);
4292
- process.exitCode = ENROLMENT_REJECTED_EXIT_CODE;
4293
- onFatal?.(new Error(`hub rejected edge enrolment (${code}): ${detail}`));
4298
+ const refresh = opts.refreshEnrolToken;
4299
+ log.error({ closeCode: code, detail, fatal: !refresh }, `EDGE_REJECTED:${code} ${detail}`);
4300
+ if (!refresh) {
4301
+ shuttingDown = true;
4302
+ process.exitCode = ENROLMENT_REJECTED_EXIT_CODE;
4303
+ onFatal?.(new Error(`hub rejected edge enrolment (${code}): ${detail}`));
4304
+ return;
4305
+ }
4306
+ park(refresh);
4294
4307
  return;
4295
4308
  }
4296
4309
  if (!shuttingDown) {
4297
- log.warn({ closeCode: code, reason: reason.toString(), connectCount }, `EDGE_DISCONNECTED:${code} reconnecting in 500ms`);
4298
- reconnectTimer = setTimeout(connect, 500);
4310
+ const delay = reconnectDelay();
4311
+ log.warn(
4312
+ { closeCode: code, reason: reason.toString(), connectCount, delayMs: delay, attempt: reconnectAttempts },
4313
+ `EDGE_DISCONNECTED:${code} reconnecting in ${delay}ms`
4314
+ );
4315
+ reconnectTimer = setTimeout(connect, delay);
4299
4316
  }
4300
4317
  });
4301
4318
  };
4319
+ const reconnectDelay = () => {
4320
+ const ceiling = Math.min(RECONNECT_BASE_MS * 2 ** reconnectAttempts, RECONNECT_MAX_MS);
4321
+ reconnectAttempts++;
4322
+ return Math.round(ceiling / 2 + Math.random() * (ceiling / 2));
4323
+ };
4324
+ const park = (refresh) => {
4325
+ const rejected = enrolToken;
4326
+ if (parkTimer) return;
4327
+ const pollMs = opts.parkPollMs ?? PARK_POLL_MS;
4328
+ log.error(
4329
+ { pollMs },
4330
+ "EDGE_PARKED:enrolment rejected - the node is up but serving nothing. Re-enrol with `dahrk start --token <token>` (get one at https://app.dahrk.ai); it will reconnect on its own."
4331
+ );
4332
+ parkTimer = setInterval(() => {
4333
+ let fresh;
4334
+ try {
4335
+ fresh = refresh();
4336
+ } catch (e) {
4337
+ log.warn({ err: e }, `EDGE_PARK_REFRESH_FAILED ${e.message}`);
4338
+ return;
4339
+ }
4340
+ if (!fresh || fresh === rejected) return;
4341
+ if (parkTimer) clearInterval(parkTimer);
4342
+ parkTimer = void 0;
4343
+ enrolToken = fresh;
4344
+ reconnectAttempts = 0;
4345
+ log.info({}, "EDGE_UNPARKED:a new enrolment token was found, reconnecting");
4346
+ connect();
4347
+ }, pollMs);
4348
+ onParked?.();
4349
+ };
4302
4350
  opts.signal?.addEventListener(
4303
4351
  "abort",
4304
4352
  () => {
@@ -4309,6 +4357,8 @@ async function startEdgeNode(opts) {
4309
4357
  reconnectTimer = void 0;
4310
4358
  if (reprobeTimer) clearInterval(reprobeTimer);
4311
4359
  reprobeTimer = void 0;
4360
+ if (parkTimer) clearInterval(parkTimer);
4361
+ parkTimer = void 0;
4312
4362
  ws?.close(1e3, "shutting down");
4313
4363
  },
4314
4364
  { once: true }
@@ -4325,6 +4375,10 @@ async function startEdgeNode(opts) {
4325
4375
  clearInterval(t);
4326
4376
  reject(err);
4327
4377
  };
4378
+ onParked = () => {
4379
+ clearInterval(t);
4380
+ resolve3();
4381
+ };
4328
4382
  });
4329
4383
  }
4330
4384
 
@@ -5737,6 +5791,7 @@ function persistEnrolment(env, enrolment) {
5737
5791
  });
5738
5792
  }
5739
5793
  function resolveEnrolToken(env, opts = {}) {
5794
+ if (opts.supervised && !opts.ephemeral) return readPersistedToken(env) ?? env.DAHRK_ENROL_TOKEN;
5740
5795
  if (env.DAHRK_ENROL_TOKEN) return env.DAHRK_ENROL_TOKEN;
5741
5796
  if (opts.ephemeral) return void 0;
5742
5797
  return readPersistedToken(env);
@@ -5756,7 +5811,6 @@ function serviceArgv(inputs) {
5756
5811
  }
5757
5812
  function serviceEnv(inputs) {
5758
5813
  return {
5759
- DAHRK_ENROL_TOKEN: inputs.token,
5760
5814
  DAHRK_SUPERVISED: "1",
5761
5815
  ...inputs.hubUrl ? { DAHRK_HUB_URL: inputs.hubUrl } : {},
5762
5816
  ...inputs.name ? { DAHRK_NODE_NAME: inputs.name } : {},
@@ -5928,7 +5982,6 @@ async function runServiceInstall(inputs, deps = {}) {
5928
5982
  manager,
5929
5983
  nodeBin: d.nodeBin,
5930
5984
  scriptPath: d.scriptPath,
5931
- token: inputs.token,
5932
5985
  ...inputs.name ? { name: inputs.name } : {},
5933
5986
  ...inputs.hubUrl ? { hubUrl: inputs.hubUrl } : {},
5934
5987
  ...d.pathEnv ? { pathEnv: d.pathEnv } : {},
@@ -5996,21 +6049,19 @@ async function runNodeStart(inputs, deps = {}, opts = {}) {
5996
6049
  manager,
5997
6050
  nodeBin: d.nodeBin,
5998
6051
  scriptPath: d.scriptPath,
5999
- token: inputs.token ?? "-",
6000
6052
  ...inputs.name ? { name: inputs.name } : {},
6001
6053
  ...inputs.hubUrl ? { hubUrl: inputs.hubUrl } : {},
6002
6054
  ...d.pathEnv ? { pathEnv: d.pathEnv } : {},
6003
6055
  homeDir: d.homeDir,
6004
6056
  logDir: d.logDir
6005
6057
  });
6006
- const canRender = inputs.token !== void 0;
6007
- const current = canRender && unitExists && unitIsCurrent(plan, d.readFile(filePath));
6058
+ const current = unitExists && unitIsCurrent(plan, d.readFile(filePath));
6008
6059
  if (!opts.alwaysLoad) {
6009
6060
  const probe2 = unitExists ? d.capture(statusCommand(manager)) : { code: 1, stdout: "" };
6010
6061
  const status = parseServiceStatus(manager, unitExists, probe2);
6011
6062
  if (current && status.running) return { kind: "running", code: 0, already: true };
6012
6063
  }
6013
- if (canRender && !current) {
6064
+ if (!current) {
6014
6065
  try {
6015
6066
  d.mkdirp(d.logDir);
6016
6067
  d.mkdirp(dirOf(plan.filePath));
@@ -6040,7 +6091,6 @@ async function runNodeRestart(inputs, deps = {}) {
6040
6091
  manager,
6041
6092
  nodeBin: d.nodeBin,
6042
6093
  scriptPath: d.scriptPath,
6043
- token: "-",
6044
6094
  homeDir: d.homeDir,
6045
6095
  logDir: d.logDir
6046
6096
  });
@@ -6082,7 +6132,6 @@ async function runNodeStop(inputs = {}, deps = {}) {
6082
6132
  manager,
6083
6133
  nodeBin: d.nodeBin,
6084
6134
  scriptPath: d.scriptPath,
6085
- token: "-",
6086
6135
  homeDir: d.homeDir,
6087
6136
  logDir: d.logDir
6088
6137
  });
@@ -6119,7 +6168,6 @@ async function runServiceUninstall(deps = {}) {
6119
6168
  manager,
6120
6169
  nodeBin: d.nodeBin,
6121
6170
  scriptPath: d.scriptPath,
6122
- token: "-",
6123
6171
  homeDir: d.homeDir,
6124
6172
  logDir: d.logDir
6125
6173
  });
@@ -6183,9 +6231,9 @@ var defaultDeps3 = () => ({
6183
6231
  // npm-global bins) that a supervisor's minimal PATH would otherwise hide.
6184
6232
  pathEnv: process.env.PATH,
6185
6233
  mkdirp: (dir) => void mkdirSync12(dir, { recursive: true }),
6186
- // The unit's environment block carries the enrolment token, so the file is a secret: write it
6187
- // owner-only. `writeFileSync`'s `mode` applies only when it CREATES the file, so chmod explicitly
6188
- // too - re-installing over a unit an older client left at 0644 must tighten it, not keep it.
6234
+ // The unit holds no secret any more, but it stays owner-only: it names paths on this host, and there is
6235
+ // no reason for it to be world-readable. `writeFileSync`'s `mode` applies only when it CREATES the file,
6236
+ // so chmod explicitly too - re-installing over a unit an older client left at 0644 must tighten it.
6189
6237
  writeFile: (path, content) => {
6190
6238
  writeFileSync11(path, content, { mode: UNIT_FILE_MODE });
6191
6239
  chmodSync3(path, UNIT_FILE_MODE);
@@ -6527,6 +6575,12 @@ function renderStatus(f) {
6527
6575
  lines.push(kv("Work", jobLine(first, f.now)));
6528
6576
  for (const j of rest) lines.push(kv("", jobLine(j, f.now)));
6529
6577
  }
6578
+ if (isEnrolmentBlocked(f.connection)) {
6579
+ lines.push("");
6580
+ lines.push(verdict("fail", "The hub rejected this node's enrolment token; it is serving no Jobs."));
6581
+ lines.push("");
6582
+ lines.push(hint("Re-enrol with `dahrk start --token <token>`. Get one at https://app.dahrk.ai."));
6583
+ }
6530
6584
  lines.push(...presenceHints(f).flatMap((h) => ["", h]));
6531
6585
  lines.push("", dim(` State file: ${f.stateFile}`));
6532
6586
  return lines;
@@ -6549,7 +6603,7 @@ function jobLine(j, now) {
6549
6603
  return `${where} ${dim(humanDuration(now - j.startedAt))}`;
6550
6604
  }
6551
6605
  function isUnhealthy(f) {
6552
- return f.presence.kind === "crashed";
6606
+ return f.presence.kind === "crashed" || isEnrolmentBlocked(f.connection);
6553
6607
  }
6554
6608
  function resolvePresence(service, lockedPid, desired) {
6555
6609
  if (service?.running) return { kind: "running", ...service.pid ? { pid: service.pid } : {} };
@@ -6592,10 +6646,21 @@ async function gatherFacts(inputs, deps) {
6592
6646
  }
6593
6647
  var CONNECTION_MARKERS = [
6594
6648
  { prefix: "EDGE_WELCOMED", event: "welcomed", detail: false },
6649
+ // The two markers whose absence here was itself the bug. `EDGE_CONNECTED` is written when the SOCKET
6650
+ // opens, which is before the hub has looked at our token - so a node whose enrolment was rejected has
6651
+ // `EDGE_CONNECTED` as its last marker, and `status` used to read that and cheerfully report a connected
6652
+ // node while it served nothing. The rejection is the newer marker, and it is the true one.
6653
+ { prefix: "EDGE_REJECTED", event: "rejected", detail: true },
6654
+ { prefix: "EDGE_PARKED", event: "parked", detail: false },
6655
+ { prefix: "EDGE_UNPARKED", event: "reconnecting", detail: false },
6595
6656
  { prefix: "EDGE_CONNECTED", event: "connected", detail: false },
6596
6657
  { prefix: "EDGE_DISCONNECTED", event: "disconnected", detail: true },
6597
6658
  { prefix: "EDGE_STALE", event: "went stale", detail: false }
6598
6659
  ];
6660
+ var BLOCKED_EVENTS = /* @__PURE__ */ new Set(["rejected", "parked"]);
6661
+ function isEnrolmentBlocked(connection) {
6662
+ return connection !== void 0 && BLOCKED_EVENTS.has(connection.event);
6663
+ }
6599
6664
  function lastConnection(records) {
6600
6665
  for (let i = records.length - 1; i >= 0; i--) {
6601
6666
  const r = records[i];
@@ -6621,7 +6686,7 @@ async function runStatus(inputs, deps) {
6621
6686
  }
6622
6687
 
6623
6688
  // src/main.ts
6624
- var CLIENT_VERSION = "0.1.17";
6689
+ var CLIENT_VERSION = "0.1.18";
6625
6690
  var DEFAULT_HUB_URL = "wss://api.dahrk.ai";
6626
6691
  var list = (v) => (v ?? "").split(",").map((s) => s.trim()).filter(Boolean);
6627
6692
  var RUNTIMES2 = ["claude-code", "codex", "pi"];
@@ -6706,6 +6771,8 @@ function wantsForeground(env, flags) {
6706
6771
  async function start(flags) {
6707
6772
  const env = envWithFlags(process.env, flags);
6708
6773
  if (wantsForeground(env, flags)) return startForeground(env, flags);
6774
+ const enrolled = await enrolToDisk(env);
6775
+ if (enrolled !== 0) return enrolled;
6709
6776
  await offerUpdate(env);
6710
6777
  const outcome = await runNodeStart(serviceInputs(env));
6711
6778
  if (outcome.kind === "error") return outcome.code;
@@ -6725,6 +6792,28 @@ var serviceInputs = (env) => ({
6725
6792
  ...env.DAHRK_NODE_NAME ? { name: env.DAHRK_NODE_NAME } : {},
6726
6793
  ...env.DAHRK_HUB_URL ? { hubUrl: env.DAHRK_HUB_URL } : {}
6727
6794
  });
6795
+ async function enrolToDisk(env) {
6796
+ const token = resolveEnrolToken(env);
6797
+ if (!token || token === readPersistedToken(env)) return 0;
6798
+ const hubUrl = env.DAHRK_HUB_URL ?? DEFAULT_HUB_URL;
6799
+ const probe2 = await probeHub({ hubUrl, enrolToken: token, clientVersion: CLIENT_VERSION });
6800
+ if (!probe2.ok && probe2.reason === "rejected") {
6801
+ out("");
6802
+ out(verdict("fail", `The hub rejected that enrolment token: ${probe2.detail}`));
6803
+ out("");
6804
+ out(hint("Get a fresh token at https://app.dahrk.ai, then run `dahrk start --token <token>` again."));
6805
+ return 2;
6806
+ }
6807
+ if (!probe2.ok) {
6808
+ out("");
6809
+ out(verdict("warn", `Could not reach ${hubUrl} to check the token (${probe2.detail}); using it anyway.`));
6810
+ }
6811
+ persistEnrolment(env, {
6812
+ token,
6813
+ ...probe2.ok ? { name: probe2.name, tenantId: probe2.tenantId } : {}
6814
+ });
6815
+ return 0;
6816
+ }
6728
6817
  async function restart(flags, force) {
6729
6818
  const env = envWithFlags(process.env, flags);
6730
6819
  out("");
@@ -6763,7 +6852,10 @@ async function startForeground(env, flags) {
6763
6852
  clientVersion: CLIENT_VERSION,
6764
6853
  ...env.DAHRK_CRASH_EXIT === "1" ? { exitOnCrash: true } : {}
6765
6854
  });
6766
- const token = resolveEnrolToken(env, { ephemeral: flags.ephemeral });
6855
+ const token = resolveEnrolToken(env, {
6856
+ ephemeral: flags.ephemeral,
6857
+ supervised: env.DAHRK_SUPERVISED === "1"
6858
+ });
6767
6859
  if (token) env.DAHRK_ENROL_TOKEN = token;
6768
6860
  const runtimes = await resolveRuntimes(env);
6769
6861
  if (!flags.ephemeral) {
@@ -6802,7 +6894,15 @@ async function startForeground(env, flags) {
6802
6894
  ...flags.ephemeral ? {} : { jobLedger: fileJobLedger(jobLedgerFile(stateDir(env))) },
6803
6895
  ...persist ? {
6804
6896
  onEnrolled: (welcome) => persistEnrolment(env, { token, name: welcome.name, tenantId: welcome.tenantId })
6805
- } : {}
6897
+ } : {},
6898
+ // How a rejected node heals. Reads `node.json` DIRECTLY rather than going through
6899
+ // `resolveEnrolToken`, and that is the whole point: the disk is where re-enrolment writes, so it is
6900
+ // the only place a *newer* token can appear. (Deliberately narrower than boot-time resolution, which
6901
+ // still lets an explicit `DAHRK_ENROL_TOKEN` win - but a stale env var must never be able to shadow
6902
+ // the fresh token on disk forever, which is precisely how a node used to wedge.)
6903
+ //
6904
+ // Only for a persistent node: an ephemeral one has no disk to heal from and must still fail fast.
6905
+ ...flags.ephemeral ? {} : { refreshEnrolToken: () => readPersistedToken(env) }
6806
6906
  });
6807
6907
  return 0;
6808
6908
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dahrk-node",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "private": false,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",