c8ctl-plugin-nano 1.58.0 → 1.59.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.
@@ -305,7 +305,10 @@ function buildRawEmitClient({ channelUrl, transportFactory, peerAdvertisement, l
305
305
  * `transportFactory` and never touches the real client.
306
306
  *
307
307
  * @param {object} opts
308
- * @param {string} opts.url the app's HTTP(S) base URL (channel served same-port at `/agentic`)
308
+ * @param {string} [opts.url] the app's HTTP(S) base URL (channel served same-port at
309
+ * `/agentic`). OPTIONAL in self-heal mode: when `resolveConfig` is supplied the base
310
+ * URL is not fixed, but a static `url` may still be passed to SEED the initial memo
311
+ * before the first re-discovery lands. Required only when `resolveConfig` is absent.
309
312
  * @param {string} [opts.token] ADR 0028 identity token (carried as `?token=`)
310
313
  * @param {string} [opts.credential] capability credential (carried as `?capability=`)
311
314
  * @param {import('@nanobpm/agentic/protocol').ProtocolAdvertisement} [opts.remoteAdvertisement]
@@ -314,23 +317,91 @@ function buildRawEmitClient({ channelUrl, transportFactory, peerAdvertisement, l
314
317
  * @param {(state: 'connected'|'disconnected') => void} [opts.onConnectionState] observer fired
315
318
  * when the single host connection opens/drops, so a caller can track its liveness
316
319
  * @param {{ warn?: Function, debug?: Function }} [opts.logger]
317
- * @returns {Promise<() => import('./supervisor.dist.js').RawEmitClient>} a synchronous `connect` factory
320
+ * @param {() => Promise<{url:string, token?:string, credential?:string}|null>} [opts.resolveConfig]
321
+ * OPTIONAL self-heal resolver (jwulf/c8ctl-plugin-nano#133). When present the
322
+ * base URL is NOT fixed: each (re)connect consults a background-refreshed,
323
+ * memoised config, and while none is known the synchronous factory THROWS —
324
+ * which `superviseAgentic` turns into its jittered, ≤30s-capped reconnect
325
+ * retry — and kicks a guarded async re-discovery, so a later retry finds the
326
+ * hub and connects (`advisory → connected` without a restart). A moved hub
327
+ * self-heals the same way. Fail-open: a throwing resolver is swallowed.
328
+ * @returns {Promise<() => import('./supervisor.dist.js').RawEmitClient>} a synchronous
329
+ * `connect` factory. In self-heal (`resolveConfig`) mode it may THROW when the
330
+ * hub is not yet discoverable — by contract that drives `superviseAgentic`'s retry.
318
331
  */
319
332
  export async function createRawEmitConnect(opts) {
320
- const { url, token, credential, remoteAdvertisement, transportFactory, logger, onConnectionState } = opts || {};
321
- if (typeof url !== 'string' || url.trim() === '') {
322
- throw new Error('createRawEmitConnect requires an agentic channel base url');
333
+ const { url, token, credential, remoteAdvertisement, transportFactory, logger, onConnectionState, resolveConfig } =
334
+ opts || {};
335
+ const hasResolver = typeof resolveConfig === 'function';
336
+ const hasStaticUrl = typeof url === 'string' && url.trim() !== '';
337
+ if (!hasResolver && !hasStaticUrl) {
338
+ throw new Error(
339
+ 'createRawEmitConnect requires an agentic channel base `url`, or a `resolveConfig` self-heal resolver to discover one',
340
+ );
323
341
  }
324
342
 
325
- const channelUrl = buildAgenticUrl(url, { token, credential });
326
343
  const factory = transportFactory ?? (await loadAgenticClient()).websocketTransport;
327
344
 
328
- return () =>
329
- buildRawEmitClient({
330
- channelUrl,
345
+ // Static mode: a fixed, known URL (explicit NANO_AGENTIC_URL or an
346
+ // already-discovered hub). The channel URL is built once; the emit client owns
347
+ // reconnect to it and `superviseAgentic` re-`connect`s to the SAME URL on a
348
+ // permanent close. Unchanged behaviour.
349
+ if (!hasResolver) {
350
+ const channelUrl = buildAgenticUrl(url, { token, credential });
351
+ return () =>
352
+ buildRawEmitClient({
353
+ channelUrl,
354
+ transportFactory: factory,
355
+ peerAdvertisement: remoteAdvertisement,
356
+ logger,
357
+ onConnectionState,
358
+ });
359
+ }
360
+
361
+ // Self-heal mode: re-resolve discovery on each (re)connect via a guarded,
362
+ // background-refreshed memoised config. While unresolved the factory throws so
363
+ // `superviseAgentic`'s ≤30s-capped retry doubles as periodic re-discovery.
364
+ let current = hasStaticUrl ? { url, token, credential } : null;
365
+ let refreshing = false;
366
+ const kickRefresh = () => {
367
+ if (refreshing) return;
368
+ refreshing = true;
369
+ Promise.resolve()
370
+ .then(() => resolveConfig())
371
+ .then((next) => {
372
+ if (next && typeof next.url === 'string' && next.url.trim() !== '') {
373
+ // Preserve the last memoised credentials when a later refresh omits
374
+ // them, falling back to the initial opts only when nothing was ever
375
+ // discovered — so a partial refresh never clears a known-good token.
376
+ current = {
377
+ url: next.url,
378
+ token: next.token ?? current?.token ?? token,
379
+ credential: next.credential ?? current?.credential ?? credential,
380
+ };
381
+ }
382
+ })
383
+ .catch((err) => {
384
+ logger?.debug?.(`agentic re-discovery attempt failed: ${err?.message || err}`);
385
+ })
386
+ .finally(() => {
387
+ refreshing = false;
388
+ });
389
+ };
390
+
391
+ return () => {
392
+ // Kick a background refresh on every (re)connect so a moved hub self-heals on
393
+ // the next attempt; it's a no-op while one is in flight, and only runs on a
394
+ // (re)connect (a steady socket never re-enters this factory).
395
+ kickRefresh();
396
+ if (!current) {
397
+ throw new Error('agentic hub not yet discoverable — retrying via supervised reconnect');
398
+ }
399
+ return buildRawEmitClient({
400
+ channelUrl: buildAgenticUrl(current.url, { token: current.token, credential: current.credential }),
331
401
  transportFactory: factory,
332
402
  peerAdvertisement: remoteAdvertisement,
333
403
  logger,
334
404
  onConnectionState,
335
405
  });
406
+ };
336
407
  }
package/c8ctl-plugin.js CHANGED
@@ -6715,94 +6715,6 @@ async function resolveAgenticTarget({ camunda, cache, ...opts } = {}) {
6715
6715
  };
6716
6716
  }
6717
6717
 
6718
- /**
6719
- * A jittered backoff schedule (ms) for background agentic re-discovery (#133-A):
6720
- * ~2s → 4s → 8s → 16s → 30s (capped), each waited value randomised ±20%, spanning
6721
- * roughly `ceilingMs` (default ~5 minutes) of total elapsed retry time before the
6722
- * loop gives up. A worker that lost the cold-start discovery race walks this
6723
- * schedule to upgrade `advisory → connected` without a restart.
6724
- * @param {{ base?: number, cap?: number, ceilingMs?: number, rng?: () => number }} [opts]
6725
- * @returns {number[]} the ordered per-attempt wait durations
6726
- */
6727
- function defaultAgenticRediscoveryDelays({
6728
- base = 2_000,
6729
- cap = 30_000,
6730
- ceilingMs = 5 * 60 * 1_000,
6731
- rng = Math.random,
6732
- } = {}) {
6733
- const delays = [];
6734
- let d = base;
6735
- let total = 0;
6736
- while (total < ceilingMs) {
6737
- const jitter = Math.round((rng() - 0.5) * 0.4 * d); // ±20%
6738
- const wait = Math.max(500, d + jitter);
6739
- delays.push(wait);
6740
- total += wait;
6741
- d = Math.min(cap, d * 2);
6742
- }
6743
- return delays;
6744
- }
6745
-
6746
- /**
6747
- * Background self-heal loop for agentic discovery (#133-A). After an initial
6748
- * cold-start miss leaves a worker in `advisory`, re-run `resolveTarget` on a
6749
- * jittered backoff schedule and, on the FIRST attempt that yields a
6750
- * `status:'connect'` target, invoke `onConnect(target)` and stop — so the worker
6751
- * upgrades to `connected` without a restart. Fail-open: an attempt whose
6752
- * `resolveTarget` throws is swallowed and the loop continues; likewise, if
6753
- * `onConnect` itself throws (a transient channel-open failure), the loop keeps
6754
- * re-discovering rather than stopping, so retries proceed until a connect
6755
- * callback actually succeeds. The loop also stops early whenever
6756
- * `shouldContinue()` returns false (e.g. a channel already came up, or the worker
6757
- * is shutting down). Returns the connecting target, or `null` if the schedule was
6758
- * exhausted / cancelled without a hit. Timers and the resolver are injectable so
6759
- * this is unit-testable without real waits or sockets.
6760
- *
6761
- * @param {{
6762
- * resolveTarget: () => Promise<{status:string, config?:object}>,
6763
- * onConnect?: (target: {status:string, config?:object}) => (void|Promise<void>),
6764
- * delaysMs?: number[],
6765
- * sleep?: (ms:number) => Promise<void>,
6766
- * shouldContinue?: () => boolean,
6767
- * logger?: object,
6768
- * }} opts
6769
- * @returns {Promise<object|null>}
6770
- */
6771
- async function rediscoverAgenticUntilConnected({
6772
- resolveTarget,
6773
- onConnect,
6774
- delaysMs = defaultAgenticRediscoveryDelays(),
6775
- sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
6776
- shouldContinue = () => true,
6777
- logger = null,
6778
- } = {}) {
6779
- if (typeof resolveTarget !== 'function') return null;
6780
- for (const delay of delaysMs) {
6781
- if (!shouldContinue()) return null;
6782
- try { await sleep(delay); } catch { return null; }
6783
- if (!shouldContinue()) return null;
6784
- let target;
6785
- try {
6786
- target = await resolveTarget();
6787
- } catch (err) {
6788
- logger?.debug?.(`agentic re-discovery attempt failed: ${err?.message || err}`);
6789
- continue;
6790
- }
6791
- if (target && target.status === 'connect') {
6792
- try {
6793
- await onConnect?.(target);
6794
- } catch (err) {
6795
- // A transient channel-open failure must not prematurely stop self-heal:
6796
- // keep re-discovering until an onConnect callback actually succeeds.
6797
- logger?.debug?.(`agentic re-discovery onConnect failed, will retry: ${err?.message || err}`);
6798
- continue;
6799
- }
6800
- return target;
6801
- }
6802
- }
6803
- return null;
6804
- }
6805
-
6806
6718
  // Worker-side liveness watchdog defaults (jwulf/c8ctl-plugin-nano#144). A
6807
6719
  // previously-connected agentic channel that has been `disconnected` for longer
6808
6720
  // than the stale threshold — because the client lib's own reconnect never
@@ -7828,8 +7740,24 @@ async function workAgent(req, flags) {
7828
7740
  // worker joins with the well-known LOCAL token and no credential; SECURE mode
7829
7741
  // (NANO_AGENTIC_SECRET) sends a real per-peer shared secret as the identity;
7830
7742
  // NANO_AGENTIC=off disables it (see resolveAgenticConfig).
7831
- const agenticTarget = await resolveAgenticTarget({ camunda, logger });
7743
+ const agenticDiscoveryCache = new Map();
7744
+ const agenticTarget = await resolveAgenticTarget({ camunda, logger, cache: agenticDiscoveryCache });
7832
7745
  let agenticCfg = null;
7746
+ let agenticSelfHeal = false;
7747
+ // Self-heal resolver (jwulf/c8ctl-plugin-nano#133): re-run discovery on demand
7748
+ // and hand back a fresh connect config, or null while still undiscoverable. The
7749
+ // endpoint's connect factory calls this in the background so `superviseAgentic`'s
7750
+ // ≤30s-capped reconnect doubles as periodic re-discovery — an `advisory`-at-
7751
+ // startup worker (a cold-start discovery miss during an engine hiccup) upgrades
7752
+ // to `connected` without a restart when the hub reappears. The shared cache lets
7753
+ // a later transient miss reuse the last known-good hub (#133-C).
7754
+ const resolveAgenticConnectConfig = async () => {
7755
+ const t = await resolveAgenticTarget({ camunda, logger, cache: agenticDiscoveryCache });
7756
+ if (t && t.status === 'connect') {
7757
+ return { url: t.config.url, token: t.config.token, credential: t.config.credential };
7758
+ }
7759
+ return null;
7760
+ };
7833
7761
  // buildAgenticUrl can throw on a malformed/unsupported explicit NANO_AGENTIC_URL.
7834
7762
  // This is only the display URL for the activity marker, so compute it
7835
7763
  // defensively: a bad URL must be recorded as a channel failure (via the
@@ -7853,10 +7781,31 @@ async function workAgent(req, flags) {
7853
7781
  logger.error(` agentic visibility is ambiguous: ${agenticTarget.message}`);
7854
7782
  process.exit(1);
7855
7783
  break;
7856
- case 'advisory':
7857
- agenticState = agenticStateForTarget(agenticTarget);
7858
- logger.info(` agentic channel: ${agenticTarget.message}`);
7784
+ case 'advisory': {
7785
+ // Don't give up: build a SELF-HEALING endpoint whose connect re-discovers
7786
+ // on each of `superviseAgentic`'s ≤30s-capped reconnects, so a cold-start
7787
+ // discovery miss (e.g. the engine hiccuped just as the worker started)
7788
+ // upgrades to `connected` without a restart (jwulf/c8ctl-plugin-nano#133).
7789
+ agenticSelfHeal = true;
7790
+ // Derive one retry-aware message so the marker's `agentic.message` and the
7791
+ // log line agree: consumers of `supervisor status`/markers must see that the
7792
+ // worker WILL self-heal, not the raw "Continuing without it." advisory text.
7793
+ const retryMessage = agenticTarget.message.replace(
7794
+ 'Continuing without it.',
7795
+ 'Retrying discovery on each ≤30s reconnect until it self-heals.',
7796
+ );
7797
+ // Derive the auth mode from the resolved agentic config so `supervisor
7798
+ // status`/markers report SECURE (NANO_AGENTIC_SECRET) correctly while the
7799
+ // worker is still reconnecting, instead of hard-coding 'local'.
7800
+ agenticState = {
7801
+ status: 'connecting',
7802
+ mode: resolveAgenticConfig(camunda)?.secure ? 'secure' : 'local',
7803
+ url: null,
7804
+ message: retryMessage,
7805
+ };
7806
+ logger.info(` agentic channel: ${retryMessage}`);
7859
7807
  break;
7808
+ }
7860
7809
  case 'off':
7861
7810
  default:
7862
7811
  agenticState = agenticStateForTarget(agenticTarget);
@@ -7885,17 +7834,23 @@ async function workAgent(req, flags) {
7885
7834
  // the raw-JS wire (`agentic-endpoint.mjs` → the single agentic import surface)
7886
7835
  // with the bundle's Effect adapter; additive negotiation still degrades
7887
7836
  // claim/release/steer to a no-op against an older hub. Reconnect + resync (and
7888
- // teardown-on-interruption) are owned by the runtime's `superviseAgentic`, so the
7889
- // retired #133 self-heal loop and #144/#147 liveness watchdog are gone the
7890
- // marker's agentic status is driven by the endpoint's connection observer below.
7891
- // Constructing the endpoint opens NO socket (the runtime's supervision calls the
7892
- // connect factory), so this stays cheap and side-effect-free until `run` forks.
7893
- if (agenticCfg) {
7837
+ // teardown-on-interruption) are owned by the runtime's `superviseAgentic`; the
7838
+ // #133 self-heal is REINSTATED without a parallel loop by handing the `advisory`
7839
+ // case a re-discovering connect factory (`resolveConfig`), so `superviseAgentic`'s
7840
+ // own ≤30s-capped reconnect retry IS the periodic re-discovery (the #144/#147
7841
+ // liveness watchdog remains retired) the marker's agentic status is driven by
7842
+ // the endpoint's connection observer below. Constructing the endpoint opens NO
7843
+ // socket (the runtime's supervision calls the connect factory), so this stays
7844
+ // cheap and side-effect-free until `run` forks.
7845
+ if (agenticCfg || agenticSelfHeal) {
7894
7846
  try {
7895
7847
  agenticEndpoint = await createAgenticEndpoint({
7896
- url: agenticCfg.url,
7897
- token: agenticCfg.token,
7898
- credential: agenticCfg.credential,
7848
+ // Discovered/explicit connect: a fixed URL (static factory). Advisory
7849
+ // self-heal: no URL yet — the resolver supplies it (and heals a moved hub)
7850
+ // on each supervised reconnect.
7851
+ ...(agenticCfg
7852
+ ? { url: agenticCfg.url, token: agenticCfg.token, credential: agenticCfg.credential }
7853
+ : { resolveConfig: resolveAgenticConnectConfig }),
7899
7854
  // Keep the activity marker's agentic status honest across the single
7900
7855
  // connection's open/drop transitions (#99) — the direct analogue of the
7901
7856
  // retired createWorkChannel onConnect/onDisconnect marker wiring.
@@ -13744,8 +13699,6 @@ export {
13744
13699
  resolveProbeCandidates,
13745
13700
  raceProbeCandidates,
13746
13701
  isLinkLocalAddress,
13747
- rediscoverAgenticUntilConnected,
13748
- defaultAgenticRediscoveryDelays,
13749
13702
  agenticChannelIsStale,
13750
13703
  agenticPresenceIsStale,
13751
13704
  jitteredDelay,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.58.0",
3
+ "version": "1.59.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",
@@ -73,12 +73,12 @@
73
73
  },
74
74
  "optionalDependencies": {
75
75
  "node-pty": "^1.0.0",
76
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.58.0",
77
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.58.0",
78
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.58.0",
79
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.58.0",
80
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.58.0",
81
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.58.0",
82
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.58.0"
76
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.59.0",
77
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.59.0",
78
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.59.0",
79
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.59.0",
80
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.59.0",
81
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.59.0",
82
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.59.0"
83
83
  }
84
84
  }