c8ctl-plugin-nano 1.61.0 → 1.61.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.61.0",
3
+ "version": "1.61.2",
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",
@@ -74,12 +74,12 @@
74
74
  },
75
75
  "optionalDependencies": {
76
76
  "node-pty": "^1.0.0",
77
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.61.0",
78
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.61.0",
79
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.61.0",
80
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.61.0",
81
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.61.0",
82
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.61.0",
83
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.61.0"
77
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.61.2",
78
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.61.2",
79
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.61.2",
80
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.61.2",
81
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.61.2",
82
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.61.2",
83
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.61.2"
84
84
  }
85
85
  }
package/work-relay.mjs CHANGED
@@ -143,7 +143,7 @@ export function parseInboundRelayChunk(frame, stream) {
143
143
  * @property {string} stream the relay stream name (derived from the jobKey)
144
144
  * @property {(chunk: string|Uint8Array) => void} relay publish one framed, jobKey-tagged output chunk on the relay lane
145
145
  * @property {(write: (chunk: string) => void) => (() => void)} attachSteer wire inbound steer bytes for this stream to `write`; returns a detach fn
146
- * @property {() => Promise<{ closeEmitted: boolean, drained: boolean, timedOut: boolean }>} close emit the `phase:close` lifecycle event, detach any steer subscription, then drain the outbound buffer (bounded)
146
+ * @property {(reason?: string) => Promise<{ closeEmitted: boolean, drained: boolean, timedOut: boolean, updates?: number, bytes?: number, reason?: string }>} close emit the `phase:close` lifecycle event, detach any steer subscription, then drain the outbound buffer (bounded). The channel-backed session ({@link createRelaySession}) IGNORES `reason`, logs no reason/totals, and resolves only `{ closeEmitted, drained, timedOut }`. The host-connection session ({@link createHostRelaySession}) accepts `reason` (normal / job-killed / error), logs it with the update/byte totals (#229), and additionally resolves with the observability totals `updates`, `bytes`, and the resolved `reason`.
147
147
  */
148
148
 
149
149
  /**
@@ -339,10 +339,13 @@ export function createRelaySession({
339
339
  * @param {(text: string) => void} opts.publish stream one transcript chunk over the host connection
340
340
  * @param {(onChunk: (chunk: string|Uint8Array) => void) => (() => void)} [opts.subscribeSteer]
341
341
  * register a steer-in sink for this instance; returns an unsubscribe fn
342
+ * @param {string} [opts.elementInstanceKey] correlation join key stamped on open/close logs (#229)
343
+ * @param {string} [opts.processInstanceKey] correlation join key stamped on open/close logs (#229)
344
+ * @param {string|(() => string)} [opts.agentInstanceKey] the AgentInstance key (or a lazy getter, since it is minted after the session opens) — joins the relay and engine channels (#229)
342
345
  * @param {{ warn?: Function, debug?: Function }} [opts.logger]
343
346
  * @returns {RelaySession}
344
347
  */
345
- export function createHostRelaySession({ instance, jobKey, publish, subscribeSteer, logger } = {}) {
348
+ export function createHostRelaySession({ instance, jobKey, publish, subscribeSteer, logger, elementInstanceKey, processInstanceKey, agentInstanceKey } = {}) {
346
349
  if (instance === undefined || instance === null || String(instance) === '') {
347
350
  throw new Error('createHostRelaySession requires an instance');
348
351
  }
@@ -360,6 +363,29 @@ export function createHostRelaySession({ instance, jobKey, publish, subscribeSte
360
363
  // the actual wire id is composed by the emit client at emit time.
361
364
  const stream = composeStreamId(String(instance), String(jobKey));
362
365
  const log = logger || {};
366
+ // #229: the AgentInstance key is only known AFTER the producer's activate()
367
+ // resolves — which can be after this session is created — so accept it as a
368
+ // getter (or a value) and resolve it lazily when logging open/close.
369
+ const resolveAik = () => {
370
+ try {
371
+ const v = typeof agentInstanceKey === 'function' ? agentInstanceKey() : agentInstanceKey;
372
+ return v != null && String(v) !== '' ? String(v) : '?';
373
+ } catch {
374
+ return '?';
375
+ }
376
+ };
377
+ // #229 cross-channel correlation for the relay lane: join the relay to the
378
+ // AgentInstance + engine channels (today the relay and engine channels can't be
379
+ // reconciled) and to the job/git channels via jobKey/eik/pik.
380
+ const eik = elementInstanceKey != null ? String(elementInstanceKey) : '?';
381
+ const pik = processInstanceKey != null ? String(processInstanceKey) : '?';
382
+ const corr = () => `stream ${stream} aik ${resolveAik()} eik ${eik} pik ${pik} job ${String(jobKey)}`;
383
+ // #229 relay activity accounting: number of agent-output chunks relayed and their
384
+ // total bytes, plus a one-shot "received first update" marker. A relay that opens
385
+ // but never receives an update (the 59-byte husk signature) is now visible.
386
+ let updateCount = 0;
387
+ let byteCount = 0;
388
+ let firstUpdateLogged = false;
363
389
 
364
390
  const relay = (chunk) => {
365
391
  if (chunk == null) return;
@@ -380,6 +406,31 @@ export function createHostRelaySession({ instance, jobKey, publish, subscribeSte
380
406
  }
381
407
  };
382
408
 
409
+ // Count + log agent-output activity (never the open/close lifecycle markers,
410
+ // which go through relay() directly). The first update proves the relay is live.
411
+ const relayUpdate = (chunk) => {
412
+ if (chunk == null) return;
413
+ // #229: once close() has emitted RELAY_CLOSE_CHUNK, the close marker must stay
414
+ // terminal and the logged totals stable. A one-shot/ACP capture can resolve on
415
+ // timeout/abort before the child's final close event, after which workAgent
416
+ // closes the relay; a late data event arriving here would otherwise publish
417
+ // after the close marker and bump updateCount/byteCount past the close summary.
418
+ if (closed) return;
419
+ const text = typeof chunk === 'string'
420
+ ? chunk
421
+ : Buffer.isBuffer(chunk)
422
+ ? chunk.toString('utf8')
423
+ : Buffer.from(chunk).toString('utf8');
424
+ if (text === '') return;
425
+ updateCount += 1;
426
+ byteCount += Buffer.byteLength(text, 'utf8');
427
+ if (!firstUpdateLogged) {
428
+ firstUpdateLogged = true;
429
+ try { log.debug?.(`relay received first update (${corr()})`); } catch { /* swallow */ }
430
+ }
431
+ relay(text);
432
+ };
433
+
383
434
  // Each attachSteer call owns its own subscription + detach fn (mirrors
384
435
  // createRelaySession), so a second attachSteer can't clobber an earlier one.
385
436
  const activeDetaches = new Set();
@@ -429,19 +480,36 @@ export function createHostRelaySession({ instance, jobKey, publish, subscribeSte
429
480
  // steer subscription is torn down.
430
481
  let closed = false;
431
482
  let closedPromise = Promise.resolve({ closeEmitted: false, drained: true, timedOut: false });
432
- const close = () => {
483
+ const close = (reason = 'normal') => {
433
484
  if (closed) return closedPromise;
434
485
  closed = true;
435
486
  // Emit the closing lifecycle twin of RELAY_OPEN_CHUNK so the app can flush the
436
487
  // durable transcript deterministically at completion (nano-workforce#710).
437
488
  relay(RELAY_CLOSE_CHUNK);
438
489
  for (const detach of [...activeDetaches]) detach();
439
- closedPromise = Promise.resolve({ closeEmitted: true, drained: true, timedOut: false });
490
+ // #229: log the close with total updates, total bytes, and the close reason
491
+ // (normal / job-killed / error) so a relay that opened but never received an
492
+ // update (the 59-byte husk) is unambiguous in the worker log.
493
+ try {
494
+ log.debug?.(`relay closed (${corr()}) — ${updateCount} update(s), ${byteCount} byte(s), reason ${reason || 'normal'}`);
495
+ } catch {
496
+ /* swallow */
497
+ }
498
+ closedPromise = Promise.resolve({ closeEmitted: true, drained: true, timedOut: false, updates: updateCount, bytes: byteCount, reason: reason || 'normal' });
440
499
  return closedPromise;
441
500
  };
442
501
 
443
502
  // Open the stream the instant the session exists (parity with createRelaySession).
444
503
  relay(RELAY_OPEN_CHUNK);
504
+ // #229: log the open with the correlation join keys (stream + AgentInstance key +
505
+ // elementInstanceKey/processInstanceKey) so the relay, engine, job and git
506
+ // channels can finally be reconciled — the AgentInstance key is resolved lazily
507
+ // so a producer that mints slightly later is still captured on close.
508
+ try {
509
+ log.debug?.(`relay opened (${corr()})`);
510
+ } catch {
511
+ /* swallow */
512
+ }
445
513
 
446
- return { stream, relay, attachSteer, close };
514
+ return { stream, relay: relayUpdate, attachSteer, close };
447
515
  }