@unlaxer/tramli 3.1.0 → 3.3.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.
@@ -9,6 +9,8 @@ export declare class FlowContext {
9
9
  readonly flowId: string;
10
10
  readonly createdAt: Date;
11
11
  private attributes;
12
+ private aliasToKey;
13
+ private keyToAlias;
12
14
  constructor(flowId: string, createdAt?: Date, attributes?: Map<string, unknown>);
13
15
  get<T>(key: FlowKey<T>): T;
14
16
  find<T>(key: FlowKey<T>): T | undefined;
@@ -16,4 +18,14 @@ export declare class FlowContext {
16
18
  has(key: FlowKey<unknown>): boolean;
17
19
  snapshot(): Map<string, unknown>;
18
20
  restoreFrom(snapshot: Map<string, unknown>): void;
21
+ /** Register a string alias for a FlowKey. Used for cross-language serialization. */
22
+ registerAlias(key: FlowKey<unknown>, alias: string): void;
23
+ /** Get the alias for a key (if registered). */
24
+ aliasOf(key: FlowKey<unknown>): string | undefined;
25
+ /** Get the key for an alias (if registered). */
26
+ keyOfAlias(alias: string): FlowKey<unknown> | undefined;
27
+ /** Export all registered aliases as a map (alias → key). */
28
+ toAliasMap(): Map<string, string>;
29
+ /** Import aliases from a map (alias → key). */
30
+ fromAliasMap(map: Map<string, string>): void;
19
31
  }
@@ -12,6 +12,8 @@ class FlowContext {
12
12
  flowId;
13
13
  createdAt;
14
14
  attributes;
15
+ aliasToKey = new Map();
16
+ keyToAlias = new Map();
15
17
  constructor(flowId, createdAt, attributes) {
16
18
  this.flowId = flowId;
17
19
  this.createdAt = createdAt ?? new Date();
@@ -40,5 +42,30 @@ class FlowContext {
40
42
  for (const [k, v] of snapshot)
41
43
  this.attributes.set(k, v);
42
44
  }
45
+ // ─── Alias support (for cross-language serialization) ──────────────────
46
+ /** Register a string alias for a FlowKey. Used for cross-language serialization. */
47
+ registerAlias(key, alias) {
48
+ this.aliasToKey.set(alias, key);
49
+ this.keyToAlias.set(key, alias);
50
+ }
51
+ /** Get the alias for a key (if registered). */
52
+ aliasOf(key) {
53
+ return this.keyToAlias.get(key);
54
+ }
55
+ /** Get the key for an alias (if registered). */
56
+ keyOfAlias(alias) {
57
+ return this.aliasToKey.get(alias);
58
+ }
59
+ /** Export all registered aliases as a map (alias → key). */
60
+ toAliasMap() {
61
+ return new Map(this.aliasToKey);
62
+ }
63
+ /** Import aliases from a map (alias → key). */
64
+ fromAliasMap(map) {
65
+ for (const [alias, key] of map) {
66
+ this.aliasToKey.set(alias, key);
67
+ this.keyToAlias.set(key, alias);
68
+ }
69
+ }
43
70
  }
44
71
  exports.FlowContext = FlowContext;
@@ -16,9 +16,17 @@ export declare class FlowDefinition<S extends string> {
16
16
  errorClass: new (...args: any[]) => Error;
17
17
  target: S;
18
18
  }>>;
19
+ readonly enterActions: Map<S, (ctx: import('./flow-context.js').FlowContext) => void>;
20
+ readonly exitActions: Map<S, (ctx: import('./flow-context.js').FlowContext) => void>;
21
+ /** Get enter action for a state (or undefined). */
22
+ enterAction(state: S): ((ctx: import('./flow-context.js').FlowContext) => void) | undefined;
23
+ /** Get exit action for a state (or undefined). */
24
+ exitAction(state: S): ((ctx: import('./flow-context.js').FlowContext) => void) | undefined;
19
25
  private constructor();
20
26
  transitionsFrom(state: S): Transition<S>[];
21
27
  externalFrom(state: S): Transition<S> | undefined;
28
+ /** All external transitions from a state (for multi-external). */
29
+ externalsFrom(state: S): Transition<S>[];
22
30
  allStates(): S[];
23
31
  /**
24
32
  * Create a new FlowDefinition with a sub-flow inserted before a specific transition.
@@ -34,7 +42,10 @@ export declare class Builder<S extends string> {
34
42
  private readonly transitions;
35
43
  private readonly errorTransitions;
36
44
  private readonly _exceptionRoutes;
45
+ private readonly _enterActions;
46
+ private readonly _exitActions;
37
47
  private readonly initiallyAvailableKeys;
48
+ private _perpetual;
38
49
  constructor(name: string, stateConfig: Record<S, StateConfig>);
39
50
  initiallyAvailable(...keys: FlowKey<unknown>[]): this;
40
51
  setTtl(ms: number): this;
@@ -44,6 +55,12 @@ export declare class Builder<S extends string> {
44
55
  /** Route specific error types to specific states. Checked before onError. */
45
56
  onStepError(from: S, errorClass: new (...args: any[]) => Error, to: S): this;
46
57
  onAnyError(errorState: S): this;
58
+ /** Callback when entering a state (pure data/metrics, no I/O). */
59
+ onStateEnter(state: S, action: (ctx: import('./flow-context.js').FlowContext) => void): this;
60
+ /** Callback when exiting a state (pure data/metrics, no I/O). */
61
+ onStateExit(state: S, action: (ctx: import('./flow-context.js').FlowContext) => void): this;
62
+ /** Allow perpetual flows (no terminal states). Skips path-to-terminal validation. */
63
+ allowPerpetual(): this;
47
64
  /** @internal */
48
65
  addTransition(t: Transition<S>): void;
49
66
  build(): FlowDefinition<S>;
@@ -53,7 +70,6 @@ export declare class Builder<S extends string> {
53
70
  private canReachTerminal;
54
71
  private checkDag;
55
72
  private hasCycle;
56
- private checkExternalUniqueness;
57
73
  private checkBranchCompleteness;
58
74
  private checkRequiresProduces;
59
75
  private checkRequiresProducesFrom;
@@ -15,6 +15,16 @@ class FlowDefinition {
15
15
  dataFlowGraph;
16
16
  warnings;
17
17
  exceptionRoutes;
18
+ enterActions;
19
+ exitActions;
20
+ /** Get enter action for a state (or undefined). */
21
+ enterAction(state) {
22
+ return this.enterActions?.get(state);
23
+ }
24
+ /** Get exit action for a state (or undefined). */
25
+ exitAction(state) {
26
+ return this.exitActions?.get(state);
27
+ }
18
28
  constructor(name, stateConfig, ttl, maxGuardRetries, transitions, errorTransitions) {
19
29
  this.name = name;
20
30
  this.stateConfig = stateConfig;
@@ -39,6 +49,10 @@ class FlowDefinition {
39
49
  externalFrom(state) {
40
50
  return this.transitions.find(t => t.from === state && t.type === 'external');
41
51
  }
52
+ /** All external transitions from a state (for multi-external). */
53
+ externalsFrom(state) {
54
+ return this.transitions.filter(t => t.from === state && t.type === 'external');
55
+ }
42
56
  allStates() {
43
57
  return Object.keys(this.stateConfig);
44
58
  }
@@ -75,6 +89,10 @@ class FlowDefinition {
75
89
  initialState: this.initialState,
76
90
  terminalStates: this.terminalStates,
77
91
  dataFlowGraph: this.dataFlowGraph, // reuse parent's graph
92
+ warnings: this.warnings,
93
+ exceptionRoutes: this.exceptionRoutes ? new Map(this.exceptionRoutes) : new Map(),
94
+ enterActions: this.enterActions ? new Map(this.enterActions) : new Map(),
95
+ exitActions: this.exitActions ? new Map(this.exitActions) : new Map(),
78
96
  });
79
97
  return result;
80
98
  }
@@ -92,7 +110,10 @@ class Builder {
92
110
  transitions = [];
93
111
  errorTransitions = new Map();
94
112
  _exceptionRoutes = new Map();
113
+ _enterActions = new Map();
114
+ _exitActions = new Map();
95
115
  initiallyAvailableKeys = [];
116
+ _perpetual = false;
96
117
  constructor(name, stateConfig) {
97
118
  this.name = name;
98
119
  this.stateConfig = stateConfig;
@@ -125,6 +146,18 @@ class Builder {
125
146
  }
126
147
  return this;
127
148
  }
149
+ /** Callback when entering a state (pure data/metrics, no I/O). */
150
+ onStateEnter(state, action) {
151
+ this._enterActions.set(state, action);
152
+ return this;
153
+ }
154
+ /** Callback when exiting a state (pure data/metrics, no I/O). */
155
+ onStateExit(state, action) {
156
+ this._exitActions.set(state, action);
157
+ return this;
158
+ }
159
+ /** Allow perpetual flows (no terminal states). Skips path-to-terminal validation. */
160
+ allowPerpetual() { this._perpetual = true; return this; }
128
161
  /** @internal */
129
162
  addTransition(t) { this.transitions.push(t); }
130
163
  build() {
@@ -162,6 +195,8 @@ class Builder {
162
195
  }
163
196
  result.warnings = warnings;
164
197
  result.exceptionRoutes = new Map(this._exceptionRoutes);
198
+ result.enterActions = new Map(this._enterActions);
199
+ result.exitActions = new Map(this._exitActions);
165
200
  return result;
166
201
  }
167
202
  validate(def) {
@@ -170,9 +205,10 @@ class Builder {
170
205
  errors.push('No initial state found (exactly one state must have initial=true)');
171
206
  }
172
207
  this.checkReachability(def, errors);
173
- this.checkPathToTerminal(def, errors);
208
+ if (!this._perpetual)
209
+ this.checkPathToTerminal(def, errors);
174
210
  this.checkDag(def, errors);
175
- this.checkExternalUniqueness(def, errors);
211
+ // checkExternalUniqueness removed (DD-020: multi-external allowed)
176
212
  this.checkBranchCompleteness(def, errors);
177
213
  this.checkRequiresProduces(def, errors);
178
214
  this.checkAutoExternalConflict(def, errors);
@@ -277,17 +313,6 @@ class Builder {
277
313
  inStack.delete(node);
278
314
  return false;
279
315
  }
280
- checkExternalUniqueness(def, errors) {
281
- const counts = new Map();
282
- for (const t of def.transitions) {
283
- if (t.type === 'external')
284
- counts.set(t.from, (counts.get(t.from) ?? 0) + 1);
285
- }
286
- for (const [state, count] of counts) {
287
- if (count > 1)
288
- errors.push(`State ${state} has ${count} external transitions (max 1)`);
289
- }
290
- }
291
316
  checkBranchCompleteness(def, errors) {
292
317
  const allStates = new Set(def.allStates());
293
318
  for (const t of def.transitions) {
@@ -6,22 +6,36 @@ export declare const DEFAULT_MAX_CHAIN_DEPTH = 10;
6
6
  /** Log entry types for tramli's pluggable logger API. */
7
7
  export interface TransitionLogEntry {
8
8
  flowId: string;
9
+ flowName: string;
9
10
  from: string | null;
10
11
  to: string;
11
12
  trigger: string;
13
+ durationMicros: number;
12
14
  }
13
15
  export interface StateLogEntry {
14
16
  flowId: string;
17
+ flowName: string;
15
18
  state: string;
16
19
  key: string;
17
20
  value: unknown;
18
21
  }
19
22
  export interface ErrorLogEntry {
20
23
  flowId: string;
24
+ flowName: string;
21
25
  from: string | null;
22
26
  to: string | null;
23
27
  trigger: string;
24
28
  cause: Error | null;
29
+ durationMicros: number;
30
+ }
31
+ export interface GuardLogEntry {
32
+ flowId: string;
33
+ flowName: string;
34
+ state: string;
35
+ guardName: string;
36
+ result: 'accepted' | 'rejected' | 'expired';
37
+ reason?: string;
38
+ durationMicros: number;
25
39
  }
26
40
  export declare class FlowEngine {
27
41
  private readonly store;
@@ -30,6 +44,7 @@ export declare class FlowEngine {
30
44
  private transitionLogger?;
31
45
  private stateLogger?;
32
46
  private errorLogger?;
47
+ private guardLogger?;
33
48
  constructor(store: InMemoryFlowStore, options?: {
34
49
  strictMode?: boolean;
35
50
  maxChainDepth?: number;
@@ -37,6 +52,7 @@ export declare class FlowEngine {
37
52
  setTransitionLogger(logger: ((entry: TransitionLogEntry) => void) | null): void;
38
53
  setStateLogger(logger: ((entry: StateLogEntry) => void) | null): void;
39
54
  setErrorLogger(logger: ((entry: ErrorLogEntry) => void) | null): void;
55
+ setGuardLogger(logger: ((entry: GuardLogEntry) => void) | null): void;
40
56
  removeAllLoggers(): void;
41
57
  startFlow<S extends string>(definition: FlowDefinition<S>, sessionId: string, initialData: Map<string, unknown>): Promise<FlowInstance<S>>;
42
58
  resumeAndExecute<S extends string>(flowId: string, definition: FlowDefinition<S>, externalData?: Map<string, unknown>): Promise<FlowInstance<S>>;
@@ -44,5 +60,10 @@ export declare class FlowEngine {
44
60
  private executeSubFlow;
45
61
  private resumeSubFlow;
46
62
  private verifyProduces;
63
+ private fireEnter;
64
+ private fireExit;
65
+ private logTransition;
66
+ private logError;
67
+ private logGuard;
47
68
  private handleError;
48
69
  }
@@ -13,6 +13,7 @@ class FlowEngine {
13
13
  transitionLogger;
14
14
  stateLogger;
15
15
  errorLogger;
16
+ guardLogger;
16
17
  constructor(store, options) {
17
18
  this.store = store;
18
19
  this.strictMode = options?.strictMode ?? false;
@@ -27,10 +28,14 @@ class FlowEngine {
27
28
  setErrorLogger(logger) {
28
29
  this.errorLogger = logger ?? undefined;
29
30
  }
31
+ setGuardLogger(logger) {
32
+ this.guardLogger = logger ?? undefined;
33
+ }
30
34
  removeAllLoggers() {
31
35
  this.transitionLogger = undefined;
32
36
  this.stateLogger = undefined;
33
37
  this.errorLogger = undefined;
38
+ this.guardLogger = undefined;
34
39
  }
35
40
  async startFlow(definition, sessionId, initialData) {
36
41
  const flowId = crypto.randomUUID();
@@ -65,9 +70,22 @@ class FlowEngine {
65
70
  return this.resumeSubFlow(flow, definition);
66
71
  }
67
72
  const currentState = flow.currentState;
68
- const transition = definition.externalFrom(currentState);
69
- if (!transition)
73
+ // Multi-external: select guard by requires matching
74
+ const externals = definition.externalsFrom(currentState);
75
+ if (externals.length === 0)
70
76
  throw flow_error_js_1.FlowError.invalidTransition(currentState, currentState);
77
+ let transition;
78
+ const dataKeys = externalData ? new Set(externalData.keys()) : new Set();
79
+ for (const ext of externals) {
80
+ if (ext.guard && ext.guard.requires.every(r => dataKeys.has(r))) {
81
+ transition = ext;
82
+ break;
83
+ }
84
+ }
85
+ if (!transition) {
86
+ // Fallback: first external
87
+ transition = externals[0];
88
+ }
71
89
  // Per-state timeout check
72
90
  if (transition.timeout != null) {
73
91
  const deadline = new Date(flow.stateEnteredAt.getTime() + transition.timeout);
@@ -79,9 +97,13 @@ class FlowEngine {
79
97
  }
80
98
  const guard = transition.guard;
81
99
  if (guard) {
100
+ const guardStart = this.guardLogger ? performance.now() : 0;
82
101
  const output = await guard.validate(flow.context);
102
+ const guardDurationMicros = this.guardLogger ? Math.round((performance.now() - guardStart) * 1000) : 0;
83
103
  switch (output.type) {
84
104
  case 'accepted': {
105
+ this.logGuard(flow, currentState, guard.name, 'accepted', guardDurationMicros);
106
+ const transStart = this.transitionLogger ? performance.now() : 0;
85
107
  const backup = flow.context.snapshot();
86
108
  if (output.data) {
87
109
  for (const [key, value] of output.data)
@@ -91,8 +113,11 @@ class FlowEngine {
91
113
  if (transition.processor)
92
114
  await transition.processor.process(flow.context);
93
115
  const from = flow.currentState;
116
+ this.fireExit(flow, from);
94
117
  flow.transitionTo(transition.to);
118
+ this.fireEnter(flow, transition.to);
95
119
  this.store.recordTransition(flow.id, from, transition.to, guard.name, flow.context);
120
+ this.logTransition(flow, from, transition.to, guard.name, transStart);
96
121
  }
97
122
  catch (e) {
98
123
  flow.context.restoreFrom(backup);
@@ -103,7 +128,8 @@ class FlowEngine {
103
128
  break;
104
129
  }
105
130
  case 'rejected': {
106
- flow.incrementGuardFailure();
131
+ this.logGuard(flow, currentState, guard.name, 'rejected', guardDurationMicros, output.reason);
132
+ flow.incrementGuardFailure(guard.name);
107
133
  if (flow.guardFailureCount >= definition.maxGuardRetries) {
108
134
  this.handleError(flow, currentState);
109
135
  }
@@ -111,6 +137,7 @@ class FlowEngine {
111
137
  return flow;
112
138
  }
113
139
  case 'expired': {
140
+ this.logGuard(flow, currentState, guard.name, 'expired', guardDurationMicros);
114
141
  flow.complete('EXPIRED');
115
142
  this.store.save(flow);
116
143
  return flow;
@@ -118,9 +145,13 @@ class FlowEngine {
118
145
  }
119
146
  }
120
147
  else {
148
+ const transStart = this.transitionLogger ? performance.now() : 0;
121
149
  const from = flow.currentState;
150
+ this.fireExit(flow, from);
122
151
  flow.transitionTo(transition.to);
152
+ this.fireEnter(flow, transition.to);
123
153
  this.store.recordTransition(flow.id, from, transition.to, 'external', flow.context);
154
+ this.logTransition(flow, from, transition.to, 'external', transStart);
124
155
  }
125
156
  await this.executeAutoChain(flow);
126
157
  this.store.save(flow);
@@ -148,6 +179,7 @@ class FlowEngine {
148
179
  if (!autoOrBranch)
149
180
  break;
150
181
  const backup = flow.context.snapshot();
182
+ const stepStart = this.transitionLogger ? performance.now() : 0;
151
183
  try {
152
184
  if (autoOrBranch.type === 'auto') {
153
185
  if (autoOrBranch.processor) {
@@ -155,8 +187,12 @@ class FlowEngine {
155
187
  this.verifyProduces(autoOrBranch.processor, flow.context);
156
188
  }
157
189
  const from = flow.currentState;
190
+ this.fireExit(flow, from);
158
191
  flow.transitionTo(autoOrBranch.to);
159
- this.store.recordTransition(flow.id, from, autoOrBranch.to, autoOrBranch.processor?.name ?? 'auto', flow.context);
192
+ this.fireEnter(flow, autoOrBranch.to);
193
+ const trigger = autoOrBranch.processor?.name ?? 'auto';
194
+ this.store.recordTransition(flow.id, from, autoOrBranch.to, trigger, flow.context);
195
+ this.logTransition(flow, from, autoOrBranch.to, trigger, stepStart);
160
196
  }
161
197
  else {
162
198
  const branch = autoOrBranch.branch;
@@ -169,8 +205,12 @@ class FlowEngine {
169
205
  if (specific.processor)
170
206
  await specific.processor.process(flow.context);
171
207
  const from = flow.currentState;
208
+ this.fireExit(flow, from);
172
209
  flow.transitionTo(target);
173
- this.store.recordTransition(flow.id, from, target, `${branch.name}:${label}`, flow.context);
210
+ this.fireEnter(flow, target);
211
+ const trigger = `${branch.name}:${label}`;
212
+ this.store.recordTransition(flow.id, from, target, trigger, flow.context);
213
+ this.logTransition(flow, from, target, trigger, stepStart);
174
214
  }
175
215
  }
176
216
  catch (e) {
@@ -194,9 +234,14 @@ class FlowEngine {
194
234
  parentFlow.setActiveSubFlow(null);
195
235
  const target = exitMappings.get(subFlow.exitState);
196
236
  if (target) {
237
+ const sfStart = this.transitionLogger ? performance.now() : 0;
197
238
  const from = parentFlow.currentState;
239
+ this.fireExit(parentFlow, from);
198
240
  parentFlow.transitionTo(target);
199
- this.store.recordTransition(parentFlow.id, from, target, `subFlow:${subDef.name}/${subFlow.exitState}`, parentFlow.context);
241
+ this.fireEnter(parentFlow, target);
242
+ const trigger = `subFlow:${subDef.name}/${subFlow.exitState}`;
243
+ this.store.recordTransition(parentFlow.id, from, target, trigger, parentFlow.context);
244
+ this.logTransition(parentFlow, from, target, trigger, sfStart);
200
245
  return 1;
201
246
  }
202
247
  // Error bubbling: no exit mapping → fall back to parent's error transitions
@@ -214,14 +259,20 @@ class FlowEngine {
214
259
  }
215
260
  const guard = transition.guard;
216
261
  if (guard) {
262
+ const guardStart = this.guardLogger ? performance.now() : 0;
217
263
  const output = await guard.validate(parentFlow.context);
264
+ const guardDur = this.guardLogger ? Math.round((performance.now() - guardStart) * 1000) : 0;
218
265
  if (output.type === 'accepted') {
219
266
  if (output.data) {
220
267
  for (const [key, value] of output.data)
221
268
  parentFlow.context.put(key, value);
222
269
  }
270
+ const sfStart = this.transitionLogger ? performance.now() : 0;
271
+ const sfFrom = subFlow.currentState;
223
272
  subFlow.transitionTo(transition.to);
224
- this.store.recordTransition(parentFlow.id, subFlow.currentState, transition.to, guard.name, parentFlow.context);
273
+ this.store.recordTransition(parentFlow.id, sfFrom, transition.to, guard.name, parentFlow.context);
274
+ this.logTransition(parentFlow, sfFrom, transition.to, guard.name, sfStart);
275
+ this.logGuard(parentFlow, sfFrom, guard.name, 'accepted', guardDur);
225
276
  }
226
277
  else if (output.type === 'rejected') {
227
278
  subFlow.incrementGuardFailure();
@@ -248,9 +299,14 @@ class FlowEngine {
248
299
  if (subFlowT?.exitMappings) {
249
300
  const target = subFlowT.exitMappings.get(subFlow.exitState);
250
301
  if (target) {
302
+ const exitStart = this.transitionLogger ? performance.now() : 0;
251
303
  const from = parentFlow.currentState;
304
+ this.fireExit(parentFlow, from);
252
305
  parentFlow.transitionTo(target);
253
- this.store.recordTransition(parentFlow.id, from, target, `subFlow:${subDef.name}/${subFlow.exitState}`, parentFlow.context);
306
+ this.fireEnter(parentFlow, target);
307
+ const trigger = `subFlow:${subDef.name}/${subFlow.exitState}`;
308
+ this.store.recordTransition(parentFlow.id, from, target, trigger, parentFlow.context);
309
+ this.logTransition(parentFlow, from, target, trigger, exitStart);
254
310
  await this.executeAutoChain(parentFlow);
255
311
  }
256
312
  }
@@ -267,7 +323,33 @@ class FlowEngine {
267
323
  }
268
324
  }
269
325
  }
326
+ fireEnter(flow, state) {
327
+ const action = flow.definition.enterAction(state);
328
+ if (action)
329
+ action(flow.context);
330
+ }
331
+ fireExit(flow, state) {
332
+ const action = flow.definition.exitAction(state);
333
+ if (action)
334
+ action(flow.context);
335
+ }
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
+ }
341
+ }
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
+ }
347
+ }
348
+ logGuard(flow, state, guardName, result, durationMicros, reason) {
349
+ this.guardLogger?.({ flowId: flow.id, flowName: flow.definition.name, state, guardName, result, reason, durationMicros });
350
+ }
270
351
  handleError(flow, fromState, cause) {
352
+ const errorStart = (this.transitionLogger || this.errorLogger) ? performance.now() : 0;
271
353
  if (cause) {
272
354
  flow.setLastError(`${cause.constructor.name}: ${cause.message}`);
273
355
  if (cause instanceof flow_error_js_1.FlowError) {
@@ -277,7 +359,7 @@ class FlowEngine {
277
359
  cause.withContextSnapshot(available, new Set());
278
360
  }
279
361
  }
280
- this.errorLogger?.({ flowId: flow.id, from: fromState, to: null, trigger: 'error', cause: cause ?? null });
362
+ this.logError(flow, fromState, null, 'error', cause ?? null, errorStart);
281
363
  // 1. Try exception-typed routes first (onStepError)
282
364
  if (cause && flow.definition.exceptionRoutes) {
283
365
  const routes = flow.definition.exceptionRoutes.get(fromState);
@@ -286,8 +368,10 @@ class FlowEngine {
286
368
  if (cause instanceof route.errorClass) {
287
369
  const from = flow.currentState;
288
370
  flow.transitionTo(route.target);
289
- this.store.recordTransition(flow.id, from, route.target, `error:${cause.constructor.name}`, flow.context);
290
- if (flow.definition.stateConfig[route.target].terminal)
371
+ const trigger = `error:${cause.constructor.name}`;
372
+ this.store.recordTransition(flow.id, from, route.target, trigger, flow.context);
373
+ this.logTransition(flow, from, route.target, trigger, errorStart);
374
+ if (flow.definition.stateConfig[route.target]?.terminal)
291
375
  flow.complete(route.target);
292
376
  return;
293
377
  }
@@ -300,7 +384,8 @@ class FlowEngine {
300
384
  const from = flow.currentState;
301
385
  flow.transitionTo(errorTarget);
302
386
  this.store.recordTransition(flow.id, from, errorTarget, 'error', flow.context);
303
- if (flow.definition.stateConfig[errorTarget].terminal)
387
+ this.logTransition(flow, from, errorTarget, 'error', errorStart);
388
+ if (flow.definition.stateConfig[errorTarget]?.terminal)
304
389
  flow.complete(errorTarget);
305
390
  }
306
391
  else {
@@ -7,6 +7,7 @@ export declare class FlowInstance<S extends string> {
7
7
  readonly context: FlowContext;
8
8
  private _currentState;
9
9
  private _guardFailureCount;
10
+ private _guardFailureCounts;
10
11
  private _version;
11
12
  readonly createdAt: Date;
12
13
  readonly expiresAt: Date;
@@ -22,6 +23,8 @@ export declare class FlowInstance<S extends string> {
22
23
  static restore<S extends string>(id: string, sessionId: string, definition: FlowDefinition<S>, context: FlowContext, currentState: S, createdAt: Date, expiresAt: Date, guardFailureCount: number, version: number, exitState: string | null): FlowInstance<S>;
23
24
  get currentState(): S;
24
25
  get guardFailureCount(): number;
26
+ /** Guard failure count for a specific guard (by name). */
27
+ guardFailureCountFor(guardName: string): number;
25
28
  get version(): number;
26
29
  get exitState(): string | null;
27
30
  get isCompleted(): boolean;
@@ -42,7 +45,7 @@ export declare class FlowInstance<S extends string> {
42
45
  withVersion(newVersion: number): FlowInstance<S>;
43
46
  get stateEnteredAt(): Date;
44
47
  /** @internal */ transitionTo(state: S): void;
45
- /** @internal */ incrementGuardFailure(): void;
48
+ /** @internal */ incrementGuardFailure(guardName?: string): void;
46
49
  /** @internal */ complete(exitState: string): void;
47
50
  /** @internal */ setVersion(version: number): void;
48
51
  /** @internal */ setActiveSubFlow(sub: FlowInstance<any> | null): void;
@@ -8,6 +8,7 @@ class FlowInstance {
8
8
  context;
9
9
  _currentState;
10
10
  _guardFailureCount;
11
+ _guardFailureCounts = new Map();
11
12
  _version;
12
13
  createdAt;
13
14
  expiresAt;
@@ -48,6 +49,8 @@ class FlowInstance {
48
49
  }
49
50
  get currentState() { return this._currentState; }
50
51
  get guardFailureCount() { return this._guardFailureCount; }
52
+ /** Guard failure count for a specific guard (by name). */
53
+ guardFailureCountFor(guardName) { return this._guardFailureCounts.get(guardName) ?? 0; }
51
54
  get version() { return this._version; }
52
55
  get exitState() { return this._exitState; }
53
56
  get isCompleted() { return this._exitState !== null; }
@@ -100,8 +103,20 @@ class FlowInstance {
100
103
  return copy;
101
104
  }
102
105
  get stateEnteredAt() { return this._stateEnteredAt; }
103
- /** @internal */ transitionTo(state) { this._currentState = state; this._stateEnteredAt = new Date(); }
104
- /** @internal */ incrementGuardFailure() { this._guardFailureCount++; }
106
+ /** @internal */ transitionTo(state) {
107
+ const stateChanged = this._currentState !== state;
108
+ this._currentState = state;
109
+ this._stateEnteredAt = new Date();
110
+ if (stateChanged) {
111
+ this._guardFailureCount = 0;
112
+ this._guardFailureCounts.clear();
113
+ }
114
+ }
115
+ /** @internal */ incrementGuardFailure(guardName) {
116
+ this._guardFailureCount++;
117
+ if (guardName)
118
+ this._guardFailureCounts.set(guardName, (this._guardFailureCounts.get(guardName) ?? 0) + 1);
119
+ }
105
120
  /** @internal */ complete(exitState) { this._exitState = exitState; }
106
121
  /** @internal */ setVersion(version) { this._version = version; }
107
122
  /** @internal */ setActiveSubFlow(sub) { this._activeSubFlow = sub; }
@@ -1,6 +1,6 @@
1
1
  export { Tramli } from './tramli.js';
2
2
  export { FlowEngine } from './flow-engine.js';
3
- export type { TransitionLogEntry, StateLogEntry, ErrorLogEntry } from './flow-engine.js';
3
+ export type { TransitionLogEntry, StateLogEntry, ErrorLogEntry, GuardLogEntry } from './flow-engine.js';
4
4
  export { FlowContext } from './flow-context.js';
5
5
  export { FlowInstance } from './flow-instance.js';
6
6
  export { FlowDefinition, Builder, FromBuilder, BranchBuilder, SubFlowBuilder } from './flow-definition.js';
@@ -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, 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, 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) {
@@ -113,7 +115,7 @@ class Pipeline {
113
115
  if (this.stateLogger && keysBefore) {
114
116
  for (const [k] of ctx.snapshot()) {
115
117
  if (!keysBefore.has(k)) {
116
- this.stateLogger({ flowId, state: step.name, key: k, value: ctx.snapshot().get(k) });
118
+ this.stateLogger({ flowId, flowName: this.name, state: step.name, key: k, value: ctx.snapshot().get(k) });
117
119
  }
118
120
  }
119
121
  }
@@ -9,6 +9,8 @@ export declare class FlowContext {
9
9
  readonly flowId: string;
10
10
  readonly createdAt: Date;
11
11
  private attributes;
12
+ private aliasToKey;
13
+ private keyToAlias;
12
14
  constructor(flowId: string, createdAt?: Date, attributes?: Map<string, unknown>);
13
15
  get<T>(key: FlowKey<T>): T;
14
16
  find<T>(key: FlowKey<T>): T | undefined;
@@ -16,4 +18,14 @@ export declare class FlowContext {
16
18
  has(key: FlowKey<unknown>): boolean;
17
19
  snapshot(): Map<string, unknown>;
18
20
  restoreFrom(snapshot: Map<string, unknown>): void;
21
+ /** Register a string alias for a FlowKey. Used for cross-language serialization. */
22
+ registerAlias(key: FlowKey<unknown>, alias: string): void;
23
+ /** Get the alias for a key (if registered). */
24
+ aliasOf(key: FlowKey<unknown>): string | undefined;
25
+ /** Get the key for an alias (if registered). */
26
+ keyOfAlias(alias: string): FlowKey<unknown> | undefined;
27
+ /** Export all registered aliases as a map (alias → key). */
28
+ toAliasMap(): Map<string, string>;
29
+ /** Import aliases from a map (alias → key). */
30
+ fromAliasMap(map: Map<string, string>): void;
19
31
  }
@@ -9,6 +9,8 @@ export class FlowContext {
9
9
  flowId;
10
10
  createdAt;
11
11
  attributes;
12
+ aliasToKey = new Map();
13
+ keyToAlias = new Map();
12
14
  constructor(flowId, createdAt, attributes) {
13
15
  this.flowId = flowId;
14
16
  this.createdAt = createdAt ?? new Date();
@@ -37,4 +39,29 @@ export class FlowContext {
37
39
  for (const [k, v] of snapshot)
38
40
  this.attributes.set(k, v);
39
41
  }
42
+ // ─── Alias support (for cross-language serialization) ──────────────────
43
+ /** Register a string alias for a FlowKey. Used for cross-language serialization. */
44
+ registerAlias(key, alias) {
45
+ this.aliasToKey.set(alias, key);
46
+ this.keyToAlias.set(key, alias);
47
+ }
48
+ /** Get the alias for a key (if registered). */
49
+ aliasOf(key) {
50
+ return this.keyToAlias.get(key);
51
+ }
52
+ /** Get the key for an alias (if registered). */
53
+ keyOfAlias(alias) {
54
+ return this.aliasToKey.get(alias);
55
+ }
56
+ /** Export all registered aliases as a map (alias → key). */
57
+ toAliasMap() {
58
+ return new Map(this.aliasToKey);
59
+ }
60
+ /** Import aliases from a map (alias → key). */
61
+ fromAliasMap(map) {
62
+ for (const [alias, key] of map) {
63
+ this.aliasToKey.set(alias, key);
64
+ this.keyToAlias.set(key, alias);
65
+ }
66
+ }
40
67
  }
@@ -16,9 +16,17 @@ export declare class FlowDefinition<S extends string> {
16
16
  errorClass: new (...args: any[]) => Error;
17
17
  target: S;
18
18
  }>>;
19
+ readonly enterActions: Map<S, (ctx: import('./flow-context.js').FlowContext) => void>;
20
+ readonly exitActions: Map<S, (ctx: import('./flow-context.js').FlowContext) => void>;
21
+ /** Get enter action for a state (or undefined). */
22
+ enterAction(state: S): ((ctx: import('./flow-context.js').FlowContext) => void) | undefined;
23
+ /** Get exit action for a state (or undefined). */
24
+ exitAction(state: S): ((ctx: import('./flow-context.js').FlowContext) => void) | undefined;
19
25
  private constructor();
20
26
  transitionsFrom(state: S): Transition<S>[];
21
27
  externalFrom(state: S): Transition<S> | undefined;
28
+ /** All external transitions from a state (for multi-external). */
29
+ externalsFrom(state: S): Transition<S>[];
22
30
  allStates(): S[];
23
31
  /**
24
32
  * Create a new FlowDefinition with a sub-flow inserted before a specific transition.
@@ -34,7 +42,10 @@ export declare class Builder<S extends string> {
34
42
  private readonly transitions;
35
43
  private readonly errorTransitions;
36
44
  private readonly _exceptionRoutes;
45
+ private readonly _enterActions;
46
+ private readonly _exitActions;
37
47
  private readonly initiallyAvailableKeys;
48
+ private _perpetual;
38
49
  constructor(name: string, stateConfig: Record<S, StateConfig>);
39
50
  initiallyAvailable(...keys: FlowKey<unknown>[]): this;
40
51
  setTtl(ms: number): this;
@@ -44,6 +55,12 @@ export declare class Builder<S extends string> {
44
55
  /** Route specific error types to specific states. Checked before onError. */
45
56
  onStepError(from: S, errorClass: new (...args: any[]) => Error, to: S): this;
46
57
  onAnyError(errorState: S): this;
58
+ /** Callback when entering a state (pure data/metrics, no I/O). */
59
+ onStateEnter(state: S, action: (ctx: import('./flow-context.js').FlowContext) => void): this;
60
+ /** Callback when exiting a state (pure data/metrics, no I/O). */
61
+ onStateExit(state: S, action: (ctx: import('./flow-context.js').FlowContext) => void): this;
62
+ /** Allow perpetual flows (no terminal states). Skips path-to-terminal validation. */
63
+ allowPerpetual(): this;
47
64
  /** @internal */
48
65
  addTransition(t: Transition<S>): void;
49
66
  build(): FlowDefinition<S>;
@@ -53,7 +70,6 @@ export declare class Builder<S extends string> {
53
70
  private canReachTerminal;
54
71
  private checkDag;
55
72
  private hasCycle;
56
- private checkExternalUniqueness;
57
73
  private checkBranchCompleteness;
58
74
  private checkRequiresProduces;
59
75
  private checkRequiresProducesFrom;
@@ -12,6 +12,16 @@ export class FlowDefinition {
12
12
  dataFlowGraph;
13
13
  warnings;
14
14
  exceptionRoutes;
15
+ enterActions;
16
+ exitActions;
17
+ /** Get enter action for a state (or undefined). */
18
+ enterAction(state) {
19
+ return this.enterActions?.get(state);
20
+ }
21
+ /** Get exit action for a state (or undefined). */
22
+ exitAction(state) {
23
+ return this.exitActions?.get(state);
24
+ }
15
25
  constructor(name, stateConfig, ttl, maxGuardRetries, transitions, errorTransitions) {
16
26
  this.name = name;
17
27
  this.stateConfig = stateConfig;
@@ -36,6 +46,10 @@ export class FlowDefinition {
36
46
  externalFrom(state) {
37
47
  return this.transitions.find(t => t.from === state && t.type === 'external');
38
48
  }
49
+ /** All external transitions from a state (for multi-external). */
50
+ externalsFrom(state) {
51
+ return this.transitions.filter(t => t.from === state && t.type === 'external');
52
+ }
39
53
  allStates() {
40
54
  return Object.keys(this.stateConfig);
41
55
  }
@@ -72,6 +86,10 @@ export class FlowDefinition {
72
86
  initialState: this.initialState,
73
87
  terminalStates: this.terminalStates,
74
88
  dataFlowGraph: this.dataFlowGraph, // reuse parent's graph
89
+ warnings: this.warnings,
90
+ exceptionRoutes: this.exceptionRoutes ? new Map(this.exceptionRoutes) : new Map(),
91
+ enterActions: this.enterActions ? new Map(this.enterActions) : new Map(),
92
+ exitActions: this.exitActions ? new Map(this.exitActions) : new Map(),
75
93
  });
76
94
  return result;
77
95
  }
@@ -88,7 +106,10 @@ export class Builder {
88
106
  transitions = [];
89
107
  errorTransitions = new Map();
90
108
  _exceptionRoutes = new Map();
109
+ _enterActions = new Map();
110
+ _exitActions = new Map();
91
111
  initiallyAvailableKeys = [];
112
+ _perpetual = false;
92
113
  constructor(name, stateConfig) {
93
114
  this.name = name;
94
115
  this.stateConfig = stateConfig;
@@ -121,6 +142,18 @@ export class Builder {
121
142
  }
122
143
  return this;
123
144
  }
145
+ /** Callback when entering a state (pure data/metrics, no I/O). */
146
+ onStateEnter(state, action) {
147
+ this._enterActions.set(state, action);
148
+ return this;
149
+ }
150
+ /** Callback when exiting a state (pure data/metrics, no I/O). */
151
+ onStateExit(state, action) {
152
+ this._exitActions.set(state, action);
153
+ return this;
154
+ }
155
+ /** Allow perpetual flows (no terminal states). Skips path-to-terminal validation. */
156
+ allowPerpetual() { this._perpetual = true; return this; }
124
157
  /** @internal */
125
158
  addTransition(t) { this.transitions.push(t); }
126
159
  build() {
@@ -158,6 +191,8 @@ export class Builder {
158
191
  }
159
192
  result.warnings = warnings;
160
193
  result.exceptionRoutes = new Map(this._exceptionRoutes);
194
+ result.enterActions = new Map(this._enterActions);
195
+ result.exitActions = new Map(this._exitActions);
161
196
  return result;
162
197
  }
163
198
  validate(def) {
@@ -166,9 +201,10 @@ export class Builder {
166
201
  errors.push('No initial state found (exactly one state must have initial=true)');
167
202
  }
168
203
  this.checkReachability(def, errors);
169
- this.checkPathToTerminal(def, errors);
204
+ if (!this._perpetual)
205
+ this.checkPathToTerminal(def, errors);
170
206
  this.checkDag(def, errors);
171
- this.checkExternalUniqueness(def, errors);
207
+ // checkExternalUniqueness removed (DD-020: multi-external allowed)
172
208
  this.checkBranchCompleteness(def, errors);
173
209
  this.checkRequiresProduces(def, errors);
174
210
  this.checkAutoExternalConflict(def, errors);
@@ -273,17 +309,6 @@ export class Builder {
273
309
  inStack.delete(node);
274
310
  return false;
275
311
  }
276
- checkExternalUniqueness(def, errors) {
277
- const counts = new Map();
278
- for (const t of def.transitions) {
279
- if (t.type === 'external')
280
- counts.set(t.from, (counts.get(t.from) ?? 0) + 1);
281
- }
282
- for (const [state, count] of counts) {
283
- if (count > 1)
284
- errors.push(`State ${state} has ${count} external transitions (max 1)`);
285
- }
286
- }
287
312
  checkBranchCompleteness(def, errors) {
288
313
  const allStates = new Set(def.allStates());
289
314
  for (const t of def.transitions) {
@@ -6,22 +6,36 @@ export declare const DEFAULT_MAX_CHAIN_DEPTH = 10;
6
6
  /** Log entry types for tramli's pluggable logger API. */
7
7
  export interface TransitionLogEntry {
8
8
  flowId: string;
9
+ flowName: string;
9
10
  from: string | null;
10
11
  to: string;
11
12
  trigger: string;
13
+ durationMicros: number;
12
14
  }
13
15
  export interface StateLogEntry {
14
16
  flowId: string;
17
+ flowName: string;
15
18
  state: string;
16
19
  key: string;
17
20
  value: unknown;
18
21
  }
19
22
  export interface ErrorLogEntry {
20
23
  flowId: string;
24
+ flowName: string;
21
25
  from: string | null;
22
26
  to: string | null;
23
27
  trigger: string;
24
28
  cause: Error | null;
29
+ durationMicros: number;
30
+ }
31
+ export interface GuardLogEntry {
32
+ flowId: string;
33
+ flowName: string;
34
+ state: string;
35
+ guardName: string;
36
+ result: 'accepted' | 'rejected' | 'expired';
37
+ reason?: string;
38
+ durationMicros: number;
25
39
  }
26
40
  export declare class FlowEngine {
27
41
  private readonly store;
@@ -30,6 +44,7 @@ export declare class FlowEngine {
30
44
  private transitionLogger?;
31
45
  private stateLogger?;
32
46
  private errorLogger?;
47
+ private guardLogger?;
33
48
  constructor(store: InMemoryFlowStore, options?: {
34
49
  strictMode?: boolean;
35
50
  maxChainDepth?: number;
@@ -37,6 +52,7 @@ export declare class FlowEngine {
37
52
  setTransitionLogger(logger: ((entry: TransitionLogEntry) => void) | null): void;
38
53
  setStateLogger(logger: ((entry: StateLogEntry) => void) | null): void;
39
54
  setErrorLogger(logger: ((entry: ErrorLogEntry) => void) | null): void;
55
+ setGuardLogger(logger: ((entry: GuardLogEntry) => void) | null): void;
40
56
  removeAllLoggers(): void;
41
57
  startFlow<S extends string>(definition: FlowDefinition<S>, sessionId: string, initialData: Map<string, unknown>): Promise<FlowInstance<S>>;
42
58
  resumeAndExecute<S extends string>(flowId: string, definition: FlowDefinition<S>, externalData?: Map<string, unknown>): Promise<FlowInstance<S>>;
@@ -44,5 +60,10 @@ export declare class FlowEngine {
44
60
  private executeSubFlow;
45
61
  private resumeSubFlow;
46
62
  private verifyProduces;
63
+ private fireEnter;
64
+ private fireExit;
65
+ private logTransition;
66
+ private logError;
67
+ private logGuard;
47
68
  private handleError;
48
69
  }
@@ -10,6 +10,7 @@ export class FlowEngine {
10
10
  transitionLogger;
11
11
  stateLogger;
12
12
  errorLogger;
13
+ guardLogger;
13
14
  constructor(store, options) {
14
15
  this.store = store;
15
16
  this.strictMode = options?.strictMode ?? false;
@@ -24,10 +25,14 @@ export class FlowEngine {
24
25
  setErrorLogger(logger) {
25
26
  this.errorLogger = logger ?? undefined;
26
27
  }
28
+ setGuardLogger(logger) {
29
+ this.guardLogger = logger ?? undefined;
30
+ }
27
31
  removeAllLoggers() {
28
32
  this.transitionLogger = undefined;
29
33
  this.stateLogger = undefined;
30
34
  this.errorLogger = undefined;
35
+ this.guardLogger = undefined;
31
36
  }
32
37
  async startFlow(definition, sessionId, initialData) {
33
38
  const flowId = crypto.randomUUID();
@@ -62,9 +67,22 @@ export class FlowEngine {
62
67
  return this.resumeSubFlow(flow, definition);
63
68
  }
64
69
  const currentState = flow.currentState;
65
- const transition = definition.externalFrom(currentState);
66
- if (!transition)
70
+ // Multi-external: select guard by requires matching
71
+ const externals = definition.externalsFrom(currentState);
72
+ if (externals.length === 0)
67
73
  throw FlowError.invalidTransition(currentState, currentState);
74
+ let transition;
75
+ const dataKeys = externalData ? new Set(externalData.keys()) : new Set();
76
+ for (const ext of externals) {
77
+ if (ext.guard && ext.guard.requires.every(r => dataKeys.has(r))) {
78
+ transition = ext;
79
+ break;
80
+ }
81
+ }
82
+ if (!transition) {
83
+ // Fallback: first external
84
+ transition = externals[0];
85
+ }
68
86
  // Per-state timeout check
69
87
  if (transition.timeout != null) {
70
88
  const deadline = new Date(flow.stateEnteredAt.getTime() + transition.timeout);
@@ -76,9 +94,13 @@ export class FlowEngine {
76
94
  }
77
95
  const guard = transition.guard;
78
96
  if (guard) {
97
+ const guardStart = this.guardLogger ? performance.now() : 0;
79
98
  const output = await guard.validate(flow.context);
99
+ const guardDurationMicros = this.guardLogger ? Math.round((performance.now() - guardStart) * 1000) : 0;
80
100
  switch (output.type) {
81
101
  case 'accepted': {
102
+ this.logGuard(flow, currentState, guard.name, 'accepted', guardDurationMicros);
103
+ const transStart = this.transitionLogger ? performance.now() : 0;
82
104
  const backup = flow.context.snapshot();
83
105
  if (output.data) {
84
106
  for (const [key, value] of output.data)
@@ -88,8 +110,11 @@ export class FlowEngine {
88
110
  if (transition.processor)
89
111
  await transition.processor.process(flow.context);
90
112
  const from = flow.currentState;
113
+ this.fireExit(flow, from);
91
114
  flow.transitionTo(transition.to);
115
+ this.fireEnter(flow, transition.to);
92
116
  this.store.recordTransition(flow.id, from, transition.to, guard.name, flow.context);
117
+ this.logTransition(flow, from, transition.to, guard.name, transStart);
93
118
  }
94
119
  catch (e) {
95
120
  flow.context.restoreFrom(backup);
@@ -100,7 +125,8 @@ export class FlowEngine {
100
125
  break;
101
126
  }
102
127
  case 'rejected': {
103
- flow.incrementGuardFailure();
128
+ this.logGuard(flow, currentState, guard.name, 'rejected', guardDurationMicros, output.reason);
129
+ flow.incrementGuardFailure(guard.name);
104
130
  if (flow.guardFailureCount >= definition.maxGuardRetries) {
105
131
  this.handleError(flow, currentState);
106
132
  }
@@ -108,6 +134,7 @@ export class FlowEngine {
108
134
  return flow;
109
135
  }
110
136
  case 'expired': {
137
+ this.logGuard(flow, currentState, guard.name, 'expired', guardDurationMicros);
111
138
  flow.complete('EXPIRED');
112
139
  this.store.save(flow);
113
140
  return flow;
@@ -115,9 +142,13 @@ export class FlowEngine {
115
142
  }
116
143
  }
117
144
  else {
145
+ const transStart = this.transitionLogger ? performance.now() : 0;
118
146
  const from = flow.currentState;
147
+ this.fireExit(flow, from);
119
148
  flow.transitionTo(transition.to);
149
+ this.fireEnter(flow, transition.to);
120
150
  this.store.recordTransition(flow.id, from, transition.to, 'external', flow.context);
151
+ this.logTransition(flow, from, transition.to, 'external', transStart);
121
152
  }
122
153
  await this.executeAutoChain(flow);
123
154
  this.store.save(flow);
@@ -145,6 +176,7 @@ export class FlowEngine {
145
176
  if (!autoOrBranch)
146
177
  break;
147
178
  const backup = flow.context.snapshot();
179
+ const stepStart = this.transitionLogger ? performance.now() : 0;
148
180
  try {
149
181
  if (autoOrBranch.type === 'auto') {
150
182
  if (autoOrBranch.processor) {
@@ -152,8 +184,12 @@ export class FlowEngine {
152
184
  this.verifyProduces(autoOrBranch.processor, flow.context);
153
185
  }
154
186
  const from = flow.currentState;
187
+ this.fireExit(flow, from);
155
188
  flow.transitionTo(autoOrBranch.to);
156
- this.store.recordTransition(flow.id, from, autoOrBranch.to, autoOrBranch.processor?.name ?? 'auto', flow.context);
189
+ this.fireEnter(flow, autoOrBranch.to);
190
+ const trigger = autoOrBranch.processor?.name ?? 'auto';
191
+ this.store.recordTransition(flow.id, from, autoOrBranch.to, trigger, flow.context);
192
+ this.logTransition(flow, from, autoOrBranch.to, trigger, stepStart);
157
193
  }
158
194
  else {
159
195
  const branch = autoOrBranch.branch;
@@ -166,8 +202,12 @@ export class FlowEngine {
166
202
  if (specific.processor)
167
203
  await specific.processor.process(flow.context);
168
204
  const from = flow.currentState;
205
+ this.fireExit(flow, from);
169
206
  flow.transitionTo(target);
170
- this.store.recordTransition(flow.id, from, target, `${branch.name}:${label}`, flow.context);
207
+ this.fireEnter(flow, target);
208
+ const trigger = `${branch.name}:${label}`;
209
+ this.store.recordTransition(flow.id, from, target, trigger, flow.context);
210
+ this.logTransition(flow, from, target, trigger, stepStart);
171
211
  }
172
212
  }
173
213
  catch (e) {
@@ -191,9 +231,14 @@ export class FlowEngine {
191
231
  parentFlow.setActiveSubFlow(null);
192
232
  const target = exitMappings.get(subFlow.exitState);
193
233
  if (target) {
234
+ const sfStart = this.transitionLogger ? performance.now() : 0;
194
235
  const from = parentFlow.currentState;
236
+ this.fireExit(parentFlow, from);
195
237
  parentFlow.transitionTo(target);
196
- this.store.recordTransition(parentFlow.id, from, target, `subFlow:${subDef.name}/${subFlow.exitState}`, parentFlow.context);
238
+ this.fireEnter(parentFlow, target);
239
+ const trigger = `subFlow:${subDef.name}/${subFlow.exitState}`;
240
+ this.store.recordTransition(parentFlow.id, from, target, trigger, parentFlow.context);
241
+ this.logTransition(parentFlow, from, target, trigger, sfStart);
197
242
  return 1;
198
243
  }
199
244
  // Error bubbling: no exit mapping → fall back to parent's error transitions
@@ -211,14 +256,20 @@ export class FlowEngine {
211
256
  }
212
257
  const guard = transition.guard;
213
258
  if (guard) {
259
+ const guardStart = this.guardLogger ? performance.now() : 0;
214
260
  const output = await guard.validate(parentFlow.context);
261
+ const guardDur = this.guardLogger ? Math.round((performance.now() - guardStart) * 1000) : 0;
215
262
  if (output.type === 'accepted') {
216
263
  if (output.data) {
217
264
  for (const [key, value] of output.data)
218
265
  parentFlow.context.put(key, value);
219
266
  }
267
+ const sfStart = this.transitionLogger ? performance.now() : 0;
268
+ const sfFrom = subFlow.currentState;
220
269
  subFlow.transitionTo(transition.to);
221
- this.store.recordTransition(parentFlow.id, subFlow.currentState, transition.to, guard.name, parentFlow.context);
270
+ this.store.recordTransition(parentFlow.id, sfFrom, transition.to, guard.name, parentFlow.context);
271
+ this.logTransition(parentFlow, sfFrom, transition.to, guard.name, sfStart);
272
+ this.logGuard(parentFlow, sfFrom, guard.name, 'accepted', guardDur);
222
273
  }
223
274
  else if (output.type === 'rejected') {
224
275
  subFlow.incrementGuardFailure();
@@ -245,9 +296,14 @@ export class FlowEngine {
245
296
  if (subFlowT?.exitMappings) {
246
297
  const target = subFlowT.exitMappings.get(subFlow.exitState);
247
298
  if (target) {
299
+ const exitStart = this.transitionLogger ? performance.now() : 0;
248
300
  const from = parentFlow.currentState;
301
+ this.fireExit(parentFlow, from);
249
302
  parentFlow.transitionTo(target);
250
- this.store.recordTransition(parentFlow.id, from, target, `subFlow:${subDef.name}/${subFlow.exitState}`, parentFlow.context);
303
+ this.fireEnter(parentFlow, target);
304
+ const trigger = `subFlow:${subDef.name}/${subFlow.exitState}`;
305
+ this.store.recordTransition(parentFlow.id, from, target, trigger, parentFlow.context);
306
+ this.logTransition(parentFlow, from, target, trigger, exitStart);
251
307
  await this.executeAutoChain(parentFlow);
252
308
  }
253
309
  }
@@ -264,7 +320,33 @@ export class FlowEngine {
264
320
  }
265
321
  }
266
322
  }
323
+ fireEnter(flow, state) {
324
+ const action = flow.definition.enterAction(state);
325
+ if (action)
326
+ action(flow.context);
327
+ }
328
+ fireExit(flow, state) {
329
+ const action = flow.definition.exitAction(state);
330
+ if (action)
331
+ action(flow.context);
332
+ }
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
+ }
338
+ }
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
+ }
344
+ }
345
+ logGuard(flow, state, guardName, result, durationMicros, reason) {
346
+ this.guardLogger?.({ flowId: flow.id, flowName: flow.definition.name, state, guardName, result, reason, durationMicros });
347
+ }
267
348
  handleError(flow, fromState, cause) {
349
+ const errorStart = (this.transitionLogger || this.errorLogger) ? performance.now() : 0;
268
350
  if (cause) {
269
351
  flow.setLastError(`${cause.constructor.name}: ${cause.message}`);
270
352
  if (cause instanceof FlowError) {
@@ -274,7 +356,7 @@ export class FlowEngine {
274
356
  cause.withContextSnapshot(available, new Set());
275
357
  }
276
358
  }
277
- this.errorLogger?.({ flowId: flow.id, from: fromState, to: null, trigger: 'error', cause: cause ?? null });
359
+ this.logError(flow, fromState, null, 'error', cause ?? null, errorStart);
278
360
  // 1. Try exception-typed routes first (onStepError)
279
361
  if (cause && flow.definition.exceptionRoutes) {
280
362
  const routes = flow.definition.exceptionRoutes.get(fromState);
@@ -283,8 +365,10 @@ export class FlowEngine {
283
365
  if (cause instanceof route.errorClass) {
284
366
  const from = flow.currentState;
285
367
  flow.transitionTo(route.target);
286
- this.store.recordTransition(flow.id, from, route.target, `error:${cause.constructor.name}`, flow.context);
287
- if (flow.definition.stateConfig[route.target].terminal)
368
+ const trigger = `error:${cause.constructor.name}`;
369
+ this.store.recordTransition(flow.id, from, route.target, trigger, flow.context);
370
+ this.logTransition(flow, from, route.target, trigger, errorStart);
371
+ if (flow.definition.stateConfig[route.target]?.terminal)
288
372
  flow.complete(route.target);
289
373
  return;
290
374
  }
@@ -297,7 +381,8 @@ export class FlowEngine {
297
381
  const from = flow.currentState;
298
382
  flow.transitionTo(errorTarget);
299
383
  this.store.recordTransition(flow.id, from, errorTarget, 'error', flow.context);
300
- if (flow.definition.stateConfig[errorTarget].terminal)
384
+ this.logTransition(flow, from, errorTarget, 'error', errorStart);
385
+ if (flow.definition.stateConfig[errorTarget]?.terminal)
301
386
  flow.complete(errorTarget);
302
387
  }
303
388
  else {
@@ -7,6 +7,7 @@ export declare class FlowInstance<S extends string> {
7
7
  readonly context: FlowContext;
8
8
  private _currentState;
9
9
  private _guardFailureCount;
10
+ private _guardFailureCounts;
10
11
  private _version;
11
12
  readonly createdAt: Date;
12
13
  readonly expiresAt: Date;
@@ -22,6 +23,8 @@ export declare class FlowInstance<S extends string> {
22
23
  static restore<S extends string>(id: string, sessionId: string, definition: FlowDefinition<S>, context: FlowContext, currentState: S, createdAt: Date, expiresAt: Date, guardFailureCount: number, version: number, exitState: string | null): FlowInstance<S>;
23
24
  get currentState(): S;
24
25
  get guardFailureCount(): number;
26
+ /** Guard failure count for a specific guard (by name). */
27
+ guardFailureCountFor(guardName: string): number;
25
28
  get version(): number;
26
29
  get exitState(): string | null;
27
30
  get isCompleted(): boolean;
@@ -42,7 +45,7 @@ export declare class FlowInstance<S extends string> {
42
45
  withVersion(newVersion: number): FlowInstance<S>;
43
46
  get stateEnteredAt(): Date;
44
47
  /** @internal */ transitionTo(state: S): void;
45
- /** @internal */ incrementGuardFailure(): void;
48
+ /** @internal */ incrementGuardFailure(guardName?: string): void;
46
49
  /** @internal */ complete(exitState: string): void;
47
50
  /** @internal */ setVersion(version: number): void;
48
51
  /** @internal */ setActiveSubFlow(sub: FlowInstance<any> | null): void;
@@ -5,6 +5,7 @@ export class FlowInstance {
5
5
  context;
6
6
  _currentState;
7
7
  _guardFailureCount;
8
+ _guardFailureCounts = new Map();
8
9
  _version;
9
10
  createdAt;
10
11
  expiresAt;
@@ -45,6 +46,8 @@ export class FlowInstance {
45
46
  }
46
47
  get currentState() { return this._currentState; }
47
48
  get guardFailureCount() { return this._guardFailureCount; }
49
+ /** Guard failure count for a specific guard (by name). */
50
+ guardFailureCountFor(guardName) { return this._guardFailureCounts.get(guardName) ?? 0; }
48
51
  get version() { return this._version; }
49
52
  get exitState() { return this._exitState; }
50
53
  get isCompleted() { return this._exitState !== null; }
@@ -97,8 +100,20 @@ export class FlowInstance {
97
100
  return copy;
98
101
  }
99
102
  get stateEnteredAt() { return this._stateEnteredAt; }
100
- /** @internal */ transitionTo(state) { this._currentState = state; this._stateEnteredAt = new Date(); }
101
- /** @internal */ incrementGuardFailure() { this._guardFailureCount++; }
103
+ /** @internal */ transitionTo(state) {
104
+ const stateChanged = this._currentState !== state;
105
+ this._currentState = state;
106
+ this._stateEnteredAt = new Date();
107
+ if (stateChanged) {
108
+ this._guardFailureCount = 0;
109
+ this._guardFailureCounts.clear();
110
+ }
111
+ }
112
+ /** @internal */ incrementGuardFailure(guardName) {
113
+ this._guardFailureCount++;
114
+ if (guardName)
115
+ this._guardFailureCounts.set(guardName, (this._guardFailureCounts.get(guardName) ?? 0) + 1);
116
+ }
102
117
  /** @internal */ complete(exitState) { this._exitState = exitState; }
103
118
  /** @internal */ setVersion(version) { this._version = version; }
104
119
  /** @internal */ setActiveSubFlow(sub) { this._activeSubFlow = sub; }
@@ -1,6 +1,6 @@
1
1
  export { Tramli } from './tramli.js';
2
2
  export { FlowEngine } from './flow-engine.js';
3
- export type { TransitionLogEntry, StateLogEntry, ErrorLogEntry } from './flow-engine.js';
3
+ export type { TransitionLogEntry, StateLogEntry, ErrorLogEntry, GuardLogEntry } from './flow-engine.js';
4
4
  export { FlowContext } from './flow-context.js';
5
5
  export { FlowInstance } from './flow-instance.js';
6
6
  export { FlowDefinition, Builder, FromBuilder, BranchBuilder, SubFlowBuilder } from './flow-definition.js';
@@ -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, 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, 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) {
@@ -108,7 +110,7 @@ export class Pipeline {
108
110
  if (this.stateLogger && keysBefore) {
109
111
  for (const [k] of ctx.snapshot()) {
110
112
  if (!keysBefore.has(k)) {
111
- this.stateLogger({ flowId, state: step.name, key: k, value: ctx.snapshot().get(k) });
113
+ this.stateLogger({ flowId, flowName: this.name, state: step.name, key: k, value: ctx.snapshot().get(k) });
112
114
  }
113
115
  }
114
116
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unlaxer/tramli",
3
- "version": "3.1.0",
3
+ "version": "3.3.0",
4
4
  "description": "Constrained flow engine — state machines that prevent invalid transitions at build time",
5
5
  "type": "module",
6
6
  "exports": {