c8ctl-plugin-nano 1.36.2 → 1.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -324,6 +324,27 @@ used **verbatim** — so an explicit target always wins, and it's also how you
324
324
  disambiguate when several apps are running. `NANO_AGENTIC=off` disables the
325
325
  channel entirely and attempts **no** discovery.
326
326
 
327
+ **Seeing it work — `supervisor status`.** Supervised workers report both the
328
+ engine they poll and their agentic-channel state to `c8ctl nano supervisor
329
+ status` (and the interactive console), so you don't have to read raw worker logs
330
+ to tell whether presence actually reached the Workforce hub. The table gains an
331
+ `ENGINE` column (the engine's `host:port`) and an `AGENTIC` column whose value is
332
+ one of:
333
+
334
+ | AGENTIC | meaning |
335
+ | --- | --- |
336
+ | `starting` | transient: the worker just spawned and hasn't resolved its channel target yet (pre-`connecting`) |
337
+ | `connected` | presence is live on the hub — you should see this worker in the Cockpit |
338
+ | `connecting` | resolved a hub, socket not open yet (or the hub is unreachable) |
339
+ | `disconnected` | an established channel dropped (hub restart/outage) — it auto-reconnects; also set if the channel failed to start (bad URL/refused socket), in which case it stays disconnected until the worker restarts |
340
+ | `advisory` | nothing discoverable at the engine — **not** in the Cockpit; set `NANO_AGENTIC_URL` |
341
+ | `off` | visibility disabled (`NANO_AGENTIC=off`) |
342
+ | `?` | a live worker not yet reporting, or an older build predating these fields |
343
+
344
+ If workers show `advisory` (or stay `connecting`) while jobs still run, that's the
345
+ "connected to the engine but empty Cockpit" case: point them at the app with
346
+ `export NANO_AGENTIC_URL=http://<engine-host>:<appUi.port>` (e.g. `:3000`).
347
+
327
348
  **Secure mode (opt-in).** For a deployment where you want the visibility channel
328
349
  authenticated (rather than open on the LAN), start the server **and** every worker
329
350
  box with the **same** `NANO_AGENTIC_SECRET` — same env-var name, same value on both
package/c8ctl-plugin.js CHANGED
@@ -4407,6 +4407,93 @@ async function resolveAgenticTarget(opts = {}) {
4407
4407
  };
4408
4408
  }
4409
4409
 
