@winkjs/composer 0.5.0 → 0.6.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.
Files changed (104) hide show
  1. package/package.json +8 -8
  2. package/src/core/batch/aoo-encoder.js +4 -4
  3. package/src/core/batch/sliding-window.js +2 -2
  4. package/src/core/codec/index.js +4 -4
  5. package/src/core/emitter-manager/mqtt/emitter.js +44 -14
  6. package/src/core/emitter-manager/mqtt/index.js +2 -1
  7. package/src/core/emitter-manager/terminal/assert-columns.js +1 -1
  8. package/src/core/emitter-manager/terminal/index.js +4 -4
  9. package/src/core/env-vars.js +44 -3
  10. package/src/core/logger/index.js +174 -0
  11. package/src/core/mqtt-protocol.js +2 -2
  12. package/src/core/partition-manager/get-stats.js +31 -0
  13. package/src/core/partition-manager/index.js +1 -0
  14. package/src/core/partition-manager/init.js +15 -0
  15. package/src/core/partition-manager/update.js +143 -87
  16. package/src/core/partition-manager/validate-flow.js +1 -1
  17. package/src/core/semantics/loader.js +1 -1
  18. package/src/core/semantics/warnings.js +5 -3
  19. package/src/core/shutdown-manager/index.js +71 -21
  20. package/src/core/source-manager/csv/start.js +109 -35
  21. package/src/core/source-manager/mqtt/client.js +95 -36
  22. package/src/core/source-manager/mqtt/dedup.js +3 -3
  23. package/src/core/source-manager/mqtt/index.js +1 -1
  24. package/src/core/source-manager/mqtt/status.js +67 -14
  25. package/src/core/source-manager/record-shape.js +55 -0
  26. package/src/core/source-manager/test-harness/field-generator.js +1 -1
  27. package/src/core/source-manager/test-harness/start.js +52 -10
  28. package/src/core/source-manager/test-harness/validate.js +1 -1
  29. package/src/core/storage-manager/questdb/assert-columns.js +2 -2
  30. package/src/core/storage-manager/questdb/ensure-tables.js +1 -1
  31. package/src/core/storage-manager/questdb/index.js +55 -17
  32. package/src/core/storage-manager/questdb/persist-plan.js +32 -8
  33. package/src/core/tunable/helpers.js +1 -1
  34. package/src/core/utils/callback-guard/index.js +155 -0
  35. package/src/core/utils/half-life/index.js +4 -4
  36. package/src/core/utils/validate/composer-validators.js +2 -2
  37. package/src/core/utils/validate/index.js +1 -1
  38. package/src/core/utils/validate/validate.js +1 -1
  39. package/src/core/wiring/assert-handle.js +5 -5
  40. package/src/core/wiring/assert-module.js +2 -2
  41. package/src/core/wiring/wire-emitters.js +4 -3
  42. package/src/core/wiring/wire-linear-graph.js +1 -1
  43. package/src/core/wiring/wire-node.js +6 -6
  44. package/src/core/wiring/wire-semantics.js +1 -1
  45. package/src/core/wiring/wire-storages.js +4 -3
  46. package/src/flow/driver.js +31 -6
  47. package/src/flow/expand-single-input-array.js +4 -4
  48. package/src/flow/flow-api-schemas.js +4 -4
  49. package/src/flow/flow.js +41 -41
  50. package/src/flow/for-each.js +12 -11
  51. package/src/flow/get-target-array.js +2 -2
  52. package/src/flow/load-node-module.js +1 -1
  53. package/src/flow/make-node-method.js +4 -4
  54. package/src/flow/resolve-pick-by-field.js +1 -1
  55. package/src/flow/run.js +176 -38
  56. package/src/flow/serialize.js +1 -1
  57. package/src/flow/spec-builder.js +1 -1
  58. package/src/flow/validate.js +1 -1
  59. package/src/nodes/butterworth-filter/init.js +5 -4
  60. package/src/nodes/categorize/update.js +3 -1
  61. package/src/nodes/controller/update.js +2 -1
  62. package/src/nodes/dwell-time-tracker/update.js +2 -1
  63. package/src/nodes/emit-if/helpers.js +57 -11
  64. package/src/nodes/emit-if/init.js +1 -1
  65. package/src/nodes/emit-if/update.js +8 -11
  66. package/src/nodes/page-hinkley/update.js +2 -1
  67. package/src/nodes/pass-if/update.js +3 -1
  68. package/src/nodes/persist-if/helpers.js +60 -4
  69. package/src/nodes/persist-if/init.js +1 -1
  70. package/src/nodes/persist-if/update.js +8 -16
  71. package/src/nodes/persistence-check/update.js +2 -1
  72. package/src/nodes/sanitize/update.js +4 -2
  73. package/src/nodes/swing-watch/update.js +3 -2
  74. package/src/nodes/threshold/update.js +2 -1
  75. package/src/nodes/trend/compute-confidence.js +4 -4
  76. package/src/nodes/trend/update.js +2 -1
  77. package/src/nodes/winnow/update.js +3 -1
  78. package/src/tools/training/features.js +13 -13
  79. package/src/tools/training/logistic.js +1 -1
  80. package/src/tools/training/metrics.js +2 -2
  81. package/src/tools/training/scale.js +2 -2
  82. package/src/tools/training/validate.js +7 -7
  83. package/src/core/emitter-manager/mqtt/mqtt-store.js +0 -538
  84. package/src/core/utils/console-formatter/index.js +0 -207
  85. package/src/nodes/archive/csv/index.js +0 -6
  86. package/src/nodes/archive/csv/init.js +0 -36
  87. package/src/nodes/archive/csv/load.js +0 -70
  88. package/src/nodes/archive/csv/reset.js +0 -14
  89. package/src/nodes/archive/csv/simple-parser.js +0 -216
  90. package/src/nodes/archive/csv/test.js +0 -173
  91. package/src/nodes/archive/csv/update.js +0 -63
  92. package/src/nodes/sse-emitter/index.js +0 -7
  93. package/src/nodes/sse-emitter/init.js +0 -105
  94. package/src/nodes/sse-emitter/introspect.js +0 -134
  95. package/src/nodes/sse-emitter/publish-to.js +0 -6
  96. package/src/nodes/sse-emitter/recompute.js +0 -16
  97. package/src/nodes/sse-emitter/reset.js +0 -19
  98. package/src/nodes/sse-emitter/update.js +0 -69
  99. package/src/nodes/sse-emitter/utils/broadcast-heartbeat.js +0 -36
  100. package/src/nodes/sse-emitter/utils/build-message.js +0 -31
  101. package/src/nodes/sse-emitter/utils/connection-manager.js +0 -45
  102. package/src/nodes/sse-emitter/utils/endpoint-initializer.js +0 -20
  103. package/src/nodes/sse-emitter/utils/health-monitor.js +0 -38
  104. package/src/nodes/sse-emitter/utils/qos-handler.js +0 -41
