@winkjs/composer 0.5.1 → 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 (102) hide show
  1. package/package.json +2 -2
  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/terminal/assert-columns.js +1 -1
  7. package/src/core/emitter-manager/terminal/index.js +4 -4
  8. package/src/core/env-vars.js +44 -3
  9. package/src/core/logger/index.js +174 -0
  10. package/src/core/mqtt-protocol.js +2 -2
  11. package/src/core/partition-manager/get-stats.js +31 -0
  12. package/src/core/partition-manager/index.js +1 -0
  13. package/src/core/partition-manager/init.js +15 -0
  14. package/src/core/partition-manager/update.js +143 -87
  15. package/src/core/partition-manager/validate-flow.js +1 -1
  16. package/src/core/semantics/loader.js +1 -1
  17. package/src/core/semantics/warnings.js +5 -3
  18. package/src/core/shutdown-manager/index.js +71 -21
  19. package/src/core/source-manager/csv/start.js +109 -35
  20. package/src/core/source-manager/mqtt/client.js +95 -36
  21. package/src/core/source-manager/mqtt/dedup.js +3 -3
  22. package/src/core/source-manager/mqtt/index.js +1 -1
  23. package/src/core/source-manager/mqtt/status.js +67 -14
  24. package/src/core/source-manager/record-shape.js +55 -0
  25. package/src/core/source-manager/test-harness/field-generator.js +1 -1
  26. package/src/core/source-manager/test-harness/start.js +52 -10
  27. package/src/core/source-manager/test-harness/validate.js +1 -1
  28. package/src/core/storage-manager/questdb/assert-columns.js +2 -2
  29. package/src/core/storage-manager/questdb/ensure-tables.js +1 -1
  30. package/src/core/storage-manager/questdb/index.js +55 -17
  31. package/src/core/storage-manager/questdb/persist-plan.js +32 -8
  32. package/src/core/tunable/helpers.js +1 -1
  33. package/src/core/utils/callback-guard/index.js +155 -0
  34. package/src/core/utils/half-life/index.js +4 -4
  35. package/src/core/utils/validate/composer-validators.js +2 -2
  36. package/src/core/utils/validate/index.js +1 -1
  37. package/src/core/utils/validate/validate.js +1 -1
  38. package/src/core/wiring/assert-handle.js +5 -5
  39. package/src/core/wiring/assert-module.js +2 -2
  40. package/src/core/wiring/wire-emitters.js +4 -3
  41. package/src/core/wiring/wire-linear-graph.js +1 -1
  42. package/src/core/wiring/wire-node.js +6 -6
  43. package/src/core/wiring/wire-semantics.js +1 -1
  44. package/src/core/wiring/wire-storages.js +4 -3
  45. package/src/flow/driver.js +31 -6
  46. package/src/flow/expand-single-input-array.js +4 -4
  47. package/src/flow/flow-api-schemas.js +4 -4
  48. package/src/flow/flow.js +41 -41
  49. package/src/flow/for-each.js +12 -11
  50. package/src/flow/get-target-array.js +2 -2
  51. package/src/flow/load-node-module.js +1 -1
  52. package/src/flow/make-node-method.js +4 -4
  53. package/src/flow/resolve-pick-by-field.js +1 -1
  54. package/src/flow/run.js +176 -38
  55. package/src/flow/serialize.js +1 -1
  56. package/src/flow/spec-builder.js +1 -1
  57. package/src/flow/validate.js +1 -1
  58. package/src/nodes/butterworth-filter/init.js +5 -4
  59. package/src/nodes/categorize/update.js +3 -1
  60. package/src/nodes/controller/update.js +2 -1
  61. package/src/nodes/dwell-time-tracker/update.js +2 -1
  62. package/src/nodes/emit-if/helpers.js +57 -11
  63. package/src/nodes/emit-if/init.js +1 -1
  64. package/src/nodes/emit-if/update.js +8 -11
  65. package/src/nodes/page-hinkley/update.js +2 -1
  66. package/src/nodes/pass-if/update.js +3 -1
  67. package/src/nodes/persist-if/helpers.js +60 -4
  68. package/src/nodes/persist-if/init.js +1 -1
  69. package/src/nodes/persist-if/update.js +8 -16
  70. package/src/nodes/persistence-check/update.js +2 -1
  71. package/src/nodes/sanitize/update.js +4 -2
  72. package/src/nodes/swing-watch/update.js +3 -2
  73. package/src/nodes/threshold/update.js +2 -1
  74. package/src/nodes/trend/compute-confidence.js +4 -4
  75. package/src/nodes/trend/update.js +2 -1
  76. package/src/nodes/winnow/update.js +3 -1
  77. package/src/tools/training/features.js +13 -13
  78. package/src/tools/training/logistic.js +1 -1
  79. package/src/tools/training/metrics.js +2 -2
  80. package/src/tools/training/scale.js +2 -2
  81. package/src/tools/training/validate.js +7 -7
  82. package/src/core/utils/console-formatter/index.js +0 -207
  83. package/src/nodes/archive/csv/index.js +0 -6
  84. package/src/nodes/archive/csv/init.js +0 -36
  85. package/src/nodes/archive/csv/load.js +0 -70
  86. package/src/nodes/archive/csv/reset.js +0 -14
  87. package/src/nodes/archive/csv/simple-parser.js +0 -216
  88. package/src/nodes/archive/csv/test.js +0 -173
  89. package/src/nodes/archive/csv/update.js +0 -63
  90. package/src/nodes/sse-emitter/index.js +0 -7
  91. package/src/nodes/sse-emitter/init.js +0 -105
  92. package/src/nodes/sse-emitter/introspect.js +0 -134
  93. package/src/nodes/sse-emitter/publish-to.js +0 -6
  94. package/src/nodes/sse-emitter/recompute.js +0 -16
  95. package/src/nodes/sse-emitter/reset.js +0 -19
  96. package/src/nodes/sse-emitter/update.js +0 -69
  97. package/src/nodes/sse-emitter/utils/broadcast-heartbeat.js +0 -36
  98. package/src/nodes/sse-emitter/utils/build-message.js +0 -31
  99. package/src/nodes/sse-emitter/utils/connection-manager.js +0 -45
  100. package/src/nodes/sse-emitter/utils/endpoint-initializer.js +0 -20
  101. package/src/nodes/sse-emitter/utils/health-monitor.js +0 -38
  102. package/src/nodes/sse-emitter/utils/qos-handler.js +0 -41
