@unlaxer/tramli 3.2.0 → 3.4.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.
@@ -88,5 +88,5 @@ export declare class DataFlowGraph<S extends string> {
88
88
  private static collectEdges;
89
89
  /** Version compatibility: check if v1 instances can resume on v2 definition. */
90
90
  static versionCompatibility<S extends string>(before: DataFlowGraph<S>, after: DataFlowGraph<S>): string[];
91
- static build<S extends string>(def: FlowDefinition<S>, initiallyAvailable: string[]): DataFlowGraph<S>;
91
+ static build<S extends string>(def: FlowDefinition<S>, initiallyAvailable: string[], externallyProvided?: string[]): DataFlowGraph<S>;
92
92
  }
@@ -370,14 +370,15 @@ class DataFlowGraph {
370
370
  return issues;
371
371
  }
372
372
  // ─── Builder ─────────────────────────────────────────────
373
- static build(def, initiallyAvailable) {
373
+ static build(def, initiallyAvailable, externallyProvided = []) {
374
374
  const stateAvail = new Map();
375
375
  const producers = new Map();
376
376
  const consumers = new Map();
377
377
  const allProduced = new Set(initiallyAvailable);
378
378
  const allConsumed = new Set();
379
+ const extSet = new Set(externallyProvided);
379
380
  if (def.initialState) {
380
- traverse(def, def.initialState, new Set(initiallyAvailable), stateAvail, producers, consumers, allProduced, allConsumed);
381
+ traverse(def, def.initialState, new Set(initiallyAvailable), extSet, stateAvail, producers, consumers, allProduced, allConsumed);
381
382
  // Mark initially available types as produced by "initial"
382
383
  for (const key of initiallyAvailable) {
383
384
  if (!producers.has(key))
@@ -391,7 +392,7 @@ class DataFlowGraph {
391
392
  }
392
393
  }
393
394
  exports.DataFlowGraph = DataFlowGraph;
394
- function traverse(def, state, available, stateAvail, producers, consumers, allProduced, allConsumed) {
395
+ function traverse(def, state, available, externallyProvided, stateAvail, producers, consumers, allProduced, allConsumed) {
395
396
  if (stateAvail.has(state)) {
396
397
  const existing = stateAvail.get(state);
397
398
  let isSubset = true;
@@ -413,6 +414,10 @@ function traverse(def, state, available, stateAvail, producers, consumers, allPr
413
414
  }
414
415
  for (const t of def.transitionsFrom(state)) {
415
416
  const newAvail = new Set(stateAvail.get(state));
417
+ if (t.type === 'external') {
418
+ for (const k of externallyProvided)
419
+ newAvail.add(k);
420
+ }
416
421
  if (t.guard) {
417
422
  for (const req of t.guard.requires) {
418
423
  addTo(consumers, req, { name: t.guard.name, fromState: t.from, toState: t.to, kind: 'guard' });
@@ -441,7 +446,7 @@ function traverse(def, state, available, stateAvail, producers, consumers, allPr
441
446
  newAvail.add(prod);
442
447
  }
443
448
  }
444
- traverse(def, t.to, newAvail, stateAvail, producers, consumers, allProduced, allConsumed);
449
+ traverse(def, t.to, newAvail, externallyProvided, stateAvail, producers, consumers, allProduced, allConsumed);
445
450
  }
446
451
  }
447
452
  function addTo(map, key, info) {
@@ -45,9 +45,12 @@ export declare class Builder<S extends string> {
45
45
  private readonly _enterActions;
46
46
  private readonly _exitActions;
47
47
  private readonly initiallyAvailableKeys;
48
+ private readonly externallyProvidedKeys;
48
49
  private _perpetual;
49
50
  constructor(name: string, stateConfig: Record<S, StateConfig>);
50
51
  initiallyAvailable(...keys: FlowKey<unknown>[]): this;
52
+ /** Declare data keys injected via resumeAndExecute(externalData), not available at start. */
53
+ externallyProvided(...keys: FlowKey<unknown>[]): this;
51
54
  setTtl(ms: number): this;
52
55
  setMaxGuardRetries(max: number): this;
53
56
  from(state: S): FromBuilder<S>;
@@ -113,6 +113,7 @@ class Builder {
113
113
  _enterActions = new Map();
114
114
  _exitActions = new Map();
115
115
  initiallyAvailableKeys = [];
116
+ externallyProvidedKeys = [];
116
117
  _perpetual = false;
117
118
  constructor(name, stateConfig) {
118
119
  this.name = name;
@@ -123,6 +124,12 @@ class Builder {
123
124
  this.initiallyAvailableKeys.push(k);
124
125
  return this;
125
126
  }
127
+ /** Declare data keys injected via resumeAndExecute(externalData), not available at start. */
128
+ externallyProvided(...keys) {
129
+ for (const k of keys)
130
+ this.externallyProvidedKeys.push(k);
131
+ return this;
132
+ }
126
133
  setTtl(ms) { this.ttl = ms; return this; }
127
134
  setMaxGuardRetries(max) { this.maxGuardRetries = max; return this; }
128
135
  from(state) {
@@ -185,7 +192,7 @@ class Builder {
185
192
  result.terminalStates = terminals;
186
193
  result.dataFlowGraph = null;
187
194
  this.validate(result);
188
- result.dataFlowGraph = data_flow_graph_js_1.DataFlowGraph.build(result, this.initiallyAvailableKeys);
195
+ result.dataFlowGraph = data_flow_graph_js_1.DataFlowGraph.build(result, this.initiallyAvailableKeys, this.externallyProvidedKeys);
189
196
  // Build warnings
190
197
  const warnings = [];
191
198
  const perpetual = terminals.size === 0;
@@ -354,6 +361,10 @@ class Builder {
354
361
  }
355
362
  for (const t of def.transitionsFrom(state)) {
356
363
  const newAvailable = new Set(stateAvailable.get(state));
364
+ if (t.type === 'external') {
365
+ for (const k of this.externallyProvidedKeys)
366
+ newAvailable.add(k);
367
+ }
357
368
  if (t.guard) {
358
369
  for (const req of t.guard.requires) {
359
370
  if (!newAvailable.has(req))
@@ -534,6 +545,7 @@ class BranchBuilder {
534
545
  processor: this.processors.get(label),
535
546
  guard: undefined, branch: this.branch,
536
547
  branchTargets: new Map(this.targets),
548
+ branchLabel: label,
537
549
  });
538
550
  }
539
551
  return this.builder;
@@ -10,6 +10,7 @@ export interface TransitionLogEntry {
10
10
  from: string | null;
11
11
  to: string;
12
12
  trigger: string;
13
+ durationMicros: number;
13
14
  }
14
15
  export interface StateLogEntry {
15
16
  flowId: string;
@@ -25,6 +26,7 @@ export interface ErrorLogEntry {
25
26
  to: string | null;
26
27
  trigger: string;
27
28
  cause: Error | null;
29
+ durationMicros: number;
28
30
  }
29
31
  export interface GuardLogEntry {
30
32
  flowId: string;
@@ -33,6 +35,7 @@ export interface GuardLogEntry {
33
35
  guardName: string;
34
36
  result: 'accepted' | 'rejected' | 'expired';
35
37
  reason?: string;
38
+ durationMicros: number;
36
39
  }
37
40
  export declare class FlowEngine {
38
41
  private readonly store;
@@ -97,10 +97,13 @@ class FlowEngine {
97
97
  }
98
98
  const guard = transition.guard;
99
99
  if (guard) {
100
+ const guardStart = this.guardLogger ? performance.now() : 0;
100
101
  const output = await guard.validate(flow.context);
102
+ const guardDurationMicros = this.guardLogger ? Math.round((performance.now() - guardStart) * 1000) : 0;
101
103
  switch (output.type) {
102
104
  case 'accepted': {
103
- this.logGuard(flow, currentState, guard.name, 'accepted');
105
+ this.logGuard(flow, currentState, guard.name, 'accepted', guardDurationMicros);
106
+ const transStart = this.transitionLogger ? performance.now() : 0;
104
107
  const backup = flow.context.snapshot();
105
108
  if (output.data) {
106
109
  for (const [key, value] of output.data)
@@ -114,7 +117,7 @@ class FlowEngine {
114
117
  flow.transitionTo(transition.to);
115
118
  this.fireEnter(flow, transition.to);
116
119
  this.store.recordTransition(flow.id, from, transition.to, guard.name, flow.context);
117
- this.logTransition(flow, from, transition.to, guard.name);
120
+ this.logTransition(flow, from, transition.to, guard.name, transStart);
118
121
  }
119
122
  catch (e) {
120
123
  flow.context.restoreFrom(backup);
@@ -125,7 +128,7 @@ class FlowEngine {
125
128
  break;
126
129
  }
127
130
  case 'rejected': {
128
- this.logGuard(flow, currentState, guard.name, 'rejected', output.reason);
131
+ this.logGuard(flow, currentState, guard.name, 'rejected', guardDurationMicros, output.reason);
129
132
  flow.incrementGuardFailure(guard.name);
130
133
  if (flow.guardFailureCount >= definition.maxGuardRetries) {
131
134
  this.handleError(flow, currentState);
@@ -134,7 +137,7 @@ class FlowEngine {
134
137
  return flow;
135
138
  }
136
139
  case 'expired': {
137
- this.logGuard(flow, currentState, guard.name, 'expired');
140
+ this.logGuard(flow, currentState, guard.name, 'expired', guardDurationMicros);
138
141
  flow.complete('EXPIRED');
139
142
  this.store.save(flow);
140
143
  return flow;
@@ -142,12 +145,13 @@ class FlowEngine {
142
145
  }
143
146
  }
144
147
  else {
148
+ const transStart = this.transitionLogger ? performance.now() : 0;
145
149
  const from = flow.currentState;
146
150
  this.fireExit(flow, from);
147
151
  flow.transitionTo(transition.to);
148
152
  this.fireEnter(flow, transition.to);
149
153
  this.store.recordTransition(flow.id, from, transition.to, 'external', flow.context);
150
- this.logTransition(flow, from, transition.to, 'external');
154
+ this.logTransition(flow, from, transition.to, 'external', transStart);
151
155
  }
152
156
  await this.executeAutoChain(flow);
153
157
  this.store.save(flow);
@@ -175,6 +179,7 @@ class FlowEngine {
175
179
  if (!autoOrBranch)
176
180
  break;
177
181
  const backup = flow.context.snapshot();
182
+ const stepStart = this.transitionLogger ? performance.now() : 0;
178
183
  try {
179
184
  if (autoOrBranch.type === 'auto') {
180
185
  if (autoOrBranch.processor) {
@@ -187,7 +192,7 @@ class FlowEngine {
187
192
  this.fireEnter(flow, autoOrBranch.to);
188
193
  const trigger = autoOrBranch.processor?.name ?? 'auto';
189
194
  this.store.recordTransition(flow.id, from, autoOrBranch.to, trigger, flow.context);
190
- this.logTransition(flow, from, autoOrBranch.to, trigger);
195
+ this.logTransition(flow, from, autoOrBranch.to, trigger, stepStart);
191
196
  }
192
197
  else {
193
198
  const branch = autoOrBranch.branch;
@@ -196,7 +201,7 @@ class FlowEngine {
196
201
  if (!target) {
197
202
  throw new flow_error_js_1.FlowError('UNKNOWN_BRANCH', `Branch '${branch.name}' returned unknown label: ${label}`);
198
203
  }
199
- const specific = transitions.find(t => t.type === 'branch' && t.to === target) ?? autoOrBranch;
204
+ const specific = transitions.find(t => t.type === 'branch' && t.branchLabel === label) ?? transitions.find(t => t.type === 'branch' && t.to === target) ?? autoOrBranch;
200
205
  if (specific.processor)
201
206
  await specific.processor.process(flow.context);
202
207
  const from = flow.currentState;
@@ -205,7 +210,7 @@ class FlowEngine {
205
210
  this.fireEnter(flow, target);
206
211
  const trigger = `${branch.name}:${label}`;
207
212
  this.store.recordTransition(flow.id, from, target, trigger, flow.context);
208
- this.logTransition(flow, from, target, trigger);
213
+ this.logTransition(flow, from, target, trigger, stepStart);
209
214
  }
210
215
  }
211
216
  catch (e) {
@@ -229,13 +234,14 @@ class FlowEngine {
229
234
  parentFlow.setActiveSubFlow(null);
230
235
  const target = exitMappings.get(subFlow.exitState);
231
236
  if (target) {
237
+ const sfStart = this.transitionLogger ? performance.now() : 0;
232
238
  const from = parentFlow.currentState;
233
239
  this.fireExit(parentFlow, from);
234
240
  parentFlow.transitionTo(target);
235
241
  this.fireEnter(parentFlow, target);
236
242
  const trigger = `subFlow:${subDef.name}/${subFlow.exitState}`;
237
243
  this.store.recordTransition(parentFlow.id, from, target, trigger, parentFlow.context);
238
- this.logTransition(parentFlow, from, target, trigger);
244
+ this.logTransition(parentFlow, from, target, trigger, sfStart);
239
245
  return 1;
240
246
  }
241
247
  // Error bubbling: no exit mapping → fall back to parent's error transitions
@@ -253,17 +259,20 @@ class FlowEngine {
253
259
  }
254
260
  const guard = transition.guard;
255
261
  if (guard) {
262
+ const guardStart = this.guardLogger ? performance.now() : 0;
256
263
  const output = await guard.validate(parentFlow.context);
264
+ const guardDur = this.guardLogger ? Math.round((performance.now() - guardStart) * 1000) : 0;
257
265
  if (output.type === 'accepted') {
258
266
  if (output.data) {
259
267
  for (const [key, value] of output.data)
260
268
  parentFlow.context.put(key, value);
261
269
  }
270
+ const sfStart = this.transitionLogger ? performance.now() : 0;
262
271
  const sfFrom = subFlow.currentState;
263
272
  subFlow.transitionTo(transition.to);
264
273
  this.store.recordTransition(parentFlow.id, sfFrom, transition.to, guard.name, parentFlow.context);
265
- this.logTransition(parentFlow, sfFrom, transition.to, guard.name);
266
- this.logGuard(parentFlow, sfFrom, guard.name, 'accepted');
274
+ this.logTransition(parentFlow, sfFrom, transition.to, guard.name, sfStart);
275
+ this.logGuard(parentFlow, sfFrom, guard.name, 'accepted', guardDur);
267
276
  }
268
277
  else if (output.type === 'rejected') {
269
278
  subFlow.incrementGuardFailure();
@@ -290,13 +299,14 @@ class FlowEngine {
290
299
  if (subFlowT?.exitMappings) {
291
300
  const target = subFlowT.exitMappings.get(subFlow.exitState);
292
301
  if (target) {
302
+ const exitStart = this.transitionLogger ? performance.now() : 0;
293
303
  const from = parentFlow.currentState;
294
304
  this.fireExit(parentFlow, from);
295
305
  parentFlow.transitionTo(target);
296
306
  this.fireEnter(parentFlow, target);
297
307
  const trigger = `subFlow:${subDef.name}/${subFlow.exitState}`;
298
308
  this.store.recordTransition(parentFlow.id, from, target, trigger, parentFlow.context);
299
- this.logTransition(parentFlow, from, target, trigger);
309
+ this.logTransition(parentFlow, from, target, trigger, exitStart);
300
310
  await this.executeAutoChain(parentFlow);
301
311
  }
302
312
  }
@@ -323,16 +333,23 @@ class FlowEngine {
323
333
  if (action)
324
334
  action(flow.context);
325
335
  }
326
- logTransition(flow, from, to, trigger) {
327
- this.transitionLogger?.({ flowId: flow.id, flowName: flow.definition.name, from, to, trigger });
336
+ logTransition(flow, from, to, trigger, startMs) {
337
+ if (this.transitionLogger) {
338
+ const durationMicros = Math.round((performance.now() - startMs) * 1000);
339
+ this.transitionLogger({ flowId: flow.id, flowName: flow.definition.name, from, to, trigger, durationMicros });
340
+ }
328
341
  }
329
- logError(flow, from, to, trigger, cause) {
330
- this.errorLogger?.({ flowId: flow.id, flowName: flow.definition.name, from, to, trigger, cause });
342
+ logError(flow, from, to, trigger, cause, startMs) {
343
+ if (this.errorLogger) {
344
+ const durationMicros = Math.round((performance.now() - startMs) * 1000);
345
+ this.errorLogger({ flowId: flow.id, flowName: flow.definition.name, from, to, trigger, cause, durationMicros });
346
+ }
331
347
  }
332
- logGuard(flow, state, guardName, result, reason) {
333
- this.guardLogger?.({ flowId: flow.id, flowName: flow.definition.name, state, guardName, result, reason });
348
+ logGuard(flow, state, guardName, result, durationMicros, reason) {
349
+ this.guardLogger?.({ flowId: flow.id, flowName: flow.definition.name, state, guardName, result, reason, durationMicros });
334
350
  }
335
351
  handleError(flow, fromState, cause) {
352
+ const errorStart = (this.transitionLogger || this.errorLogger) ? performance.now() : 0;
336
353
  if (cause) {
337
354
  flow.setLastError(`${cause.constructor.name}: ${cause.message}`);
338
355
  if (cause instanceof flow_error_js_1.FlowError) {
@@ -342,7 +359,7 @@ class FlowEngine {
342
359
  cause.withContextSnapshot(available, new Set());
343
360
  }
344
361
  }
345
- this.logError(flow, fromState, null, 'error', cause ?? null);
362
+ this.logError(flow, fromState, null, 'error', cause ?? null, errorStart);
346
363
  // 1. Try exception-typed routes first (onStepError)
347
364
  if (cause && flow.definition.exceptionRoutes) {
348
365
  const routes = flow.definition.exceptionRoutes.get(fromState);
@@ -353,7 +370,7 @@ class FlowEngine {
353
370
  flow.transitionTo(route.target);
354
371
  const trigger = `error:${cause.constructor.name}`;
355
372
  this.store.recordTransition(flow.id, from, route.target, trigger, flow.context);
356
- this.logTransition(flow, from, route.target, trigger);
373
+ this.logTransition(flow, from, route.target, trigger, errorStart);
357
374
  if (flow.definition.stateConfig[route.target]?.terminal)
358
375
  flow.complete(route.target);
359
376
  return;
@@ -367,7 +384,7 @@ class FlowEngine {
367
384
  const from = flow.currentState;
368
385
  flow.transitionTo(errorTarget);
369
386
  this.store.recordTransition(flow.id, from, errorTarget, 'error', flow.context);
370
- this.logTransition(flow, from, errorTarget, 'error');
387
+ this.logTransition(flow, from, errorTarget, 'error', errorStart);
371
388
  if (flow.definition.stateConfig[errorTarget]?.terminal)
372
389
  flow.complete(errorTarget);
373
390
  }
@@ -92,14 +92,16 @@ class Pipeline {
92
92
  const completed = [];
93
93
  let prev = 'initial';
94
94
  for (const step of this.steps) {
95
- this.transitionLogger?.({ flowId, flowName: this.name, from: prev, to: step.name, trigger: step.name });
95
+ const stepStart = (this.transitionLogger || this.errorLogger) ? performance.now() : 0;
96
+ this.transitionLogger?.({ flowId, flowName: this.name, from: prev, to: step.name, trigger: step.name, durationMicros: 0 });
96
97
  const keysBefore = this.stateLogger ? new Set(ctx.snapshot().keys()) : null;
97
98
  try {
98
99
  await step.process(ctx);
99
100
  }
100
101
  catch (e) {
101
102
  const err = e instanceof Error ? e : new Error(String(e));
102
- this.errorLogger?.({ flowId, flowName: this.name, from: prev, to: step.name, trigger: step.name, cause: err });
103
+ const durationMicros = Math.round((performance.now() - stepStart) * 1000);
104
+ this.errorLogger?.({ flowId, flowName: this.name, from: prev, to: step.name, trigger: step.name, cause: err, durationMicros });
103
105
  throw new PipelineException(step.name, [...completed], ctx, err);
104
106
  }
105
107
  if (this.strictMode) {
@@ -26,6 +26,8 @@ export interface Transition<S extends string> {
26
26
  guard?: TransitionGuard<S>;
27
27
  branch?: BranchProcessor<S>;
28
28
  branchTargets: Map<string, S>;
29
+ /** Label assigned by builder .to(target, label, processor). Used for branch label-specific processor matching. */
30
+ branchLabel?: string;
29
31
  subFlowDefinition?: import('./flow-definition.js').FlowDefinition<any>;
30
32
  exitMappings?: Map<string, S>;
31
33
  /** Per-state timeout in milliseconds. If set, resumeAndExecute checks this before guard. */
@@ -88,5 +88,5 @@ export declare class DataFlowGraph<S extends string> {
88
88
  private static collectEdges;
89
89
  /** Version compatibility: check if v1 instances can resume on v2 definition. */
90
90
  static versionCompatibility<S extends string>(before: DataFlowGraph<S>, after: DataFlowGraph<S>): string[];
91
- static build<S extends string>(def: FlowDefinition<S>, initiallyAvailable: string[]): DataFlowGraph<S>;
91
+ static build<S extends string>(def: FlowDefinition<S>, initiallyAvailable: string[], externallyProvided?: string[]): DataFlowGraph<S>;
92
92
  }
@@ -367,14 +367,15 @@ export class DataFlowGraph {
367
367
  return issues;
368
368
  }
369
369
  // ─── Builder ─────────────────────────────────────────────
370
- static build(def, initiallyAvailable) {
370
+ static build(def, initiallyAvailable, externallyProvided = []) {
371
371
  const stateAvail = new Map();
372
372
  const producers = new Map();
373
373
  const consumers = new Map();
374
374
  const allProduced = new Set(initiallyAvailable);
375
375
  const allConsumed = new Set();
376
+ const extSet = new Set(externallyProvided);
376
377
  if (def.initialState) {
377
- traverse(def, def.initialState, new Set(initiallyAvailable), stateAvail, producers, consumers, allProduced, allConsumed);
378
+ traverse(def, def.initialState, new Set(initiallyAvailable), extSet, stateAvail, producers, consumers, allProduced, allConsumed);
378
379
  // Mark initially available types as produced by "initial"
379
380
  for (const key of initiallyAvailable) {
380
381
  if (!producers.has(key))
@@ -387,7 +388,7 @@ export class DataFlowGraph {
387
388
  return new DataFlowGraph(stateAvail, producers, consumers, allProduced, allConsumed);
388
389
  }
389
390
  }
390
- function traverse(def, state, available, stateAvail, producers, consumers, allProduced, allConsumed) {
391
+ function traverse(def, state, available, externallyProvided, stateAvail, producers, consumers, allProduced, allConsumed) {
391
392
  if (stateAvail.has(state)) {
392
393
  const existing = stateAvail.get(state);
393
394
  let isSubset = true;
@@ -409,6 +410,10 @@ function traverse(def, state, available, stateAvail, producers, consumers, allPr
409
410
  }
410
411
  for (const t of def.transitionsFrom(state)) {
411
412
  const newAvail = new Set(stateAvail.get(state));
413
+ if (t.type === 'external') {
414
+ for (const k of externallyProvided)
415
+ newAvail.add(k);
416
+ }
412
417
  if (t.guard) {
413
418
  for (const req of t.guard.requires) {
414
419
  addTo(consumers, req, { name: t.guard.name, fromState: t.from, toState: t.to, kind: 'guard' });
@@ -437,7 +442,7 @@ function traverse(def, state, available, stateAvail, producers, consumers, allPr
437
442
  newAvail.add(prod);
438
443
  }
439
444
  }
440
- traverse(def, t.to, newAvail, stateAvail, producers, consumers, allProduced, allConsumed);
445
+ traverse(def, t.to, newAvail, externallyProvided, stateAvail, producers, consumers, allProduced, allConsumed);
441
446
  }
442
447
  }
443
448
  function addTo(map, key, info) {
@@ -45,9 +45,12 @@ export declare class Builder<S extends string> {
45
45
  private readonly _enterActions;
46
46
  private readonly _exitActions;
47
47
  private readonly initiallyAvailableKeys;
48
+ private readonly externallyProvidedKeys;
48
49
  private _perpetual;
49
50
  constructor(name: string, stateConfig: Record<S, StateConfig>);
50
51
  initiallyAvailable(...keys: FlowKey<unknown>[]): this;
52
+ /** Declare data keys injected via resumeAndExecute(externalData), not available at start. */
53
+ externallyProvided(...keys: FlowKey<unknown>[]): this;
51
54
  setTtl(ms: number): this;
52
55
  setMaxGuardRetries(max: number): this;
53
56
  from(state: S): FromBuilder<S>;
@@ -109,6 +109,7 @@ export class Builder {
109
109
  _enterActions = new Map();
110
110
  _exitActions = new Map();
111
111
  initiallyAvailableKeys = [];
112
+ externallyProvidedKeys = [];
112
113
  _perpetual = false;
113
114
  constructor(name, stateConfig) {
114
115
  this.name = name;
@@ -119,6 +120,12 @@ export class Builder {
119
120
  this.initiallyAvailableKeys.push(k);
120
121
  return this;
121
122
  }
123
+ /** Declare data keys injected via resumeAndExecute(externalData), not available at start. */
124
+ externallyProvided(...keys) {
125
+ for (const k of keys)
126
+ this.externallyProvidedKeys.push(k);
127
+ return this;
128
+ }
122
129
  setTtl(ms) { this.ttl = ms; return this; }
123
130
  setMaxGuardRetries(max) { this.maxGuardRetries = max; return this; }
124
131
  from(state) {
@@ -181,7 +188,7 @@ export class Builder {
181
188
  result.terminalStates = terminals;
182
189
  result.dataFlowGraph = null;
183
190
  this.validate(result);
184
- result.dataFlowGraph = DataFlowGraph.build(result, this.initiallyAvailableKeys);
191
+ result.dataFlowGraph = DataFlowGraph.build(result, this.initiallyAvailableKeys, this.externallyProvidedKeys);
185
192
  // Build warnings
186
193
  const warnings = [];
187
194
  const perpetual = terminals.size === 0;
@@ -350,6 +357,10 @@ export class Builder {
350
357
  }
351
358
  for (const t of def.transitionsFrom(state)) {
352
359
  const newAvailable = new Set(stateAvailable.get(state));
360
+ if (t.type === 'external') {
361
+ for (const k of this.externallyProvidedKeys)
362
+ newAvailable.add(k);
363
+ }
353
364
  if (t.guard) {
354
365
  for (const req of t.guard.requires) {
355
366
  if (!newAvailable.has(req))
@@ -527,6 +538,7 @@ export class BranchBuilder {
527
538
  processor: this.processors.get(label),
528
539
  guard: undefined, branch: this.branch,
529
540
  branchTargets: new Map(this.targets),
541
+ branchLabel: label,
530
542
  });
531
543
  }
532
544
  return this.builder;
@@ -10,6 +10,7 @@ export interface TransitionLogEntry {
10
10
  from: string | null;
11
11
  to: string;
12
12
  trigger: string;
13
+ durationMicros: number;
13
14
  }
14
15
  export interface StateLogEntry {
15
16
  flowId: string;
@@ -25,6 +26,7 @@ export interface ErrorLogEntry {
25
26
  to: string | null;
26
27
  trigger: string;
27
28
  cause: Error | null;
29
+ durationMicros: number;
28
30
  }
29
31
  export interface GuardLogEntry {
30
32
  flowId: string;
@@ -33,6 +35,7 @@ export interface GuardLogEntry {
33
35
  guardName: string;
34
36
  result: 'accepted' | 'rejected' | 'expired';
35
37
  reason?: string;
38
+ durationMicros: number;
36
39
  }
37
40
  export declare class FlowEngine {
38
41
  private readonly store;
@@ -94,10 +94,13 @@ export class FlowEngine {
94
94
  }
95
95
  const guard = transition.guard;
96
96
  if (guard) {
97
+ const guardStart = this.guardLogger ? performance.now() : 0;
97
98
  const output = await guard.validate(flow.context);
99
+ const guardDurationMicros = this.guardLogger ? Math.round((performance.now() - guardStart) * 1000) : 0;
98
100
  switch (output.type) {
99
101
  case 'accepted': {
100
- this.logGuard(flow, currentState, guard.name, 'accepted');
102
+ this.logGuard(flow, currentState, guard.name, 'accepted', guardDurationMicros);
103
+ const transStart = this.transitionLogger ? performance.now() : 0;
101
104
  const backup = flow.context.snapshot();
102
105
  if (output.data) {
103
106
  for (const [key, value] of output.data)
@@ -111,7 +114,7 @@ export class FlowEngine {
111
114
  flow.transitionTo(transition.to);
112
115
  this.fireEnter(flow, transition.to);
113
116
  this.store.recordTransition(flow.id, from, transition.to, guard.name, flow.context);
114
- this.logTransition(flow, from, transition.to, guard.name);
117
+ this.logTransition(flow, from, transition.to, guard.name, transStart);
115
118
  }
116
119
  catch (e) {
117
120
  flow.context.restoreFrom(backup);
@@ -122,7 +125,7 @@ export class FlowEngine {
122
125
  break;
123
126
  }
124
127
  case 'rejected': {
125
- this.logGuard(flow, currentState, guard.name, 'rejected', output.reason);
128
+ this.logGuard(flow, currentState, guard.name, 'rejected', guardDurationMicros, output.reason);
126
129
  flow.incrementGuardFailure(guard.name);
127
130
  if (flow.guardFailureCount >= definition.maxGuardRetries) {
128
131
  this.handleError(flow, currentState);
@@ -131,7 +134,7 @@ export class FlowEngine {
131
134
  return flow;
132
135
  }
133
136
  case 'expired': {
134
- this.logGuard(flow, currentState, guard.name, 'expired');
137
+ this.logGuard(flow, currentState, guard.name, 'expired', guardDurationMicros);
135
138
  flow.complete('EXPIRED');
136
139
  this.store.save(flow);
137
140
  return flow;
@@ -139,12 +142,13 @@ export class FlowEngine {
139
142
  }
140
143
  }
141
144
  else {
145
+ const transStart = this.transitionLogger ? performance.now() : 0;
142
146
  const from = flow.currentState;
143
147
  this.fireExit(flow, from);
144
148
  flow.transitionTo(transition.to);
145
149
  this.fireEnter(flow, transition.to);
146
150
  this.store.recordTransition(flow.id, from, transition.to, 'external', flow.context);
147
- this.logTransition(flow, from, transition.to, 'external');
151
+ this.logTransition(flow, from, transition.to, 'external', transStart);
148
152
  }
149
153
  await this.executeAutoChain(flow);
150
154
  this.store.save(flow);
@@ -172,6 +176,7 @@ export class FlowEngine {
172
176
  if (!autoOrBranch)
173
177
  break;
174
178
  const backup = flow.context.snapshot();
179
+ const stepStart = this.transitionLogger ? performance.now() : 0;
175
180
  try {
176
181
  if (autoOrBranch.type === 'auto') {
177
182
  if (autoOrBranch.processor) {
@@ -184,7 +189,7 @@ export class FlowEngine {
184
189
  this.fireEnter(flow, autoOrBranch.to);
185
190
  const trigger = autoOrBranch.processor?.name ?? 'auto';
186
191
  this.store.recordTransition(flow.id, from, autoOrBranch.to, trigger, flow.context);
187
- this.logTransition(flow, from, autoOrBranch.to, trigger);
192
+ this.logTransition(flow, from, autoOrBranch.to, trigger, stepStart);
188
193
  }
189
194
  else {
190
195
  const branch = autoOrBranch.branch;
@@ -193,7 +198,7 @@ export class FlowEngine {
193
198
  if (!target) {
194
199
  throw new FlowError('UNKNOWN_BRANCH', `Branch '${branch.name}' returned unknown label: ${label}`);
195
200
  }
196
- const specific = transitions.find(t => t.type === 'branch' && t.to === target) ?? autoOrBranch;
201
+ const specific = transitions.find(t => t.type === 'branch' && t.branchLabel === label) ?? transitions.find(t => t.type === 'branch' && t.to === target) ?? autoOrBranch;
197
202
  if (specific.processor)
198
203
  await specific.processor.process(flow.context);
199
204
  const from = flow.currentState;
@@ -202,7 +207,7 @@ export class FlowEngine {
202
207
  this.fireEnter(flow, target);
203
208
  const trigger = `${branch.name}:${label}`;
204
209
  this.store.recordTransition(flow.id, from, target, trigger, flow.context);
205
- this.logTransition(flow, from, target, trigger);
210
+ this.logTransition(flow, from, target, trigger, stepStart);
206
211
  }
207
212
  }
208
213
  catch (e) {
@@ -226,13 +231,14 @@ export class FlowEngine {
226
231
  parentFlow.setActiveSubFlow(null);
227
232
  const target = exitMappings.get(subFlow.exitState);
228
233
  if (target) {
234
+ const sfStart = this.transitionLogger ? performance.now() : 0;
229
235
  const from = parentFlow.currentState;
230
236
  this.fireExit(parentFlow, from);
231
237
  parentFlow.transitionTo(target);
232
238
  this.fireEnter(parentFlow, target);
233
239
  const trigger = `subFlow:${subDef.name}/${subFlow.exitState}`;
234
240
  this.store.recordTransition(parentFlow.id, from, target, trigger, parentFlow.context);
235
- this.logTransition(parentFlow, from, target, trigger);
241
+ this.logTransition(parentFlow, from, target, trigger, sfStart);
236
242
  return 1;
237
243
  }
238
244
  // Error bubbling: no exit mapping → fall back to parent's error transitions
@@ -250,17 +256,20 @@ export class FlowEngine {
250
256
  }
251
257
  const guard = transition.guard;
252
258
  if (guard) {
259
+ const guardStart = this.guardLogger ? performance.now() : 0;
253
260
  const output = await guard.validate(parentFlow.context);
261
+ const guardDur = this.guardLogger ? Math.round((performance.now() - guardStart) * 1000) : 0;
254
262
  if (output.type === 'accepted') {
255
263
  if (output.data) {
256
264
  for (const [key, value] of output.data)
257
265
  parentFlow.context.put(key, value);
258
266
  }
267
+ const sfStart = this.transitionLogger ? performance.now() : 0;
259
268
  const sfFrom = subFlow.currentState;
260
269
  subFlow.transitionTo(transition.to);
261
270
  this.store.recordTransition(parentFlow.id, sfFrom, transition.to, guard.name, parentFlow.context);
262
- this.logTransition(parentFlow, sfFrom, transition.to, guard.name);
263
- this.logGuard(parentFlow, sfFrom, guard.name, 'accepted');
271
+ this.logTransition(parentFlow, sfFrom, transition.to, guard.name, sfStart);
272
+ this.logGuard(parentFlow, sfFrom, guard.name, 'accepted', guardDur);
264
273
  }
265
274
  else if (output.type === 'rejected') {
266
275
  subFlow.incrementGuardFailure();
@@ -287,13 +296,14 @@ export class FlowEngine {
287
296
  if (subFlowT?.exitMappings) {
288
297
  const target = subFlowT.exitMappings.get(subFlow.exitState);
289
298
  if (target) {
299
+ const exitStart = this.transitionLogger ? performance.now() : 0;
290
300
  const from = parentFlow.currentState;
291
301
  this.fireExit(parentFlow, from);
292
302
  parentFlow.transitionTo(target);
293
303
  this.fireEnter(parentFlow, target);
294
304
  const trigger = `subFlow:${subDef.name}/${subFlow.exitState}`;
295
305
  this.store.recordTransition(parentFlow.id, from, target, trigger, parentFlow.context);
296
- this.logTransition(parentFlow, from, target, trigger);
306
+ this.logTransition(parentFlow, from, target, trigger, exitStart);
297
307
  await this.executeAutoChain(parentFlow);
298
308
  }
299
309
  }
@@ -320,16 +330,23 @@ export class FlowEngine {
320
330
  if (action)
321
331
  action(flow.context);
322
332
  }
323
- logTransition(flow, from, to, trigger) {
324
- this.transitionLogger?.({ flowId: flow.id, flowName: flow.definition.name, from, to, trigger });
333
+ logTransition(flow, from, to, trigger, startMs) {
334
+ if (this.transitionLogger) {
335
+ const durationMicros = Math.round((performance.now() - startMs) * 1000);
336
+ this.transitionLogger({ flowId: flow.id, flowName: flow.definition.name, from, to, trigger, durationMicros });
337
+ }
325
338
  }
326
- logError(flow, from, to, trigger, cause) {
327
- this.errorLogger?.({ flowId: flow.id, flowName: flow.definition.name, from, to, trigger, cause });
339
+ logError(flow, from, to, trigger, cause, startMs) {
340
+ if (this.errorLogger) {
341
+ const durationMicros = Math.round((performance.now() - startMs) * 1000);
342
+ this.errorLogger({ flowId: flow.id, flowName: flow.definition.name, from, to, trigger, cause, durationMicros });
343
+ }
328
344
  }
329
- logGuard(flow, state, guardName, result, reason) {
330
- this.guardLogger?.({ flowId: flow.id, flowName: flow.definition.name, state, guardName, result, reason });
345
+ logGuard(flow, state, guardName, result, durationMicros, reason) {
346
+ this.guardLogger?.({ flowId: flow.id, flowName: flow.definition.name, state, guardName, result, reason, durationMicros });
331
347
  }
332
348
  handleError(flow, fromState, cause) {
349
+ const errorStart = (this.transitionLogger || this.errorLogger) ? performance.now() : 0;
333
350
  if (cause) {
334
351
  flow.setLastError(`${cause.constructor.name}: ${cause.message}`);
335
352
  if (cause instanceof FlowError) {
@@ -339,7 +356,7 @@ export class FlowEngine {
339
356
  cause.withContextSnapshot(available, new Set());
340
357
  }
341
358
  }
342
- this.logError(flow, fromState, null, 'error', cause ?? null);
359
+ this.logError(flow, fromState, null, 'error', cause ?? null, errorStart);
343
360
  // 1. Try exception-typed routes first (onStepError)
344
361
  if (cause && flow.definition.exceptionRoutes) {
345
362
  const routes = flow.definition.exceptionRoutes.get(fromState);
@@ -350,7 +367,7 @@ export class FlowEngine {
350
367
  flow.transitionTo(route.target);
351
368
  const trigger = `error:${cause.constructor.name}`;
352
369
  this.store.recordTransition(flow.id, from, route.target, trigger, flow.context);
353
- this.logTransition(flow, from, route.target, trigger);
370
+ this.logTransition(flow, from, route.target, trigger, errorStart);
354
371
  if (flow.definition.stateConfig[route.target]?.terminal)
355
372
  flow.complete(route.target);
356
373
  return;
@@ -364,7 +381,7 @@ export class FlowEngine {
364
381
  const from = flow.currentState;
365
382
  flow.transitionTo(errorTarget);
366
383
  this.store.recordTransition(flow.id, from, errorTarget, 'error', flow.context);
367
- this.logTransition(flow, from, errorTarget, 'error');
384
+ this.logTransition(flow, from, errorTarget, 'error', errorStart);
368
385
  if (flow.definition.stateConfig[errorTarget]?.terminal)
369
386
  flow.complete(errorTarget);
370
387
  }
@@ -87,14 +87,16 @@ export class Pipeline {
87
87
  const completed = [];
88
88
  let prev = 'initial';
89
89
  for (const step of this.steps) {
90
- this.transitionLogger?.({ flowId, flowName: this.name, from: prev, to: step.name, trigger: step.name });
90
+ const stepStart = (this.transitionLogger || this.errorLogger) ? performance.now() : 0;
91
+ this.transitionLogger?.({ flowId, flowName: this.name, from: prev, to: step.name, trigger: step.name, durationMicros: 0 });
91
92
  const keysBefore = this.stateLogger ? new Set(ctx.snapshot().keys()) : null;
92
93
  try {
93
94
  await step.process(ctx);
94
95
  }
95
96
  catch (e) {
96
97
  const err = e instanceof Error ? e : new Error(String(e));
97
- this.errorLogger?.({ flowId, flowName: this.name, from: prev, to: step.name, trigger: step.name, cause: err });
98
+ const durationMicros = Math.round((performance.now() - stepStart) * 1000);
99
+ this.errorLogger?.({ flowId, flowName: this.name, from: prev, to: step.name, trigger: step.name, cause: err, durationMicros });
98
100
  throw new PipelineException(step.name, [...completed], ctx, err);
99
101
  }
100
102
  if (this.strictMode) {
@@ -26,6 +26,8 @@ export interface Transition<S extends string> {
26
26
  guard?: TransitionGuard<S>;
27
27
  branch?: BranchProcessor<S>;
28
28
  branchTargets: Map<string, S>;
29
+ /** Label assigned by builder .to(target, label, processor). Used for branch label-specific processor matching. */
30
+ branchLabel?: string;
29
31
  subFlowDefinition?: import('./flow-definition.js').FlowDefinition<any>;
30
32
  exitMappings?: Map<string, S>;
31
33
  /** Per-state timeout in milliseconds. If set, resumeAndExecute checks this before guard. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unlaxer/tramli",
3
- "version": "3.2.0",
3
+ "version": "3.4.0",
4
4
  "description": "Constrained flow engine — state machines that prevent invalid transitions at build time",
5
5
  "type": "module",
6
6
  "exports": {