package/src/flow/run.js CHANGED
@@ -17,6 +17,8 @@ import * as partitionManager from '../core/partition-manager/index.js';
17
17
  import { wireLinearGraph, emitters, storages, assertModuleDurability } from '../core/wiring/index.js';
18
18
  import shutdownManager from '../core/shutdown-manager/index.js';
19
19
  import { ENV_VARS } from '../core/env-vars.js';
20
+ import { wrapCallback } from '../core/utils/callback-guard/index.js';
21
+ import { logger } from '../core/logger/index.js';
20
22
 
21
23
  /**
22
24
  * Wires and starts the pipeline for direct execution.
@@ -27,7 +29,8 @@ import { ENV_VARS } from '../core/env-vars.js';
27
29
  * @param {Set} importSet - Set of node names used
28
30
  * @param {Object} runtime - Runtime configuration
29
31
  * @param {Array} [caseOrder] - Order of case keys (only for multi-specialization)
30
- * @returns {Promise<Object>} Pipeline handle with shutdown method
32
+ * @returns {Promise<Object>} Pipeline handle: processMessage, shutdown,
33
+ * whenComplete, getStats, flowName, composerState
31
34
  */
32
35
  export const runFlow = async function ( flowName, specsOrSpecsByCase, importSet, runtime, caseOrder ) {
33
36
  // Determine if this is multi-specialization mode
@@ -156,10 +159,16 @@ export const runFlow = async function ( flowName, specsOrSpecsByCase, importSet,
156
159
  // caller that ignores the Promise (a push source such as the MQTT
157
160
  // subscriber) can never see messages update a partition out of order.
158
161
  //
159
- // The caller contract (ADR-013) is unchanged: `undefined` on the hot
160
- // path, a Promise on the yield tick; `await` works with both, and the
161
- // Promise never rejects — a pipeline fault throws synchronously here,
162
- // the same as on every other message.
162
+ // The caller contract (ADR-013) is unchanged in shape: `undefined` on
163
+ // the hot path, a Promise on the yield tick; `await` works with both,
164
+ // and the Promise never rejects. Fault delivery depends on the flow
165
+ // kind (ADR-018 — the runtime owns per-message dispatch failure). With
166
+ // a source present, a pipeline fault is contained here: the message is
167
+ // skipped, one red MESSAGE_HANDLER_FAILED travels the status channel,
168
+ // and a run of consecutive failures stops the flow in the terminal
169
+ // 'errored' phase. A headless flow (no source) has no status channel,
170
+ // so the fault throws synchronously to the caller — the headless
171
+ // driver's catch and `failed` counter are that path's containment.
163
172
  //
164
173
  // The default threshold comes from YIELD_TIME_THRESHOLD_MS (500 ms);
165
174
  // `.yield( { threshold } )` overrides it per flow, and Infinity turns
@@ -187,26 +196,10 @@ export const runFlow = async function ( flowName, specsOrSpecsByCase, importSet,
187
196
 
188
197
  const partitionState = composerState.partitionState;
189
198
 
190
- const processMessage = function ( msg ) {
191
- const graph = partitionManager.update( composerState, msg );
192
-
193
- // Process first — pmUpdate always returns the graph synchronously
194
- // (or null for a dropped message, which never sets the flag).
195
- if ( graph ) {
196
- runPipeline( graph, msg );
197
- }
198
-
199
- // Breathe after (yield tick, rare): hand the awaiting caller a
200
- // Promise that resolves once the event loop has had a full turn.
201
- if ( partitionState.yieldPending ) {
202
- partitionState.yieldPending = false;
203
- return new Promise( ( resolve ) => {
204
- setImmediate( resolve );
205
- } );
206
- }
207
-
208
- return undefined;
209
- };
199
+ // The dispatch function itself is defined AFTER the shutdown
200
+ // closure below — its escalation path drains the flow, so it
201
+ // needs `shutdown` in scope. No message can arrive before then:
202
+ // the source only receives processMessage at start, further down.
210
203
 
211
204
  // 6. Setup the flow's shutdown closure.
212
205
  //
@@ -272,8 +265,8 @@ export const runFlow = async function ( flowName, specsOrSpecsByCase, importSet,
272
265
  await stage();
273
266
  } catch ( err ) {
274
267
  stageErrors.push( err );
275
- console.error(
276
- `WinkComposer/flow: ${label} drain stage failed [${( err && err.code ) || 'UNKNOWN'}]: ` +
268
+ logger.error(
269
+ `winkComposer/flow: ${label} drain stage failed [${( err && err.code ) || 'UNKNOWN'}]: ` +
277
270
  `${( err && err.message ) || String( err )} — later drain stages still ran`
278
271
  );
279
272
  }
@@ -311,6 +304,93 @@ export const runFlow = async function ( flowName, specsOrSpecsByCase, importSet,
311
304
  return shutdownPromise;
312
305
  };
313
306
 
307
+ // Run the drain with its rejection observed. Both fire-and-forget
308
+ // call sites use this: the natural-completion trigger and the
309
+ // consecutive-failure escalation. drainAll rethrows the first
310
+ // stage error; with no awaiting caller that rejection would end
311
+ // the process as unhandled. The classified log keeps the loss
312
+ // loud without the crash, and a caller that later awaits
313
+ // `handle.shutdown()` still receives the memoized rejection.
314
+ const observedShutdown = function () {
315
+ shutdown().catch( function ( err ) {
316
+ logger.error(
317
+ `winkComposer/flow '${flowName}': shutdown failed ` +
318
+ `[${( err && err.code ) || 'UNKNOWN'}]: ${( err && err.message ) || String( err )}`
319
+ );
320
+ } );
321
+ };
322
+
323
+ // ── Message dispatch with fault containment (ADR-018: the flow
324
+ // runtime owns per-message dispatch failure at this chokepoint) ──
325
+ // All containment state is flow-scoped policy, kept in this
326
+ // closure beside the shutdown state — never on the handle-exposed
327
+ // composerState. `reportDispatchFailure` stays null for a headless
328
+ // flow (no source); the source block below assigns it before the
329
+ // source ever receives processMessage.
330
+ let consecutiveFailures = 0;
331
+ let errored = false;
332
+ let erroredDropReported = false;
333
+ let reportDispatchFailure = null;
334
+
335
+ const processMessage = function ( msg ) {
336
+ // Terminal 'errored': drop everything. The first drop is
337
+ // reported once, classified; a flood of per-drop reports
338
+ // would bury the one terminal red that matters.
339
+ if ( errored ) {
340
+ if ( !erroredDropReported ) {
341
+ erroredDropReported = true;
342
+ logger.error(
343
+ `winkComposer/flow '${flowName}': flow is in the terminal 'errored' ` +
344
+ 'phase [MESSAGE_HANDLER_FAILED] — dropping this and all further messages'
345
+ );
346
+ }
347
+ return undefined;
348
+ }
349
+
350
+ // Process first — pmUpdate always returns the graph synchronously
351
+ // (or null for a dropped message, which never sets the flag).
352
+ // The guard covers pmUpdate too: a first message for a new asset
353
+ // runs every node's init lazily, so an init throw surfaces here.
354
+ try {
355
+ const graph = partitionManager.update( composerState, msg );
356
+ if ( graph ) {
357
+ runPipeline( graph, msg );
358
+ // Reset on success only — a dropped message is neither
359
+ // a success nor a failure. Branch-guarded so the
360
+ // healthy steady state never writes.
361
+ if ( consecutiveFailures !== 0 ) {
362
+ consecutiveFailures = 0;
363
+ }
364
+ }
365
+ } catch ( err ) {
366
+ if ( reportDispatchFailure === null ) {
367
+ // Headless: the caller is the reporting channel — the
368
+ // driver's catch and `failed` counter own the fault.
369
+ throw err;
370
+ }
371
+ consecutiveFailures += 1;
372
+ const isTerminal = consecutiveFailures >= ENV_VARS.messageFailureThreshold;
373
+ reportDispatchFailure( err, isTerminal );
374
+ if ( isTerminal ) {
375
+ errored = true;
376
+ observedShutdown();
377
+ }
378
+ }
379
+
380
+ // Breathe after (yield tick, rare): hand the awaiting caller a
381
+ // Promise that resolves once the event loop has had a full turn.
382
+ // Runs on the catch path too — pmUpdate may have set the flag
383
+ // before the pipeline threw, and the breath must not be lost.
384
+ if ( partitionState.yieldPending ) {
385
+ partitionState.yieldPending = false;
386
+ return new Promise( ( resolve ) => {
387
+ setImmediate( resolve );
388
+ } );
389
+ }
390
+
391
+ return undefined;
392
+ };
393
+
314
394
  // 7. Start source (singleton).
315
395
  if ( runtime.source ) {
316
396
  const { adapter, config } = runtime.source;
@@ -321,17 +401,42 @@ export const runFlow = async function ( flowName, specsOrSpecsByCase, importSet,
321
401
  // too (they bypass the DSL entirely).
322
402
  assertModuleDurability( adapter.id || 'source', adapter );
323
403
 
404
+ // The callback guard below turns a non-function into null
405
+ // (absent). Validate raw first, so a misconfigured handler
406
+ // fails loudly at setup instead of silently vanishing. Placed
407
+ // here for the same reason as the durability check above:
408
+ // direct runFlow() callers bypass the DSL schema.
409
+ if ( config.onStatus !== undefined && config.onStatus !== null && typeof config.onStatus !== 'function' ) {
410
+ const err = new Error( `winkComposer/flow '${flowName}': onStatus must be a function` );
411
+ err.code = 'INVALID_CONFIG';
412
+ throw err;
413
+ }
414
+
324
415
  // Wrap user's onStatus so the runtime can spot the natural-
325
416
  // completion signal without taking it away from the user.
326
417
  // When the source signals `phase: 'complete'`:
327
418
  // 1. Resolve the whenComplete Promise so callers can stop
328
419
  // waiting for natural completion.
329
- // 2. Trigger the drain (`shutdown()` is fire-and-forget —
330
- // it's concurrent-safe and stages have their own timeouts).
331
- const userOnStatus = config.onStatus;
420
+ // 2. Trigger the drain via observedShutdown — fire-and-forget
421
+ // with its rejection logged; shutdown is concurrent-safe
422
+ // and stages have their own timeouts.
423
+ // The user's handler is armed by the shared callback guard
424
+ // (ADR-018: a misbehaving user callback never interrupts the
425
+ // runtime that called it). Null when the flow has no handler,
426
+ // so the classified fallback branch below keeps red statuses
427
+ // loud.
428
+ const safeUserOnStatus = wrapCallback( config.onStatus, {
429
+ name: 'onStatus',
430
+ severity: 'red',
431
+ report: function ( severity, name, detail ) {
432
+ logger.error(
433
+ `winkComposer/flow '${flowName}': user callback ${name} failed [CALLBACK_FAILED]: ${detail}`
434
+ );
435
+ }
436
+ } );
332
437
  const wrappedOnStatus = function ( s ) {
333
- if ( userOnStatus ) {
334
- userOnStatus( s );
438
+ if ( safeUserOnStatus ) {
439
+ safeUserOnStatus( s );
335
440
  } else if ( s && s.status === 'red' ) {
336
441
  // No user handler: log the failure here, classified. The
337
442
  // source's own console fallback fires only when NO handler
@@ -341,13 +446,39 @@ export const runFlow = async function ( flowName, specsOrSpecsByCase, importSet,
341
446
  const code = ( s.error && s.error.code ) || 'UNKNOWN';
342
447
  const message = ( s.error && s.error.message ) ||
343
448
  'source reported status red with no error detail';
344
- console.error( `WinkComposer/flow '${flowName}': source error [${code}]: ${message}` );
449
+ logger.error( `winkComposer/flow '${flowName}': source error [${code}]: ${message}` );
345
450
  }
451
+ // Completion bookkeeping runs after — and independently of —
452
+ // the user's reporter. A throwing onStatus must never swallow
453
+ // the flow's own completion: whenComplete waiters would hang
454
+ // and the source would never stop.
346
455
  if ( s && s.phase === 'complete' ) {
347
456
  resolveWhenComplete();
348
- shutdown();
457
+ observedShutdown();
349
458
  }
350
459
  };
460
+
461
+ // The dispatch guard's reporting channel. Routing through
462
+ // wrappedOnStatus keeps the two-party rule: a flow without a
463
+ // user handler still gets the classified console fallback for
464
+ // red statuses. No local guard is needed here: the callback
465
+ // guard above contains a throwing user handler, and every other
466
+ // branch of wrappedOnStatus is throw-free.
467
+ reportDispatchFailure = function ( err, isTerminal ) {
468
+ const payload = {
469
+ status: 'red',
470
+ connected: true,
471
+ phase: isTerminal ? 'errored' : 'running',
472
+ error: {
473
+ code: 'MESSAGE_HANDLER_FAILED',
474
+ message: isTerminal ?
475
+ `${ENV_VARS.messageFailureThreshold} consecutive message failures — ` +
476
+ `flow stopped. Last: ${err.message}` :
477
+ `${err.message} — message skipped`
478
+ }
479
+ };
480
+ wrappedOnStatus( payload );
481
+ };
351
482
  stopSource = adapter.start( {
352
483
  ...config,
353
484
  // Override any source-level auto-shutdown setting. The
@@ -362,10 +493,12 @@ export const runFlow = async function ( flowName, specsOrSpecsByCase, importSet,
362
493
  onShutdown: shutdown
363
494
  } );
364
495
  } else {
365
-
366
- /* c8 ignore next 3 -- defensive: the flow API rejects empty flows before run() is called, so a flow without a source cannot reach this branch via the public API. Kept as a safety net for direct runFlow() callers. */
367
- // No source means "nothing to wait for" — resolve immediately
368
- // so any `await handle.whenComplete()` is a no-op.
496
+ // The headless branch: a flow with no source is a documented
497
+ // DSL path (the headless driver feeds it). No source means
498
+ // "nothing to wait for" — resolve immediately so any
499
+ // `await handle.whenComplete()` is a no-op. And with no
500
+ // status channel, `reportDispatchFailure` stays null, so the
501
+ // dispatch guard rethrows pipeline faults to the caller.
369
502
  resolveWhenComplete();
370
503
  }
371
504
 
@@ -384,6 +517,11 @@ export const runFlow = async function ( flowName, specsOrSpecsByCase, importSet,
384
517
  composerState,
385
518
  shutdown,
386
519
  processMessage,
520
+ // Cold-path counter snapshot (drops, partitions). Allocates one
521
+ // small object per call; never called by the pipeline itself.
522
+ getStats: function () {
523
+ return partitionManager.getStats( composerState );
524
+ },
387
525
  whenComplete: function () {
388
526
  return whenCompletePromise;
389
527
  }
@@ -59,7 +59,7 @@ const serializeValue = function ( value, indent ) {
59
59
 
60
60
  if ( t === 'function' ) {
61
61
  const funcStr = value.toString();
62
- if ( funcStr.includes( '[native code]' ) ) throw Error( 'WinkComposer/flow: only regular or arrow functions are supported.' );
62
+ if ( funcStr.includes( '[native code]' ) ) throw Error( 'winkComposer/flow: only regular or arrow functions are supported.' );
63
63
  return funcStr;
64
64
  }
65
65
 
@@ -11,7 +11,7 @@ const buildStatsFromOutputs = function ( outputs ) {
11
11
  // overwrite case, caught at build time by check-output-collisions.js.
12
12
  if ( seenStoreAs[ storeAs ] ) {
13
13
  throw new Error(
14
- `WinkComposer/flow: Node has duplicate storeAs '${storeAs}' ` +
14
+ `winkComposer/flow: Node has duplicate storeAs '${storeAs}' ` +
15
15
  `for stats '${seenStoreAs[ storeAs ]}' and '${stat}'`
16
16
  );
17
17
  }
@@ -254,7 +254,7 @@ export const validateFlowOrThrow = function ( flowName, specs, nodeModules, regi
254
254
  if ( !result.valid ) {
255
255
  const errorList = result.errors.map( ( e ) => ` - ${e}` ).join( '\n' );
256
256
  throw new Error(
257
- `Flow '${flowName}' validation failed:\n${errorList}`
257
+ `winkComposer/flow '${flowName}': validation failed:\n${errorList}`
258
258
  );
259
259
  }
260
260
  };
@@ -10,6 +10,7 @@
10
10
  import { validateSpec } from '../../core/utils/node/index.js';
11
11
  import * as introspect from './introspect.js';
12
12
  import { resolveScalar } from '../../core/utils/options/resolve-field-keyed.js';
13
+ import { logger } from '../../core/logger/index.js';
13
14
 
14
15
  const init = function ( spec ) {
15
16
  validateSpec( spec, introspect );
@@ -74,14 +75,14 @@ const init = function ( spec ) {
74
75
  // Validate the computed cutoff
75
76
  if ( cutoffHz <= 0 || cutoffHz >= nyquistHz ) {
76
77
  throw new RangeError(
77
- `Computed cutoff ${cutoffHz.toFixed(1)}Hz is outside valid range (0, ${nyquistHz}Hz)`
78
+ `winkComposer/butterworthFilter: Computed cutoff ${cutoffHz.toFixed(1)}Hz is outside valid range (0, ${nyquistHz}Hz)`
78
79
  );
79
80
  }
80
81
 
81
82
  // Warn about potential numerical issues
82
83
  const normalizedCutoff = cutoffHz / nyquistHz;
83
84
  if ( normalizedCutoff < 0.001 && !state.acceptNumericalRisk ) {
84
- console.warn( `Very low cutoff (${cutoffHz.toFixed(3)}Hz) may cause numerical instability` );
85
+ logger.warn( `winkComposer/butterworthFilter: Very low cutoff (${cutoffHz.toFixed(3)}Hz) may cause numerical instability` );
85
86
  }
86
87
 
87
88
  // Store configuration for reference
@@ -127,14 +128,14 @@ const init = function ( spec ) {
127
128
  /* c8 ignore next 5 -- defensive: bilinear transform produces stable coefficients for valid Nyquist-bounded cutoff */
128
129
  if ( Math.abs( state.a2 ) >= 1 ) {
129
130
  throw new Error(
130
- `Filter unstable: coefficient a2 (${state.a2.toFixed(4)}) is outside stability bounds`
131
+ `winkComposer/butterworthFilter: Filter unstable: coefficient a2 (${state.a2.toFixed(4)}) is outside stability bounds`
131
132
  );
132
133
  }
133
134
 
134
135
  /* c8 ignore next 5 -- defensive: same Nyquist guard prevents a1 stability violation */
135
136
  if ( Math.abs( state.a1 ) >= 1 + state.a2 ) {
136
137
  throw new Error(
137
- `Filter unstable: coefficient a1 (${state.a1.toFixed(4)}) violates stability condition |a1| < 1 + a2`
138
+ `winkComposer/butterworthFilter: Filter unstable: coefficient a1 (${state.a1.toFixed(4)}) violates stability condition |a1| < 1 + a2`
138
139
  );
139
140
  }
140
141
 
@@ -6,6 +6,8 @@
6
6
  * threshold counts (3–5), linear search outperforms binary search.
7
7
  */
8
8
 
9
+ import { logger } from '../../core/logger/index.js';
10
+
9
11
  const update = function ( state, msg ) {
10
12
  if ( state.disable || state.pause ) return state;
11
13
  const xVal = msg[ state.x ];
@@ -25,7 +27,7 @@ const update = function ( state, msg ) {
25
27
  } catch ( error ) {
26
28
  if ( !state.tunableErrorLogged ) {
27
29
  state.tunableErrorLogged = true;
28
- console.error( `WinkComposer/${state.nodeType}: tunable threw: ${error.message}` );
30
+ logger.error( `winkComposer/${state.nodeType}: tunable threw: ${error.message}` );
29
31
  }
30
32
  }
31
33
 
@@ -1,6 +1,7 @@
1
1
  // nodes/controller/update.js
2
2
 
3
3
  import { executeTriggers } from '../../core/utils/node/index.js';
4
+ import { logger } from '../../core/logger/index.js';
4
5
 
5
6
  const update = function ( state, msg ) {
6
7
  // Track whether any predicate threw during this pass
@@ -22,7 +23,7 @@ const update = function ( state, msg ) {
22
23
  // Log first error per episode; suppress subsequent until recovery
23
24
  if ( !state.predicateErrorLogged ) {
24
25
  state.predicateErrorLogged = true;
25
- console.error( `WinkComposer/controller: predicate threw exception: ${error.message}` );
26
+ logger.error( `winkComposer/controller: predicate threw exception: ${error.message}` );
26
27
  }
27
28
  // Skip to next condition
28
29
  continue; // eslint-disable-line no-continue
@@ -1,6 +1,7 @@
1
1
  // nodes/dwell-time-tracker/update.js
2
2
 
3
3
  import { executeTriggers } from '../../core/utils/node/index.js';
4
+ import { logger } from '../../core/logger/index.js';
4
5
 
5
6
  const update = function ( state, msg ) {
6
7
  if ( state.disable || state.pause ) return state;
@@ -20,7 +21,7 @@ const update = function ( state, msg ) {
20
21
  // Log first error per episode; suppress subsequent until recovery
21
22
  if ( !state.predicateErrorLogged ) {
22
23
  state.predicateErrorLogged = true;
23
- console.error( `WinkComposer/dwellTimeTracker: predicate threw exception: ${error.message}` );
24
+ logger.error( `winkComposer/dwellTimeTracker: predicate threw exception: ${error.message}` );
24
25
  }
25
26
  return state;
26
27
  }
@@ -6,6 +6,8 @@
6
6
  * and the status-signal path below.
7
7
  */
8
8
 
9
+ import { logger } from '../../core/logger/index.js';
10
+
9
11
  /**
10
12
  * Static fallback error the framework substitutes when an adapter breaks
11
13
  * the return contract — no result object at all, or `{ ok: false }` with
@@ -54,10 +56,52 @@ const recordEmissionFailure = function ( state, error ) {
54
56
  state.emitErrorLogged = true;
55
57
  state.firstEmissionError = error.message;
56
58
  state.firstEmissionErrorCode = error.code;
57
- console.error( state.emitErrorLogPrefix + error.code + '): ' + error.message );
59
+ logger.error( state.emitErrorLogPrefix + error.code + '): ' + error.message );
58
60
  }
59
61
  }; // recordEmissionFailure()
60
62
 
63
+ /**
64
+ * Substituted error for an emitter that THREW from publishNow — the
65
+ * other face of a broken return contract (ADR-018: the hot path never
66
+ * throws; a sink answers { ok }). Same code as the malformed-result
67
+ * face: the remediation is identical (fix the non-conforming adapter),
68
+ * and the message names the difference. Allocates on the failure path
69
+ * only.
70
+ *
71
+ * @param {Error} error - The thrown value
72
+ * @returns {Object} `{ code, message }` for the failure episode
73
+ */
74
+ const thrownSinkError = function ( error ) {
75
+ return {
76
+ code: 'MALFORMED_RESULT',
77
+ message: `emitter publishNow threw instead of returning { ok, error? }: ${error.message}`
78
+ };
79
+ }; // thrownSinkError()
80
+
81
+ /**
82
+ * Delivers one payload to the wired emitter with the sink call fully
83
+ * guarded. A conforming emitter never throws; a throwing one is a
84
+ * broken adapter, recorded in the emission episode — never blamed on
85
+ * the user's predicate, and never escaped into the pipeline where it
86
+ * would cost the whole message. Returns true only on `{ ok: true }`.
87
+ *
88
+ * @param {Object} state - Node state containing emitter and topic
89
+ * @param {Object} data - Payload to publish
90
+ * @returns {boolean} True when the emitter accepted the payload
91
+ */
92
+ const deliverToEmitter = function ( state, data ) {
93
+ try {
94
+ const result = state.emitter.publishNow( state.topic, data );
95
+ if ( result && result.ok ) {
96
+ return true;
97
+ }
98
+ recordEmissionFailure( state, ( result && result.error ) || MALFORMED_RESULT_ERROR );
99
+ } catch ( sinkError ) {
100
+ recordEmissionFailure( state, thrownSinkError( sinkError ) );
101
+ }
102
+ return false;
103
+ }; // deliverToEmitter()
104
+
61
105
  /**
62
106
  * Emits disable/enable status signal to the wired emitter. Published
63
107
  * unconditionally (no connectivity pre-check per ADR-018 — during a
@@ -80,15 +124,17 @@ const emitStatusSignal = function ( state, disable, reason ) {
80
124
  $timestamp: Date.now()
81
125
  };
82
126
 
83
- // Guarded read: this path runs inside update()'s catch block, so a
84
- // TypeError here would escape update() and kill the pipeline. A
85
- // malformed result counts as a failure with the static fallback
86
- // error instead.
87
- const result = state.emitter.publishNow( state.topic, signal );
88
- if ( result && result.ok ) {
89
- return;
90
- }
91
- recordEmissionFailure( state, ( result && result.error ) || MALFORMED_RESULT_ERROR );
127
+ // Fully guarded delivery: this path can run inside update()'s
128
+ // catch block, so a throwing emitter here would otherwise escape
129
+ // update() and kill the pipeline. deliverToEmitter contains both
130
+ // faces of a broken adapter — a throw and a malformed result.
131
+ deliverToEmitter( state, signal );
92
132
  }; // emitStatusSignal()
93
133
 
94
- export { assertAnnotateReturn, emitStatusSignal, recordEmissionFailure, MALFORMED_RESULT_ERROR };
134
+ export {
135
+ assertAnnotateReturn,
136
+ emitStatusSignal,
137
+ recordEmissionFailure,
138
+ deliverToEmitter,
139
+ MALFORMED_RESULT_ERROR
140
+ };
@@ -59,7 +59,7 @@ const init = function ( spec ) {
59
59
  state.firstEmissionErrorCode = null;
60
60
  // Static log prefix pre-built here per ADR-004 (no string building in
61
61
  // update beyond the two runtime fields, and only once per episode).
62
- state.emitErrorLogPrefix = `WinkComposer/emitIf: publish failed (node=${state.name}, insightType=${state.insightType}, code=`;
62
+ state.emitErrorLogPrefix = `winkComposer/emitIf: publish failed (node=${state.name}, insightType=${state.insightType}, code=`;
63
63
 
64
64
  return state;
65
65
  }; // init()
@@ -14,7 +14,8 @@
14
14
  * the two output gates share one failure model).
15
15
  */
16
16
 
17
- import { assertAnnotateReturn, emitStatusSignal, recordEmissionFailure, MALFORMED_RESULT_ERROR } from './helpers.js';
17
+ import { assertAnnotateReturn, emitStatusSignal, deliverToEmitter } from './helpers.js';
18
+ import { logger } from '../../core/logger/index.js';
18
19
 
19
20
  const update = function ( state, msg ) {
20
21
  // Always increment pass count for observability
@@ -44,19 +45,15 @@ const update = function ( state, msg ) {
44
45
  // Per the ADR-018 sink return contract, the emitter answers
45
46
  // { ok: true } on success or
46
47
  // { ok: false, error: { code, message } } on failure.
47
- const result = state.emitter.publishNow( state.topic, data );
48
-
49
- if ( result && result.ok ) {
48
+ // deliverToEmitter guards the call itself too: a THROWING
49
+ // emitter is the other face of a broken contract, recorded
50
+ // in the same adapter episode — a non-conforming adapter
51
+ // can neither fail silently nor throw out of the hot path.
52
+ if ( deliverToEmitter( state, data ) ) {
50
53
  state.emissionCount += 1;
51
54
  state.lastEmissionTime = Date.now();
52
55
  // Recovery: a successful publish closes the failure episode.
53
56
  if ( state.emitErrorLogged ) state.emitErrorLogged = false;
54
- } else {
55
- // A malformed result (no object at all, or ok:false with no
56
- // error) counts as a failure with the static fallback error,
57
- // so a non-conforming adapter can neither fail silently
58
- // nor throw out of the hot path.
59
- recordEmissionFailure( state, ( result && result.error ) || MALFORMED_RESULT_ERROR );
60
57
  }
61
58
  }
62
59
 
@@ -76,7 +73,7 @@ const update = function ( state, msg ) {
76
73
  // Log first error per episode; suppress subsequent until recovery
77
74
  if ( !state.predicateErrorLogged ) {
78
75
  state.predicateErrorLogged = true;
79
- console.error( `WinkComposer/emitIf: predicate threw exception: ${error.message}` );
76
+ logger.error( `winkComposer/emitIf: predicate threw exception: ${error.message}` );
80
77
  }
81
78
  state.emissionErrors += 1;
82
79
  state.lastEmissionError = error.message;
@@ -39,6 +39,7 @@
39
39
  */
40
40
 
41
41
  import { executeTriggers } from '../../core/utils/node/index.js';
42
+ import { logger } from '../../core/logger/index.js';
42
43
 
43
44
  const update = function ( state, msg ) {
44
45
  if ( state.disable || state.pause ) return state;
@@ -64,7 +65,7 @@ const update = function ( state, msg ) {
64
65
  } catch ( error ) {
65
66
  if ( !state.tunableErrorLogged ) {
66
67
  state.tunableErrorLogged = true;
67
- console.error( `WinkComposer/${state.nodeType}: tunable threw: ${error.message}` );
68
+ logger.error( `winkComposer/${state.nodeType}: tunable threw: ${error.message}` );
68
69
  }
69
70
  }
70
71
 
@@ -8,6 +8,8 @@
8
8
  * is false. Predicate errors are caught and tracked per-episode.
9
9
  */
10
10
 
11
+ import { logger } from '../../core/logger/index.js';
12
+
11
13
  const update = function ( state, msg ) {
12
14
  if ( state.disable || state.pause ) return state;
13
15
 
@@ -26,7 +28,7 @@ const update = function ( state, msg ) {
26
28
  // Log first error per episode; suppress subsequent until recovery
27
29
  if ( !state.predicateErrorLogged ) {
28
30
  state.predicateErrorLogged = true;
29
- console.error( `WinkComposer/passIf: predicate threw exception: ${error.message}` );
31
+ logger.error( `winkComposer/passIf: predicate threw exception: ${error.message}` );
30
32
  }
31
33
  }
32
34