@@ -1,6 +1,7 @@
1
1
  import nodeTypeToModule from '../wiring/node-type-to-module.js';
2
2
 
3
3
  import { ENV_VARS } from '../env-vars.js';
4
+ import { logger } from '../logger/index.js';
4
5
 
5
6
  /**
6
7
  * This function transforms trigger specifications that reference nodes by name
@@ -44,12 +45,12 @@ const resolveTriggers = function ( stateStore, triggers, scopedFlow ) {
44
45
  // Resolve first target to establish reference node type
45
46
  const firstTargetName = targets[ 0 ];
46
47
  if ( typeof firstTargetName !== 'string' || firstTargetName.length === 0 ) {
47
- throw new TypeError( `Target name at trigger ${i}, target 0 must be a non-empty string; found '${firstTargetName}'` );
48
+ throw new TypeError( `winkComposer/partitionManager: Target name at trigger ${i}, target 0 must be a non-empty string; found '${firstTargetName}'` );
48
49
  }
49
50
 
50
51
  const firstTargetIndex = scopedFlow.specs.findIndex( ( spec ) => spec.name === firstTargetName );
51
52
  if ( firstTargetIndex === -1 ) {
52
- throw new Error( `Target node '${firstTargetName}' not found in flow specs for trigger ${i}` );
53
+ throw new Error( `winkComposer/partitionManager: Target node '${firstTargetName}' not found in flow specs for trigger ${i}` );
53
54
  }
54
55
 
55
56
  // Get reference node module and validate control method exists
@@ -58,11 +59,11 @@ const resolveTriggers = function ( stateStore, triggers, scopedFlow ) {
58
59
  const referenceNode = scopedFlow.nodeModules[ moduleName ];
59
60
 
60
61
  if ( !referenceNode ) {
61
- throw new Error( `Node module '${moduleName}' not found for nodeType '${firstSpec.nodeType}' in trigger ${i}` );
62
+ throw new Error( `winkComposer/partitionManager: Node module '${moduleName}' not found for nodeType '${firstSpec.nodeType}' in trigger ${i}` );
62
63
  }
63
64
 
64
65
  if ( typeof referenceNode[ control ] !== 'function' ) {
65
- throw new Error( `Control method '${control}' not found on node type '${referenceNode.getNodeType?.() || firstSpec.nodeType}' for trigger ${i}` );
66
+ throw new Error( `winkComposer/partitionManager: Control method '${control}' not found on node type '${referenceNode.getNodeType?.() || firstSpec.nodeType}' for trigger ${i}` );
66
67
  }
67
68
 
68
69
  // Store first resolved target
@@ -73,12 +74,12 @@ const resolveTriggers = function ( stateStore, triggers, scopedFlow ) {
73
74
  const targetName = targets[ j ];
74
75
 
75
76
  if ( typeof targetName !== 'string' || targetName.length === 0 ) {
76
- throw new TypeError( `Target name at trigger ${i}, target ${j} must be a non-empty string; found '${targetName}'` );
77
+ throw new TypeError( `winkComposer/partitionManager: Target name at trigger ${i}, target ${j} must be a non-empty string; found '${targetName}'` );
77
78
  }
78
79
 
79
80
  const targetIndex = scopedFlow.specs.findIndex( ( spec ) => spec.name === targetName );
80
81
  if ( targetIndex === -1 ) {
81
- throw new Error( `Target node '${targetName}' not found in flow specs for trigger ${i}, target ${j}` );
82
+ throw new Error( `winkComposer/partitionManager: Target node '${targetName}' not found in flow specs for trigger ${i}, target ${j}` );
82
83
  }
83
84
 
84
85
  // Ensure all targets are homogeneous (same node type)
@@ -89,7 +90,7 @@ const resolveTriggers = function ( stateStore, triggers, scopedFlow ) {
89
90
  if ( targetNode !== referenceNode ) {
90
91
  const refNodeType = referenceNode.getNodeType?.() || firstSpec.nodeType;
91
92
  const targetNodeType = targetNode.getNodeType?.() || targetSpec.nodeType;
92
- throw new Error( `Target node '${targetName}' (type: ${targetNodeType}) is incompatible with reference node type '${refNodeType}' for trigger ${i}` );
93
+ throw new Error( `winkComposer/partitionManager: Target node '${targetName}' (type: ${targetNodeType}) is incompatible with reference node type '${refNodeType}' for trigger ${i}` );
93
94
  }
94
95
 
95
96
  resolvedTargets[ j ] = stateStore[ targetIndex ];
@@ -104,6 +105,102 @@ const resolveTriggers = function ( stateStore, triggers, scopedFlow ) {
104
105
  return resolvedTriggers;
105
106
  }; // resolveTriggers()
106
107
 
108
+ /**
109
+ * Builds one specialization's graph: node states, per-partition
110
+ * injections, and resolved triggers. Extracted from update() so the
111
+ * creation attempt is one guardable unit — a throw from any node's
112
+ * init, a missing module, or trigger resolution leaves no partial
113
+ * graph published (the caller publishes only on success).
114
+ *
115
+ * @param {Object} flow - Flow specification with nodeModules
116
+ * @param {Array} specs - This specialization's node specs
117
+ * @param {*} partitionId - Partition key, for topic/storage injection
118
+ * @param {*} specializationType - Specialization key, for topic injection
119
+ * @returns {Array} Initialized node state array with resolved triggers
120
+ */
121
+ const buildGraph = function ( flow, specs, partitionId, specializationType ) {
122
+ const graph = new Array( specs.length );
123
+
124
+ for ( let k = 0; k < specs.length; k += 1 ) {
125
+ const spec = specs[ k ];
126
+
127
+ // Look up node module using nodeTypeToModule
128
+ const moduleName = nodeTypeToModule( spec.nodeType );
129
+ const node = flow.nodeModules[ moduleName ];
130
+
131
+ if ( !node ) {
132
+ throw new Error( `winkComposer/partitionManager: Node module '${moduleName}' not found for nodeType '${spec.nodeType}' at index ${k}` );
133
+ }
134
+
135
+ // Initialize node state
136
+ graph[ k ] = node.init( spec );
137
+
138
+ // ====================================================================
139
+ // MQTT TOPIC INJECTION IN EMIT IF NODE
140
+ // ====================================================================
141
+ // Precompute partition-specific MQTT topic at initialization to avoid
142
+ // reconstruction on every emission (performance optimization).
143
+ //
144
+ // Topic structure: edgeDeviceId/partitionId/specialization/insightType
145
+ // Example: "edge-001/sensor-42/temperature/changeDetected"
146
+ //
147
+ // The edgeDeviceId can encode ISA-95 hierarchy for Unified Namespace:
148
+ // - Simple: "edge-001"
149
+ // - UNS: "northwind/riverton/plant2/line3" (enterprise/site/area/cell)
150
+ //
151
+ // Enables powerful MQTT subscription patterns:
152
+ // - All from edge: "edge-001/#" or "northwind/riverton/#"
153
+ // - Specific sensor: "+/sensor-42/#"
154
+ if ( spec.nodeType === 'Emit If' && ( graph[ k ].target === 'mqtt' || graph[ k ].target === 'terminal' ) ) {
155
+ graph[ k ].topic = `${ENV_VARS.edgeDeviceId}/${partitionId}/${specializationType}/${graph[ k ].insightType}`;
156
+ }
157
+
158
+ // ====================================================================
159
+ // STORAGE AND PARTITION INJECTION IN PERSIST IF NODE
160
+ // ====================================================================
161
+ // Inject the storage singleton and partition ID at initialization.
162
+ // Storage reference is set during wiring (spec.storage), but partition
163
+ // ID is partition-specific and must be injected here.
164
+ //
165
+ // Zero hot-path overhead: All references are pre-resolved.
166
+ if ( spec.nodeType === 'Persist If' && spec.storage ) {
167
+ graph[ k ].storage = spec.storage;
168
+ graph[ k ].partitionId = partitionId;
169
+ }
170
+
171
+ // Resolve triggers with specialization-scoped data
172
+ graph[ k ].resolvedTriggers = resolveTriggers( graph, spec.triggers, {
173
+ specs: specs,
174
+ nodeModules: flow.nodeModules
175
+ });
176
+ } // for ( let k = 0; k < specs.length; k += 1 )
177
+
178
+ // Special handling for Controller nodes with nested triggers
179
+ // Only controller node allows FORWARD node control.
180
+ // That is why it has to be performed only after all nodes have
181
+ // been initialized.
182
+ for ( let k = 0; k < specs.length; k += 1 ) {
183
+ const spec = specs[ k ];
184
+ if ( spec.nodeType === 'Controller' && graph[ k ].logic ) {
185
+ // Resolve triggers for each condition in the logic array
186
+ for ( let j = 0; j < graph[ k ].logic.length; j += 1 ) {
187
+ graph[ k ].logic[ j ].resolvedTriggers = resolveTriggers(
188
+ graph,
189
+ graph[ k ].logic[ j ].triggers,
190
+ {
191
+ specs: specs,
192
+ nodeModules: flow.nodeModules
193
+ }
194
+ );
195
+ }
196
+ // No top-level resolvedTriggers for controller
197
+ graph[ k ].resolvedTriggers = null;
198
+ }
199
+ } // for ( let k = 0; k < specs.length; k += 1 )
200
+
201
+ return graph;
202
+ };
203
+
107
204
  /**
108
205
  * Partition state manager's update with integrated trigger resolution and specialization support.
109
206
  * Resolved triggers are co-located with each node's state for zero hot-path overhead.
@@ -137,9 +234,10 @@ const update = function ( composerState, msg ) {
137
234
  const specs = flow.specsBySpecialization[ specializationType ];
138
235
 
139
236
  if ( !specs ) {
140
- // Log error and skip message - no default fallback
237
+ // Count, log, and skip the message - no default fallback
141
238
  // Note: Do NOT create partition entry for unknown specialization
142
- console.error( `[PartitionManager] Unknown specialization '${specializationType}' for partition '${partitionId}'. Message dropped.` );
239
+ composerState.droppedUnknownSpecialization += 1;
240
+ logger.error( `winkComposer/partitionManager: Unknown specialization '${specializationType}' for partition '${partitionId}'. Message dropped.` );
143
241
  return null;
144
242
  }
145
243
 
@@ -152,8 +250,8 @@ const update = function ( composerState, msg ) {
152
250
  if ( !specializedGraphs ) {
153
251
  composerState.totalPartitionsCreated += 1;
154
252
  if ( composerState.totalPartitionsCreated > ENV_VARS.maxPartitionsAllowed ) {
155
- console.error(
156
- `[PartitionManager] Partition creation for assetId '${partitionId}' ` +
253
+ logger.error(
254
+ `winkComposer/partitionManager: Partition creation for assetId '${partitionId}' ` +
157
255
  `failed — maxPartitionsAllowed (${ENV_VARS.maxPartitionsAllowed}) reached. ` +
158
256
  'Message dropped.'
159
257
  );
@@ -163,85 +261,43 @@ const update = function ( composerState, msg ) {
163
261
  partitionSpecializations.set( partitionId, specializedGraphs );
164
262
  }
165
263
 
166
- // Create state array sized for this specialization
167
- graph = new Array( specs.length );
168
-
169
- for ( let k = 0; k < specs.length; k += 1 ) {
170
- const spec = specs[ k ];
171
-
172
- // Look up node module using nodeTypeToModule
173
- const moduleName = nodeTypeToModule( spec.nodeType );
174
- const node = flow.nodeModules[ moduleName ];
175
-
176
- if ( !node ) {
177
- throw new Error( `Node module '${moduleName}' not found for nodeType '${spec.nodeType}' at index ${k}` );
178
- }
179
-
180
- // Initialize node state
181
- graph[ k ] = node.init( spec );
182
-
183
- // ====================================================================
184
- // MQTT TOPIC INJECTION IN EMIT IF NODE
185
- // ====================================================================
186
- // Precompute partition-specific MQTT topic at initialization to avoid
187
- // reconstruction on every emission (performance optimization).
188
- //
189
- // Topic structure: edgeDeviceId/partitionId/specialization/insightType
190
- // Example: "edge-001/sensor-42/temperature/changeDetected"
191
- //
192
- // The edgeDeviceId can encode ISA-95 hierarchy for Unified Namespace:
193
- // - Simple: "edge-001"
194
- // - UNS: "northwind/riverton/plant2/line3" (enterprise/site/area/cell)
195
- //
196
- // Enables powerful MQTT subscription patterns:
197
- // - All from edge: "edge-001/#" or "northwind/riverton/#"
198
- // - Specific sensor: "+/sensor-42/#"
199
- if ( spec.nodeType === 'Emit If' && ( graph[ k ].target === 'mqtt' || graph[ k ].target === 'terminal' ) ) {
200
- graph[ k ].topic = `${ENV_VARS.edgeDeviceId}/${partitionId}/${specializationType}/${graph[ k ].insightType}`;
201
- }
264
+ // Quarantine check. A partition with no failure history (a
265
+ // fresh entry, or a healthy one adding a new specialization)
266
+ // has no ledger and passes through. A quarantined partition's
267
+ // messages drop here, before any node init runs — creation is
268
+ // blocked, not re-attempted. One WeakMap read on this cold
269
+ // path; the healthy hot path (graph exists) never reaches it.
270
+ const priorLedger = composerState.creationFailures.get( specializedGraphs );
271
+ if ( priorLedger && priorLedger.quarantined ) {
272
+ return null;
273
+ }
202
274
 
203
- // ====================================================================
204
- // STORAGE AND PARTITION INJECTION IN PERSIST IF NODE
205
- // ====================================================================
206
- // Inject the storage singleton and partition ID at initialization.
207
- // Storage reference is set during wiring (spec.storage), but partition
208
- // ID is partition-specific and must be injected here.
209
- //
210
- // Zero hot-path overhead: All references are pre-resolved.
211
- if ( spec.nodeType === 'Persist If' && spec.storage ) {
212
- graph[ k ].storage = spec.storage;
213
- graph[ k ].partitionId = partitionId;
275
+ // Build the graph under the quarantine ledger. A throw anywhere
276
+ // in the build (a node init, trigger resolution, a missing
277
+ // module) counts one consecutive creation failure for this
278
+ // partition and RETHROWS — the dispatch guard owns reporting.
279
+ // At the shared threshold the partition is quarantined with one
280
+ // classified report. A successful build clears the ledger.
281
+ try {
282
+ graph = buildGraph( flow, specs, partitionId, specializationType );
283
+ } catch ( creationError ) {
284
+ let ledger = composerState.creationFailures.get( specializedGraphs );
285
+ if ( !ledger ) {
286
+ ledger = { failures: 0, quarantined: false };
287
+ composerState.creationFailures.set( specializedGraphs, ledger );
214
288
  }
215
-
216
- // Resolve triggers with specialization-scoped data
217
- graph[ k ].resolvedTriggers = resolveTriggers( graph, spec.triggers, {
218
- specs: specs,
219
- nodeModules: flow.nodeModules
220
- });
221
- } // for ( let k = 0; k < specs.length; k += 1 )
222
-
223
- // Special handling for Controller nodes with nested triggers
224
- // Only controller node allows FORWARD node control.
225
- // That is why it has to be performed only after all nodes have
226
- // been initialized.
227
- for ( let k = 0; k < specs.length; k += 1 ) {
228
- const spec = specs[ k ];
229
- if ( spec.nodeType === 'Controller' && graph[ k ].logic ) {
230
- // Resolve triggers for each condition in the logic array
231
- for ( let j = 0; j < graph[ k ].logic.length; j += 1 ) {
232
- graph[ k ].logic[ j ].resolvedTriggers = resolveTriggers(
233
- graph,
234
- graph[ k ].logic[ j ].triggers,
235
- {
236
- specs: specs,
237
- nodeModules: flow.nodeModules
238
- }
239
- );
240
- }
241
- // No top-level resolvedTriggers for controller
242
- graph[ k ].resolvedTriggers = null;
289
+ ledger.failures += 1;
290
+ if ( ledger.failures >= ENV_VARS.messageFailureThreshold ) {
291
+ ledger.quarantined = true;
292
+ logger.error(
293
+ `winkComposer/partitionManager: Partition '${partitionId}' quarantined after ` +
294
+ `${ledger.failures} consecutive creation failures ` +
295
+ `(last: ${creationError.message}). Later messages for it are dropped.`
296
+ );
243
297
  }
244
- } // for ( let k = 0; k < specs.length; k += 1 )
298
+ throw creationError;
299
+ }
300
+ composerState.creationFailures.delete( specializedGraphs );
245
301
 
246
302
  specializedGraphs[ specializationType ] = graph;
247
303
  }
@@ -145,7 +145,7 @@ const validateFlow = function ( flow ) {
145
145
  actualType = typeof flow;
146
146
  }
147
147
  throw new TypeError(
148
- `WinkComposer/partitionManager validation failed:\n flow: Expected object, got ${actualType}`
148
+ `winkComposer/partitionManager: validation failed:\n flow: Expected object, got ${actualType}`
149
149
  );
150
150
  }
151
151
 
@@ -27,7 +27,7 @@ import { readFile } from 'node:fs/promises';
27
27
  import { basename } from 'node:path';
28
28
 
29
29
  // Standard error prefix for all semantic loader errors
30
- const ERR_PREFIX = 'WinkComposer/semantics: ';
30
+ const ERR_PREFIX = 'winkComposer/semantics: ';
31
31
  import { glob } from 'glob';
32
32
  import { validateWithSchema } from '../utils/validate/index.js';
33
33
  import {
@@ -16,6 +16,8 @@
16
16
  * @see core/storage-manager/questdb/persist-plan.js for onWarning callback pattern
17
17
  */
