c8ctl-plugin-nano 1.52.0 → 1.53.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/work-relay.mjs CHANGED
@@ -309,3 +309,150 @@ export function createRelaySession({
309
309
 
310
310
  return { stream, relay, attachSteer, close };
311
311
  }
312
+
313
+ /**
314
+ * The canonical host-connection transcript stream name for a supervised job
315
+ * (issue #173). Unlike {@link relayStreamName} (which keys purely on the
316
+ * jobKey, safe only when one process owns one identity), the single host
317
+ * connection multiplexes N workers, so the stream carries BOTH the owning
318
+ * `instance` and the `jobKey` explicitly — two workers' (or two jobs') streams
319
+ * over the one socket can never collide. Mirrors the endpoint's own
320
+ * `composeTranscriptStream`, exposed here only for observability/parity.
321
+ *
322
+ * @param {string} instance the owning worker instance
323
+ * @param {string|number} jobKey the activated job's key
324
+ * @returns {string}
325
+ */
326
+ export function hostRelayStreamName(instance, jobKey) {
327
+ return `t/${encodeURIComponent(String(instance))}/${encodeURIComponent(String(jobKey))}`;
328
+ }
329
+
330
+ /**
331
+ * Create a per-job relay session backed by the single-owner supervisor's ONE
332
+ * multiplexed host connection (issue #173) instead of a per-worker
333
+ * {@link createWorkChannel} socket.
334
+ *
335
+ * It is shape-compatible with {@link createRelaySession} — `runAgentJob`
336
+ * consumes the identical `{ relay, attachSteer, close }` contract — but every
337
+ * frame rides the supervisor's connection keyed by explicit `instance`/`jobKey`:
338
+ *
339
+ * - {@link RelaySession.relay} streams a terminal chunk over the connection's
340
+ * transcript lane via the injected `publish(text)` (the plugin wires it to
341
+ * `supervisor.transcript(instance, jobKey, bytes)`), so N agents' transcript
342
+ * streams multiplex over one socket without crossing.
343
+ * - {@link RelaySession.attachSteer} registers a per-instance inbound sink via
344
+ * the injected `subscribeSteer(onChunk) → unsubscribe` (the plugin wires it
345
+ * to `supervisor.steerRouter.register/unregister`), so cockpit → agent steer
346
+ * bytes fan back to exactly this job's PTY.
347
+ *
348
+ * Kept Effect-free and transport-agnostic (the plugin owns the Effect glue), so
349
+ * it is unit-testable with plain fakes. Every wire op is best-effort: a publish
350
+ * or steer failure is logged and swallowed, never crashing the worker — the
351
+ * ownership registry (claim/release), not the transcript, is the source of truth.
352
+ *
353
+ * @param {object} opts
354
+ * @param {string} opts.instance the owning worker instance (explicit on every frame)
355
+ * @param {string|number} opts.jobKey the activated job's key
356
+ * @param {(text: string) => void} opts.publish stream one transcript chunk over the host connection
357
+ * @param {(onChunk: (chunk: string|Uint8Array) => void) => (() => void)} [opts.subscribeSteer]
358
+ * register a steer-in sink for this instance; returns an unsubscribe fn
359
+ * @param {{ warn?: Function, debug?: Function }} [opts.logger]
360
+ * @returns {RelaySession}
361
+ */
362
+ export function createHostRelaySession({ instance, jobKey, publish, subscribeSteer, logger } = {}) {
363
+ if (instance === undefined || instance === null || String(instance) === '') {
364
+ throw new Error('createHostRelaySession requires an instance');
365
+ }
366
+ if (jobKey === undefined || jobKey === null || String(jobKey) === '') {
367
+ throw new Error('createHostRelaySession requires a jobKey');
368
+ }
369
+ if (typeof publish !== 'function') {
370
+ throw new Error('createHostRelaySession requires a publish(text) sink');
371
+ }
372
+ const stream = hostRelayStreamName(instance, jobKey);
373
+ const log = logger || {};
374
+
375
+ const relay = (chunk) => {
376
+ if (chunk == null) return;
377
+ const text = typeof chunk === 'string'
378
+ ? chunk
379
+ : Buffer.isBuffer(chunk)
380
+ ? chunk.toString('utf8')
381
+ : Buffer.from(chunk).toString('utf8');
382
+ if (text === '') return;
383
+ try {
384
+ publish(text);
385
+ } catch (err) {
386
+ try {
387
+ log.warn?.(`host relay publish failed for ${stream}: ${err?.message || err}`);
388
+ } catch {
389
+ /* never let a logging failure escape the relay path */
390
+ }
391
+ }
392
+ };
393
+
394
+ // Each attachSteer call owns its own subscription + detach fn (mirrors
395
+ // createRelaySession), so a second attachSteer can't clobber an earlier one.
396
+ const activeDetaches = new Set();
397
+ const attachSteer = (write) => {
398
+ if (typeof write !== 'function' || typeof subscribeSteer !== 'function') return () => {};
399
+ let unsub;
400
+ try {
401
+ unsub = subscribeSteer((data) => {
402
+ const text = typeof data === 'string' ? data : Buffer.from(data).toString('utf8');
403
+ try {
404
+ write(text);
405
+ } catch (err) {
406
+ try {
407
+ log.warn?.(`host steer-in write failed for ${stream}: ${err?.message || err}`);
408
+ } catch {
409
+ /* swallow */
410
+ }
411
+ }
412
+ });
413
+ } catch (err) {
414
+ try {
415
+ log.warn?.(`host steer-in subscribe failed for ${stream}: ${err?.message || err}`);
416
+ } catch {
417
+ /* swallow */
418
+ }
419
+ return () => {};
420
+ }
421
+ let detached = false;
422
+ const detach = () => {
423
+ if (detached) return;
424
+ detached = true;
425
+ activeDetaches.delete(detach);
426
+ try {
427
+ unsub?.();
428
+ } catch {
429
+ /* swallow */
430
+ }
431
+ };
432
+ activeDetaches.add(detach);
433
+ return detach;
434
+ };
435
+
436
+ // close() is idempotent: a second call returns the same settled promise
437
+ // without re-emitting the close marker or re-detaching. There is no outbound
438
+ // buffer to drain on the host connection (the supervisor owns reconnect +
439
+ // resync), so close resolves as soon as the close marker is emitted and every
440
+ // steer subscription is torn down.
441
+ let closed = false;
442
+ let closedPromise = Promise.resolve({ closeEmitted: false, drained: true, timedOut: false });
443
+ const close = () => {
444
+ if (closed) return closedPromise;
445
+ closed = true;
446
+ // Emit the closing lifecycle twin of RELAY_OPEN_CHUNK so the app can flush the
447
+ // durable transcript deterministically at completion (nano-workforce#710).
448
+ relay(RELAY_CLOSE_CHUNK);
449
+ for (const detach of [...activeDetaches]) detach();
450
+ closedPromise = Promise.resolve({ closeEmitted: true, drained: true, timedOut: false });
451
+ return closedPromise;
452
+ };
453
+
454
+ // Open the stream the instant the session exists (parity with createRelaySession).
455
+ relay(RELAY_OPEN_CHUNK);
456
+
457
+ return { stream, relay, attachSteer, close };
458
+ }