4410
+ /**
4411
+ * Collapse an agentic disconnect/failure detail into the single short string the
4412
+ * marker's `agentic.message` field carries (#99 contract). Accepts the close
4413
+ * `info` the work channel's onDisconnect passes (transport-dependent shape, e.g.
4414
+ * `{ code, reason, local }`), a thrown Error, or a bare string.
4415
+ * @param {unknown} x diagnostic input (close info, Error, or string)
4416
+ * @returns {string|null} a human-readable reason, or null when nothing useful
4417
+ */
4418
+ function normalizeAgenticMessage(x) {
4419
+ // Collapse an agentic disconnect/failure detail into the single short string
4420
+ // the marker's `agentic.message` field carries (#99 contract). Accepts the
4421
+ // close `info` the work channel's onDisconnect passes (transport-dependent
4422
+ // shape, e.g. `{ code, reason, local }`), a thrown Error, or a bare string,
4423
+ // and returns a human-readable reason or null when there is nothing useful.
4424
+ if (x == null) return null;
4425
+ if (typeof x === 'string') return x.trim() || null;
4426
+ if (x instanceof Error) return x.message ? String(x.message) : String(x);
4427
+ if (typeof x === 'object') {
4428
+ const reason = typeof x.reason === 'string' ? x.reason.trim() : '';
4429
+ const code = x.code != null && x.code !== '' ? String(x.code) : '';
4430
+ if (reason && code) return `${reason} (code ${code})`;
4431
+ if (reason) return reason;
4432
+ if (code) return `close code ${code}`;
4433
+ if (x.message) return String(x.message);
4434
+ if (x.local === true) return 'closed locally';
4435
+ if (x.local === false) return 'connection dropped';
4436
+ return null;
4437
+ }
4438
+ return String(x);
4439
+ }
4440
+
4441
+ /**
4442
+ * Map a resolved `resolveAgenticTarget` result to the INITIAL agentic-channel
4443
+ * state persisted on the supervisor activity marker (#99). Pure so the marker
4444
+ * producer's state transitions are unit-testable without a live broker/SDK
4445
+ * client — a regression here would leave every supervised worker stuck at
4446
+ * `?`/`starting`, which the reader/renderer tests can't catch. `connected`/
4447
+ * `disconnected` are layered on top of this base by the channel lifecycle
4448
+ * (a `{ ...state, status }` merge). The `ambiguous` status is a hard-stop
4449
+ * handled by the caller (never reaches the marker), so it degrades to `off`
4450
+ * here. `safeUrl` mirrors the caller's defensive display-URL builder.
4451
+ * @param {{ status?: string, config?: any, message?: string }} target
4452
+ * @param {(u: string) => (string|null)} [safeUrl]
4453
+ */
4454
+ function agenticStateForTarget(target, safeUrl = (u) => u) {
4455
+ switch (target?.status) {
4456
+ case 'connect': {
4457
+ const cfg = target.config || {};
4458
+ return {
4459
+ status: 'connecting',
4460
+ mode: cfg.secure ? 'secure' : 'local',
4461
+ url: safeUrl(cfg.url),
4462
+ discovered: cfg.discovered || null,
4463
+ };
4464
+ }
4465
+ case 'advisory':
4466
+ // Retain the discovery diagnostic so the supervisor can distinguish a
4467
+ // missing projects API, a timeout, or a non-Nano endpoint (#99).
4468
+ return { status: 'advisory', message: target.message || null };
4469
+ case 'off':
4470
+ default:
4471
+ return { status: 'off' };
4472
+ }
4473
+ }
4474
+
4475
+ /**
4476
+ * Build the supervisor activity-marker payload the worker atomically writes for
4477
+ * `supervisor status`. Pure so the producer's field set is unit-testable without
4478
+ * spawning a worker: the reader/renderer tests exercise a hand-written marker and
4479
+ * `agenticStateForTarget` in isolation, so a regression that dropped `engine` or
4480
+ * `agentic` from THIS payload — leaving every supervised worker's Engine/Agentic
4481
+ * column stuck at `?` — would otherwise slip through. `jobs` is the live active-job
4482
+ * list; `busy` is derived so callers can't desync it from `jobs`.
4483
+ * @param {{ pid:number, updatedAt:number, jobs:Array<{key:string,type:string,since:number}>, engine:(string|null), agentic:object }} fields
4484
+ */
4485
+ function buildActivityPayload({ pid, updatedAt, jobs, engine, agentic }) {
4486
+ const jobList = Array.isArray(jobs) ? jobs : [];
4487
+ return {
4488
+ pid,
4489
+ updatedAt,
4490
+ busy: jobList.length > 0,
4491
+ jobs: jobList,
4492
+ engine: engine ?? null,
4493
+ agentic,
4494
+ };
4495
+ }
4496
+
4410
4497
  /**
4411
4498
  * work — turn a hire profile into live Nano job workers (one per job-type in
4412
4499
  * the rank×capability matrix) and poll for work in the foreground until Ctrl-C.
@@ -4695,10 +4782,29 @@ async function workAgent(req, flags) {
4695
4782
  installParentDeathWatchdog({ parentPid: Number.isInteger(daemonPid) ? daemonPid : undefined });
4696
4783
  }
4697
4784
  const activeJobs = new Map(); // jobKey -> { type, since (ms epoch) }
4785
+ // Which engine this worker polls jobs from + the live agentic-visibility
4786
+ // channel status, both surfaced to `supervisor status` via the activity
4787
+ // marker (#99). `agenticState` starts 'starting' and is updated once the
4788
+ // channel target is resolved and again on each connect/disconnect below.
4789
+ // The engine must name the ACTUAL polling authority: jobs are activated by
4790
+ // `camunda.createJobWorker()` against the active c8ctl profile engine
4791
+ // (`camunda.getConfig().restAddress`), whereas `restConfig` can honor the
4792
+ // auxiliary NANO_REST_URL/NANO_BASE_URL/nanoUrl overrides (for `--auto`
4793
+ // reads). Derive from the profile engine, using restConfig only as a
4794
+ // fallback, so the column can't advertise an override host jobs aren't
4795
+ // polled from.
4796
+ const workerEngine = (() => {
4797
+ try {
4798
+ const profileBase = normalizeRestBase(camunda?.getConfig?.()?.restAddress);
4799
+ if (profileBase) return profileBase;
4800
+ } catch { /* degrade to the auxiliary REST config below */ }
4801
+ return restConfig?.baseUrl || null;
4802
+ })();
4803
+ let agenticState = { status: 'starting' };
4698
4804
  const writeActivity = () => {
4699
4805
  if (!activityFile) return;
4700
4806
  const jobs = [...activeJobs.entries()].map(([key, v]) => ({ key, type: v.type, since: v.since }));
4701
- const payload = { pid: process.pid, updatedAt: Date.now(), busy: jobs.length > 0, jobs };
4807
+ const payload = buildActivityPayload({ pid: process.pid, updatedAt: Date.now(), jobs, engine: workerEngine, agentic: agenticState });
4702
4808
  const tmp = `${activityFile}.${process.pid}.tmp`;
4703
4809
  try {
4704
4810
  mkdirSync(dirname(activityFile), { recursive: true });
@@ -4747,9 +4853,22 @@ async function workAgent(req, flags) {
4747
4853
  // NANO_AGENTIC=off disables it (see resolveAgenticConfig).
4748
4854
  const agenticTarget = await resolveAgenticTarget({ logger });
4749
4855
  let agenticCfg = null;
4856
+ // buildAgenticUrl can throw on a malformed/unsupported explicit NANO_AGENTIC_URL.
4857
+ // This is only the display URL for the activity marker, so compute it
4858
+ // defensively: a bad URL must be recorded as a channel failure (via the
4859
+ // createWorkChannel try/catch below), never crash the worker before it — which
4860
+ // would violate the best-effort channel contract and cause a restart loop.
4861
+ const safeAgenticDisplayUrl = (u) => {
4862
+ try { return redactAgenticUrl(buildAgenticUrl(u, {})); }
4863
+ catch { return null; }
4864
+ };
4750
4865
  switch (agenticTarget.status) {
4751
4866
  case 'connect':
4752
4867
  agenticCfg = agenticTarget.config;
4868
+ // 'connecting' until the socket actually opens (wired on the channel
4869
+ // lifecycle below). Carry the resolved mode/target/discovery so the
4870
+ // supervisor can show WHERE presence is being announced (#99).
4871
+ agenticState = agenticStateForTarget(agenticTarget, safeAgenticDisplayUrl);
4753
4872
  break;
4754
4873
  case 'ambiguous':
4755
4874
  // The operator ran with visibility on-by-default but the hub is
@@ -4758,13 +4877,19 @@ async function workAgent(req, flags) {
4758
4877
  process.exit(1);
4759
4878
  break;
4760
4879
  case 'advisory':
4880
+ agenticState = agenticStateForTarget(agenticTarget);
4761
4881
  logger.info(` agentic channel: ${agenticTarget.message}`);
4762
4882
  break;
4763
4883
  case 'off':
4764
4884
  default:
4885
+ agenticState = agenticStateForTarget(agenticTarget);
4765
4886
  logger.info(' agentic channel: disabled — the off-switch is set (NANO_AGENTIC=off or persisted agentic:false). Clear it to use default LOCAL visibility.');
4766
4887
  break;
4767
4888
  }
4889
+ // Persist the resolved channel state to the activity marker now, so
4890
+ // `supervisor status` reflects connecting/advisory/off immediately, before
4891
+ // the socket opens (or without a channel at all).
4892
+ writeActivity();
4768
4893
  if (agenticCfg) {
4769
4894
  try {
4770
4895
  workChannel = await createWorkChannel({
@@ -4789,9 +4914,31 @@ async function workAgent(req, flags) {
4789
4914
  logger.info(` agentic channel: auto-discovered ${d.project} on the app's /agentic port ${wsHostPart(d.host)}:${d.port} (bypassing the WS-incapable console proxy).`);
4790
4915
  }
4791
4916
  logger.info(` agentic channel (${mode}): announcing presence as ${workerName} on ${shown}`);
4917
+ // Track the live connection state on the activity marker so the
4918
+ // supervisor shows connected↔disconnected transitions (#99). onConnect
4919
+ // fires only for listeners present at first open, so also reconcile the
4920
+ // already-open case synchronously via connected(). If the socket opened
4921
+ // and then dropped inside the createWorkChannel() await window (before
4922
+ // these listeners existed), connected() is false but everConnected() is
4923
+ // true — record that as `disconnected` rather than leaving it stuck at
4924
+ // `connecting`. A close carries a normalized diagnostic under the contract
4925
+ // `agentic.message` field (not `reason`) so a hub drop explains WHY; a
4926
+ // fresh (re)connect clears any stale message.
4927
+ const markAgentic = (status, message = null) => { agenticState = { ...agenticState, status, message }; writeActivity(); };
4928
+ workChannel.onConnect(() => markAgentic('connected'));
4929
+ workChannel.onReconnect(() => markAgentic('connected'));
4930
+ workChannel.onDisconnect((info) => markAgentic('disconnected', normalizeAgenticMessage(info)));
4931
+ if (workChannel.connected()) markAgentic('connected');
4932
+ else if (workChannel.everConnected()) markAgentic('disconnected');
4792
4933
  } catch (err) {
4793
4934
  // Never let a channel failure stop the worker from doing its actual job.
4794
4935
  workChannel = null;
4936
+ // Retain the failure reason on the marker so the supervisor can show WHY
4937
+ // presence dropped (bad URL, refused socket, …), not just `disconnected`.
4938
+ // The contract diagnostic field is `agentic.message` (#99), matching the
4939
+ // live-disconnect path above — keep the key consistent, not `reason`.
4940
+ agenticState = { ...agenticState, status: 'disconnected', message: normalizeAgenticMessage(err) };
4941
+ writeActivity();
4795
4942
  logger.warn(` agentic channel unavailable (${err?.message || err}); continuing without visibility.`);
4796
4943
  }
4797
4944
  // C4 (#43): observe the client's built-in outbound buffer across the
@@ -5656,6 +5803,8 @@ function summarizeSupervisorWorker(w, now = Date.now()) {
5656
5803
  // Per-job activity (supervised workers only). Guard on pid so a stale marker
5657
5804
  // left by a previous incarnation can't show a dead job as in-flight.
5658
5805
  let activity = null; // { state: 'busy'|'idle', jobs: [{ key, type, sinceMs, sinceEpochMs }] }
5806
+ let engine = null; // job-polling engine base URL this worker reported (#99)
5807
+ let agentic = null; // { status, mode, url, discovered } agentic-channel state (#99)
5659
5808
  if (alive) {
5660
5809
  const act = readWorkerActivity(w.id);
5661
5810
  if (act && act.pid === w.pid) {
@@ -5670,6 +5819,10 @@ function summarizeSupervisorWorker(w, now = Date.now()) {
5670
5819
  }))
5671
5820
  : [];
5672
5821
  activity = { state: jobs.length > 0 ? 'busy' : 'idle', jobs };
5822
+ // Engine + agentic-channel status ride the same pid-guarded marker, so a
5823
+ // stale incarnation can't show a dead worker as connected to a hub.
5824
+ engine = typeof act.engine === 'string' && act.engine ? act.engine : null;
5825
+ agentic = act.agentic && typeof act.agentic === 'object' ? act.agentic : null;
5673
5826
  }
5674
5827
  // No marker (or a stale-pid one): leave activity null → rendered as unknown.
5675
5828
  }
@@ -5684,6 +5837,8 @@ function summarizeSupervisorWorker(w, now = Date.now()) {
5684
5837
  lastExit: w.lastExit ?? null,
5685
5838
  args: Array.isArray(w.args) ? w.args : [],
5686
5839
  activity,
5840
+ engine,
5841
+ agentic,
5687
5842
  };
5688
5843
  }
5689
5844
 
@@ -5710,6 +5865,10 @@ function supervisorStatusSignature(workers) {
5710
5865
  w.activity
5711
5866
  ? w.activity.jobs.map((j) => `${j.key}\u0000${j.type ?? ''}`).sort()
5712
5867
  : null,
5868
+ // Engine + agentic-channel status: a connect/disconnect or an engine
5869
+ // change is a real transition that must repaint attached consoles (#99).
5870
+ w.engine ?? '',
5871
+ w.agentic ? (w.agentic.status ?? '') : null,
5713
5872
  ]),
5714
5873
  );
5715
5874
  }
@@ -5726,6 +5885,37 @@ function supervisorJobCell(w) {
5726
5885
  return `${first.key}${more}${dur}`;
5727
5886
  }
5728
5887
 
5888
+ /**
5889
+ * ENGINE cell: the authority (host:port) of the engine this worker polls jobs
5890
+ * from, so an operator can see cross-machine fleets at a glance. `-` for a
5891
+ * down/stopping worker, `?` for a live worker not (yet) reporting or on an
5892
+ * older build whose marker predates this field. A non-URL engine string falls
5893
+ * back to the raw value.
5894
+ */
5895
+ function supervisorEngineCell(w) {
5896
+ if (w.state !== 'running') return '-';
5897
+ if (!w.activity) return '?'; // alive but not reporting
5898
+ if (!w.engine) return '?'; // reporting, but marker predates the engine field
5899
+ try {
5900
+ const host = new URL(w.engine).host;
5901
+ return host || String(w.engine); // a scheme-less string parses host-empty
5902
+ } catch { return String(w.engine); }
5903
+ }
5904
+
5905
+ /**
5906
+ * AGENTIC cell: the visibility-channel status word
5907
+ * (`connected`/`connecting`/`disconnected`/`advisory`/`off`/`starting`), so an
5908
+ * operator can tell whether presence actually reached the Workforce hub. `-`
5909
+ * for a down/stopping worker, `?` for a live worker not (yet) reporting or on an
5910
+ * older build whose marker predates this field.
5911
+ */
5912
+ function supervisorAgenticCell(w) {
5913
+ if (w.state !== 'running') return '-';
5914
+ if (!w.activity) return '?'; // alive but not reporting
5915
+ if (!w.agentic || !w.agentic.status) return '?'; // marker predates the agentic field
5916
+ return String(w.agentic.status);
5917
+ }
5918
+
5729
5919
  /**
5730
5920
  * Re-age a supervisor status snapshot to `now`, recomputing the ticking
5731
5921
  * durations (`uptimeMs`, per-job `sinceMs`) from the absolute base epochs the
@@ -5871,14 +6061,19 @@ function formatSupervisorStatus(status) {
5871
6061
  id: String(w.id),
5872
6062
  profile: String(w.profile),
5873
6063
  state: String(w.state),
6064
+ engine: supervisorEngineCell(w),
6065
+ agentic: supervisorAgenticCell(w),
5874
6066
  job: supervisorJobCell(w),
5875
6067
  pid: w.pid ? String(w.pid) : '-',
5876
6068
  restarts: String(w.restarts),
5877
6069
  uptime: w.state === 'running' ? formatDuration(w.uptimeMs) : '-',
5878
6070
  last: w.lastExit ? String(w.lastExit) : '-',
5879
6071
  }));
5880
- const head = { id: 'ID', profile: 'PROFILE', state: 'STATE', job: 'JOB', pid: 'PID', restarts: 'RESTARTS', uptime: 'UPTIME', last: 'LAST EXIT' };
5881
- const cols = ['id', 'profile', 'state', 'job', 'pid', 'restarts', 'uptime', 'last'];
6072
+ // ENGINE + AGENTIC sit early (just after STATE) so the pinned live view's
6073
+ // width clamp (which trims from the right) drops the least-critical columns
6074
+ // (LAST EXIT, UPTIME) first and keeps the visibility diagnostics visible.
6075
+ const head = { id: 'ID', profile: 'PROFILE', state: 'STATE', engine: 'ENGINE', agentic: 'AGENTIC', job: 'JOB', pid: 'PID', restarts: 'RESTARTS', uptime: 'UPTIME', last: 'LAST EXIT' };
6076
+ const cols = ['id', 'profile', 'state', 'engine', 'agentic', 'job', 'pid', 'restarts', 'uptime', 'last'];
5882
6077
  const width = {};
5883
6078
  for (const c of cols) width[c] = Math.max(head[c].length, ...rows.map((r) => r[c].length));
5884
6079
  const fmt = (r) => ' ' + cols.map((c) => r[c].padEnd(width[c])).join(' ');
@@ -8723,6 +8918,11 @@ export {
8723
8918
  printSupervisorStatus,
8724
8919
  supervisorStatusSignature,
8725
8920
  supervisorJobCell,
8921
+ supervisorEngineCell,
8922
+ supervisorAgenticCell,
8923
+ agenticStateForTarget,
8924
+ normalizeAgenticMessage,
8925
+ buildActivityPayload,
8726
8926
  supervisorWorkerActivityFile,
8727
8927
  WORK_FORWARD_FLAGS,
8728
8928
  installParentDeathWatchdog,
@@ -8783,7 +8983,7 @@ export const metadata = {
8783
8983
  { command: 'NANO_AGENTIC_URL=http://localhost:8080 NANO_AGENTIC_SECRET=<shared-secret> c8ctl nano work reviewer', description: 'Enrol the worker on the app\'s same-port /agentic channel in SECURE mode (same NANO_AGENTIC_SECRET as the server) so it appears live (presence + relay terminals) on the Workforce visibility page' },
8784
8984
  { command: 'c8ctl nano supervisor start --worker reviewer --worker coder', description: 'Start a detached supervisor managing several workers from one terminal' },
8785
8985
  { command: 'c8ctl nano supervisor', description: 'Attach an interactive console to the supervisor (detach with Ctrl-D, leaving it running)' },
8786
- { command: 'c8ctl nano supervisor status', description: 'List supervised workers (pid, state, serviced job / idle, restarts, uptime) without the console' },
8986
+ { command: 'c8ctl nano supervisor status', description: 'List supervised workers (state, ENGINE + AGENTIC visibility diagnostics, serviced job / idle, pid, restarts, uptime) without the console' },
8787
8987
  { command: 'c8ctl nano supervisor add decider --max-parallel 2', description: 'Add a supervised worker (forwarding work flags) to the running supervisor' },
8788
8988
  { command: 'c8ctl nano supervisor add reviewer --instances 3', description: 'Add 3 distinct auto-named instances of a profile in one call' },
8789
8989
  { command: 'c8ctl nano supervisor restart reviewer', description: 'Restart a supervised worker by id or profile' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.36.2",
3
+ "version": "1.38.0",
4
4
  "type": "module",
5
5
  "description": "c8ctl plugin to start, inspect, and stop a local Nano BPM (nanobpmn) cluster",
6
6
  "main": "c8ctl-plugin.js",
@@ -53,16 +53,16 @@
53
53
  },
54
54
  "dependencies": {
55
55
  "@nanobpm/agentic": "^0.1.0",
56
- "@nanobpm/urban-agent-client": "^0.1.0"
56
+ "@nanobpm/urban-agent-client": "^0.1.4"
57
57
  },
58
58
  "optionalDependencies": {
59
59
  "node-pty": "^1.0.0",
60
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.36.2",
61
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.36.2",
62
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.36.2",
63
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.36.2",
64
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.36.2",
65
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.36.2",
66
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.36.2"
60
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.38.0",
61
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.38.0",
62
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.38.0",
63
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.38.0",
64
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.38.0",
65
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.38.0",
66
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.38.0"
67
67
  }
68
68
  }
package/work-channel.mjs CHANGED
@@ -118,6 +118,7 @@ function presenceCapability(capability, jobs) {
118
118
  * @property {(fn: (info: object) => void) => () => void} onDisconnect subscribe to channel close
119
119
  * @property {(fn: () => void) => () => void} onReconnect subscribe to reconnects (every open after the first)
120
120
  * @property {() => boolean} connected whether the channel is currently open
121
+ * @property {() => boolean} everConnected whether the channel has ever opened (stays true after a later close)
121
122
  * @property {() => number} buffered outbound frames currently buffered awaiting the channel
122
123
  * @property {(reason?: string) => Promise<void>} stop deregister + close cleanly
123
124
  */
@@ -272,6 +273,12 @@ export async function createWorkChannel(opts) {
272
273
  onReconnect: subscribe(reconnectListeners),
273
274
  onDisconnect: subscribe(disconnectListeners),
274
275
  connected: () => client.connected,
276
+ // Whether the channel has ever opened (even if it has since closed). Lets a
277
+ // late subscriber tell "still connecting, never opened" (false) apart from
278
+ // "opened then dropped before I subscribed" (true), so an initial close that
279
+ // fires inside the createWorkChannel() await window is reconciled to
280
+ // `disconnected` rather than left stuck at `connecting`.
281
+ everConnected: () => hasConnected,
275
282
  buffered: () => client.buffered,
276
283
  async stop(reason = 'worker stopped') {
277
284
  try {