18
18
 
19
+ import { logger } from '../logger/index.js';
20
+
19
21
  // ============================================================================
20
22
  // CONSTANTS
21
23
  // ============================================================================
@@ -24,7 +26,7 @@
24
26
  * Standard warning prefix for semantic validation warnings.
25
27
  * @type {string}
26
28
  */
27
- const WARNING_PREFIX = 'WinkComposer/semantics';
29
+ const WARNING_PREFIX = 'winkComposer/semantics';
28
30
 
29
31
  // ============================================================================
30
32
  // DEFAULT WARNING HANDLER
@@ -32,12 +34,12 @@ const WARNING_PREFIX = 'WinkComposer/semantics';
32
34
 
33
35
  /**
34
36
  * Default warning handler for semantic validation issues.
35
- * Logs to console in WinkComposer format.
37
+ * Logs to console in winkComposer format.
36
38
  *
37
39
  * @param {string} message - Warning message describing the issue
38
40
  */
39
41
  const defaultOnWarning = function ( message ) {
40
- console.warn( `${WARNING_PREFIX}: ${message}` );
42
+ logger.warn( `${WARNING_PREFIX}: ${message}` );
41
43
  };
42
44
 
43
45
  // ============================================================================
@@ -21,6 +21,15 @@
21
21
  * wider scope: the per-adapter `{ timeout }` (one adapter's drain),
22
22
  * the per-flow stage budget, and this process-level ceiling on the
23
23
  * whole exit.
24
+ * - **Exit 1 on a data-losing stop** (the 2026-08-29 ruling). A drain
25
+ * that rejects means that flow lost data at shutdown; the adapter
26
+ * said so with a classified error. Each rejection prints one
27
+ * classified console line, and the process exits 1 — a supervisor
28
+ * (systemd, Docker, Kubernetes, a batch script) must never read a
29
+ * data-losing stop as clean. This extends ADR-018's delivery
30
+ * invariant to the process boundary. Callers who invoke
31
+ * `handle.shutdown()` directly are unaffected: they receive the
32
+ * rejection themselves and own their own response.
24
33
  *
25
34
  * This layer owns NO storage. The storage-directory cleanup it used to
26
35
  * run (an `fs.rm` of STORAGE_DIR at dev/test shutdown) was removed
@@ -57,6 +66,7 @@
57
66
 
58
67
  import { emitters } from '../wiring/index.js';
59
68
  import { ENV_VARS } from '../env-vars.js';
69
+ import { logger } from '../logger/index.js';
60
70
 
61
71
  /**
62
72
  * Factory that produces a fresh shutdown-manager instance with isolated
@@ -120,15 +130,24 @@ const createShutdownManager = function () {
120
130
  /**
121
131
  * Iterate every registered flow handle, calling `handle.shutdown()`
122
132
  * on each in parallel via `Promise.allSettled`. Race the whole drain
123
- * against `timeoutMs`; if the timeout wins, return false so the
124
- * caller can log + force-exit with code 1.
133
+ * against `timeoutMs`.
125
134
  *
126
- * Returns true when no handles are registered (e.g., the legacy
127
- * code path with no modern flows in process).
135
+ * A drain that REJECTS means that flow lost data at shutdown — the
136
+ * adapter said so with a classified error (`DELIVERY_FAILED` or
137
+ * `SHUTDOWN_TIMEOUT`, per ADR-018 a shutdown never resolves cleanly
138
+ * over a loss). Each rejection prints one classified console line
139
+ * and counts in `failedDrains`, so the caller can refuse the clean
140
+ * exit code (the 2026-08-29 exit-1 ruling).
141
+ *
142
+ * @param {number} timeoutMs - Ceiling for the whole drain
143
+ * @returns {Promise<{timedOut: boolean, failedDrains: number}>}
144
+ * `timedOut` — the race hit the ceiling; the drains are still in
145
+ * flight, so their outcomes are unknown and `failedDrains` stays 0.
146
+ * `failedDrains` — how many drains rejected (0 with no handles).
128
147
  */
129
148
  const drainHandles = async function ( timeoutMs ) {
130
149
  if ( handles.size === 0 ) {
131
- return true;
150
+ return { timedOut: false, failedDrains: 0 };
132
151
  }
133
152
 
134
153
  const drainAll = Promise.allSettled(
@@ -153,7 +172,25 @@ const createShutdownManager = function () {
153
172
  // that runs the manager and then expects to exit cleanly.
154
173
  // Same canonical pattern used by every source `stopFn`.
155
174
  clearTimeout( timerHandle );
156
- return winner !== TIMED_OUT;
175
+ if ( winner === TIMED_OUT ) {
176
+ return { timedOut: true, failedDrains: 0 };
177
+ }
178
+
179
+ // The drain settled in time: read every outcome. The reads are
180
+ // null-safe because a rejection reason can be anything.
181
+ let failedDrains = 0;
182
+ for ( let i = 0; i < winner.length; i += 1 ) {
183
+ if ( winner[ i ].status === 'rejected' ) {
184
+ failedDrains += 1;
185
+ const reason = winner[ i ].reason;
186
+ const code = ( reason && reason.code ) || 'UNKNOWN';
187
+ const detail = ( reason && reason.message ) || String( reason );
188
+ const count = reason && reason.dropped && reason.dropped.count;
189
+ const droppedNote = ( typeof count === 'number' ) ? ` — ${count} message(s) dropped` : '';
190
+ logger.error( `winkComposer/shutdownManager: Flow drain failed [${code}]: ${detail}${droppedNote}` );
191
+ }
192
+ }
193
+ return { timedOut: false, failedDrains };
157
194
  };
158
195
 
159
196
  // ========================================================================
@@ -167,11 +204,16 @@ const createShutdownManager = function () {
167
204
  * 1. Drain registered flow handles (modern path).
168
205
  * 2. Run the legacy path block (clustered for retirement; warns once
169
206
  * when actually doing work).
170
- * 3. Return graceful (true) / forced (false) — the caller decides
171
- * the exit code.
207
+ * 3. Return graceful (true) / not-graceful (false) — the caller
208
+ * decides the exit code.
209
+ *
210
+ * Graceful means BOTH: the drain finished inside the timeout, AND
211
+ * every drain resolved clean. A drain that rejected lost data, so
212
+ * it must not read as a clean stop (the 2026-08-29 exit-1 ruling;
213
+ * ADR-018's delivery invariant, extended to the process boundary).
172
214
  *
173
- * @returns {Promise<boolean>} true on graceful completion, false on
174
- * forced timeout
215
+ * @returns {Promise<boolean>} true on graceful completion; false on
216
+ * forced timeout or on any rejected drain
175
217
  */
176
218
  const shutdown = async function () {
177
219
  if ( isShuttingDown ) {
@@ -180,17 +222,23 @@ const createShutdownManager = function () {
180
222
  isShuttingDown = true;
181
223
 
182
224
  const timestamp = new Date().toISOString();
183
- console.log( `\n[${timestamp}] Shutdown initiated` );
225
+ logger.info( `\nwinkComposer/shutdownManager: [${timestamp}] Shutdown initiated` );
184
226
 
185
227
  // Phase 1 (modern path): drain registered flow handles, racing
186
228
  // against the forced-shutdown timeout.
187
- const graceful = await drainHandles( ENV_VARS.shutdownForceTimeoutMs );
188
- if ( !graceful ) {
189
- console.warn(
190
- ` ⚠ Forced shutdown — flow drain exceeded ${ENV_VARS.shutdownForceTimeoutMs}ms; ` +
229
+ const drain = await drainHandles( ENV_VARS.shutdownForceTimeoutMs );
230
+ if ( drain.timedOut ) {
231
+ logger.warn(
232
+ `winkComposer/shutdownManager: Forced shutdown — flow drain exceeded ${ENV_VARS.shutdownForceTimeoutMs}ms; ` +
191
233
  'some adapters may not have completed cleanly'
192
234
  );
193
235
  }
236
+ if ( drain.failedDrains > 0 ) {
237
+ logger.warn(
238
+ `winkComposer/shutdownManager: ${drain.failedDrains} flow drain(s) lost data (lines above) — shutdown is not clean`
239
+ );
240
+ }
241
+ const graceful = !drain.timedOut && ( drain.failedDrains === 0 );
194
242
 
195
243
  try {
196
244
  // ================================================================
@@ -219,9 +267,9 @@ const createShutdownManager = function () {
219
267
  const failedEmitters = emitterResults.filter( ( r ) => r.status === 'rejected' );
220
268
  if ( emitterResults.length > 0 ) {
221
269
  if ( failedEmitters.length > 0 ) {
222
- console.warn( ` ⚠ Warning: ${failedEmitters.length} legacy emitter(s) failed to shutdown cleanly` );
270
+ logger.warn( `winkComposer/shutdownManager: ${failedEmitters.length} legacy emitter(s) failed to shutdown cleanly` );
223
271
  } else {
224
- console.log( ' ✓ Legacy message queues flushed' );
272
+ logger.info( 'winkComposer/shutdownManager: Legacy message queues flushed' );
225
273
  }
226
274
  }
227
275
 
@@ -229,12 +277,12 @@ const createShutdownManager = function () {
229
277
  // END LEGACY PATH
230
278
  // ================================================================
231
279
 
232
- console.log( `[${new Date().toISOString()}] Shutdown complete\n` );
280
+ logger.info( `winkComposer/shutdownManager: [${new Date().toISOString()}] Shutdown complete\n` );
233
281
 
234
282
  return graceful;
235
283
 
236
284
  } catch ( error ) {
237
- console.error( '✗ Shutdown error:', error );
285
+ logger.error( 'winkComposer/shutdownManager: Shutdown error: ' + ( ( error && error.stack ) || String( error ) ) );
238
286
  process.exitCode = 1;
239
287
  return false;
240
288
  }
@@ -251,7 +299,9 @@ const createShutdownManager = function () {
251
299
  * process regardless of caller.
252
300
  *
253
301
  * On signal: runs `shutdown()`, then `process.exit(0)` on graceful
254
- * or `process.exit(1)` on forced timeout.
302
+ * completion. Exit is 1 on a forced timeout or when any flow's
303
+ * drain rejected — a rejected drain lost data, and the exit code
304
+ * must say so (the 2026-08-29 exit-1 ruling).
255
305
  */
256
306
  const attachHandlers = function () {
257
307
  if ( attached ) {
@@ -260,7 +310,7 @@ const createShutdownManager = function () {
260
310
  attached = true;
261
311
 
262
312
  const signalHandler = async function ( signalName ) {
263
- console.log( `\ncomposer: Received ${signalName}` );
313
+ logger.info( `\nwinkComposer/shutdownManager: Received ${signalName}` );
264
314
  const graceful = await shutdown();
265
315
 
266
316
  /* c8 ignore next 2 -- fundamentally untestable: invoking the signal handler from a test would call process.exit and kill the test runner. The graceful/forced exit-code mapping is verified by the shutdown() return-value tests above. */