@unlaxer/tramli 3.0.0 → 3.2.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,23 +6,34 @@ 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;
12
13
  }
13
14
  export interface StateLogEntry {
14
15
  flowId: string;
16
+ flowName: string;
15
17
  state: string;
16
18
  key: string;
17
19
  value: unknown;
18
20
  }
19
21
  export interface ErrorLogEntry {
20
22
  flowId: string;
23
+ flowName: string;
21
24
  from: string | null;
22
25
  to: string | null;
23
26
  trigger: string;
24
27
  cause: Error | null;
25
28
  }
29
+ export interface GuardLogEntry {
30
+ flowId: string;
31
+ flowName: string;
32
+ state: string;
33
+ guardName: string;
34
+ result: 'accepted' | 'rejected' | 'expired';
35
+ reason?: string;
36
+ }
26
37
  export declare class FlowEngine {
27
38
  private readonly store;
28
39
  private readonly strictMode;
@@ -30,6 +41,7 @@ export declare class FlowEngine {
30
41
  private transitionLogger?;
31
42
  private stateLogger?;
32
43
  private errorLogger?;
44
+ private guardLogger?;
33
45
  constructor(store: InMemoryFlowStore, options?: {
34
46
  strictMode?: boolean;
35
47
  maxChainDepth?: number;
@@ -37,6 +49,7 @@ export declare class FlowEngine {
37
49
  setTransitionLogger(logger: ((entry: TransitionLogEntry) => void) | null): void;
38
50
  setStateLogger(logger: ((entry: StateLogEntry) => void) | null): void;
39
51
  setErrorLogger(logger: ((entry: ErrorLogEntry) => void) | null): void;
52
+ setGuardLogger(logger: ((entry: GuardLogEntry) => void) | null): void;
40
53
  removeAllLoggers(): void;
41
54
  startFlow<S extends string>(definition: FlowDefinition<S>, sessionId: string, initialData: Map<string, unknown>): Promise<FlowInstance<S>>;
42
55
  resumeAndExecute<S extends string>(flowId: string, definition: FlowDefinition<S>, externalData?: Map<string, unknown>): Promise<FlowInstance<S>>;
@@ -44,5 +57,10 @@ export declare class FlowEngine {
44
57
  private executeSubFlow;
45
58
  private resumeSubFlow;
46
59
  private verifyProduces;
60
+ private fireEnter;
61
+ private fireExit;
62
+ private logTransition;
63
+ private logError;
64
+ private logGuard;
47
65
  private handleError;
48
66
  }
@@ -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);
@@ -82,6 +100,7 @@ class FlowEngine {
82
100
  const output = await guard.validate(flow.context);
83
101
  switch (output.type) {
84
102
  case 'accepted': {
103
+ this.logGuard(flow, currentState, guard.name, 'accepted');
85
104
  const backup = flow.context.snapshot();
86
105
  if (output.data) {
87
106
  for (const [key, value] of output.data)
@@ -91,8 +110,11 @@ class FlowEngine {
91
110
  if (transition.processor)
92
111
  await transition.processor.process(flow.context);
93
112
  const from = flow.currentState;
113
+ this.fireExit(flow, from);
94
114
  flow.transitionTo(transition.to);
115
+ this.fireEnter(flow, transition.to);
95
116
  this.store.recordTransition(flow.id, from, transition.to, guard.name, flow.context);
117
+ this.logTransition(flow, from, transition.to, guard.name);
96
118
  }
97
119
  catch (e) {
98
120
  flow.context.restoreFrom(backup);
@@ -103,7 +125,8 @@ class FlowEngine {
103
125
  break;
104
126
  }
105
127
  case 'rejected': {
106
- flow.incrementGuardFailure();
128
+ this.logGuard(flow, currentState, guard.name, 'rejected', output.reason);
129
+ flow.incrementGuardFailure(guard.name);
107
130
  if (flow.guardFailureCount >= definition.maxGuardRetries) {
108
131
  this.handleError(flow, currentState);
109
132
  }
@@ -111,6 +134,7 @@ class FlowEngine {
111
134
  return flow;
112
135
  }
113
136
  case 'expired': {
137
+ this.logGuard(flow, currentState, guard.name, 'expired');
114
138
  flow.complete('EXPIRED');
115
139
  this.store.save(flow);
116
140
  return flow;
@@ -119,8 +143,11 @@ class FlowEngine {
119
143
  }
120
144
  else {
121
145
  const from = flow.currentState;
146
+ this.fireExit(flow, from);
122
147
  flow.transitionTo(transition.to);
148
+ this.fireEnter(flow, transition.to);
123
149
  this.store.recordTransition(flow.id, from, transition.to, 'external', flow.context);
150
+ this.logTransition(flow, from, transition.to, 'external');
124
151
  }
125
152
  await this.executeAutoChain(flow);
126
153
  this.store.save(flow);
@@ -155,8 +182,12 @@ class FlowEngine {
155
182
  this.verifyProduces(autoOrBranch.processor, flow.context);
156
183
  }
157
184
  const from = flow.currentState;
185
+ this.fireExit(flow, from);
158
186
  flow.transitionTo(autoOrBranch.to);
159
- this.store.recordTransition(flow.id, from, autoOrBranch.to, autoOrBranch.processor?.name ?? 'auto', flow.context);
187
+ this.fireEnter(flow, autoOrBranch.to);
188
+ const trigger = autoOrBranch.processor?.name ?? 'auto';
189
+ this.store.recordTransition(flow.id, from, autoOrBranch.to, trigger, flow.context);
190
+ this.logTransition(flow, from, autoOrBranch.to, trigger);
160
191
  }
161
192
  else {
162
193
  const branch = autoOrBranch.branch;
@@ -169,8 +200,12 @@ class FlowEngine {
169
200
  if (specific.processor)
170
201
  await specific.processor.process(flow.context);
171
202
  const from = flow.currentState;
203
+ this.fireExit(flow, from);
172
204
  flow.transitionTo(target);
173
- this.store.recordTransition(flow.id, from, target, `${branch.name}:${label}`, flow.context);
205
+ this.fireEnter(flow, target);
206
+ const trigger = `${branch.name}:${label}`;
207
+ this.store.recordTransition(flow.id, from, target, trigger, flow.context);
208
+ this.logTransition(flow, from, target, trigger);
174
209
  }
175
210
  }
176
211
  catch (e) {
@@ -195,8 +230,12 @@ class FlowEngine {
195
230
  const target = exitMappings.get(subFlow.exitState);
196
231
  if (target) {
197
232
  const from = parentFlow.currentState;
233
+ this.fireExit(parentFlow, from);
198
234
  parentFlow.transitionTo(target);
199
- this.store.recordTransition(parentFlow.id, from, target, `subFlow:${subDef.name}/${subFlow.exitState}`, parentFlow.context);
235
+ this.fireEnter(parentFlow, target);
236
+ const trigger = `subFlow:${subDef.name}/${subFlow.exitState}`;
237
+ this.store.recordTransition(parentFlow.id, from, target, trigger, parentFlow.context);
238
+ this.logTransition(parentFlow, from, target, trigger);
200
239
  return 1;
201
240
  }
202
241
  // Error bubbling: no exit mapping → fall back to parent's error transitions
@@ -220,8 +259,11 @@ class FlowEngine {
220
259
  for (const [key, value] of output.data)
221
260
  parentFlow.context.put(key, value);
222
261
  }
262
+ const sfFrom = subFlow.currentState;
223
263
  subFlow.transitionTo(transition.to);
224
- this.store.recordTransition(parentFlow.id, subFlow.currentState, transition.to, guard.name, parentFlow.context);
264
+ 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');
225
267
  }
226
268
  else if (output.type === 'rejected') {
227
269
  subFlow.incrementGuardFailure();
@@ -249,8 +291,12 @@ class FlowEngine {
249
291
  const target = subFlowT.exitMappings.get(subFlow.exitState);
250
292
  if (target) {
251
293
  const from = parentFlow.currentState;
294
+ this.fireExit(parentFlow, from);
252
295
  parentFlow.transitionTo(target);
253
- this.store.recordTransition(parentFlow.id, from, target, `subFlow:${subDef.name}/${subFlow.exitState}`, parentFlow.context);
296
+ this.fireEnter(parentFlow, target);
297
+ const trigger = `subFlow:${subDef.name}/${subFlow.exitState}`;
298
+ this.store.recordTransition(parentFlow.id, from, target, trigger, parentFlow.context);
299
+ this.logTransition(parentFlow, from, target, trigger);
254
300
  await this.executeAutoChain(parentFlow);
255
301
  }
256
302
  }
@@ -267,6 +313,25 @@ class FlowEngine {
267
313
  }
268
314
  }
269
315
  }
316
+ fireEnter(flow, state) {
317
+ const action = flow.definition.enterAction(state);
318
+ if (action)
319
+ action(flow.context);
320
+ }
321
+ fireExit(flow, state) {
322
+ const action = flow.definition.exitAction(state);
323
+ if (action)
324
+ action(flow.context);
325
+ }
326
+ logTransition(flow, from, to, trigger) {
327
+ this.transitionLogger?.({ flowId: flow.id, flowName: flow.definition.name, from, to, trigger });
328
+ }
329
+ logError(flow, from, to, trigger, cause) {
330
+ this.errorLogger?.({ flowId: flow.id, flowName: flow.definition.name, from, to, trigger, cause });
331
+ }
332
+ logGuard(flow, state, guardName, result, reason) {
333
+ this.guardLogger?.({ flowId: flow.id, flowName: flow.definition.name, state, guardName, result, reason });
334
+ }
270
335
  handleError(flow, fromState, cause) {
271
336
  if (cause) {
272
337
  flow.setLastError(`${cause.constructor.name}: ${cause.message}`);
@@ -277,7 +342,7 @@ class FlowEngine {
277
342
  cause.withContextSnapshot(available, new Set());
278
343
  }
279
344
  }
280
- this.errorLogger?.({ flowId: flow.id, from: fromState, to: null, trigger: 'error', cause: cause ?? null });
345
+ this.logError(flow, fromState, null, 'error', cause ?? null);
281
346
  // 1. Try exception-typed routes first (onStepError)
282
347
  if (cause && flow.definition.exceptionRoutes) {
283
348
  const routes = flow.definition.exceptionRoutes.get(fromState);
@@ -286,8 +351,10 @@ class FlowEngine {
286
351
  if (cause instanceof route.errorClass) {
287
352
  const from = flow.currentState;
288
353
  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)
354
+ const trigger = `error:${cause.constructor.name}`;
355
+ this.store.recordTransition(flow.id, from, route.target, trigger, flow.context);
356
+ this.logTransition(flow, from, route.target, trigger);
357
+ if (flow.definition.stateConfig[route.target]?.terminal)
291
358
  flow.complete(route.target);
292
359
  return;
293
360
  }
@@ -300,7 +367,8 @@ class FlowEngine {
300
367
  const from = flow.currentState;
301
368
  flow.transitionTo(errorTarget);
302
369
  this.store.recordTransition(flow.id, from, errorTarget, 'error', flow.context);
303
- if (flow.definition.stateConfig[errorTarget].terminal)
370
+ this.logTransition(flow, from, errorTarget, 'error');
371
+ if (flow.definition.stateConfig[errorTarget]?.terminal)
304
372
  flow.complete(errorTarget);
305
373
  }
306
374
  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,14 @@ 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
+ this.transitionLogger?.({ flowId, flowName: this.name, from: prev, to: step.name, trigger: step.name });
96
96
  const keysBefore = this.stateLogger ? new Set(ctx.snapshot().keys()) : null;
97
97
  try {
98
98
  await step.process(ctx);
99
99
  }
100
100
  catch (e) {
101
101
  const err = e instanceof Error ? e : new Error(String(e));
102
- this.errorLogger?.({ flowId, from: prev, to: step.name, trigger: step.name, cause: err });
102
+ this.errorLogger?.({ flowId, flowName: this.name, from: prev, to: step.name, trigger: step.name, cause: err });
103
103
  throw new PipelineException(step.name, [...completed], ctx, err);
104
104
  }
105
105
  if (this.strictMode) {
@@ -113,7 +113,7 @@ class Pipeline {
113
113
  if (this.stateLogger && keysBefore) {
114
114
  for (const [k] of ctx.snapshot()) {
115
115
  if (!keysBefore.has(k)) {
116
- this.stateLogger({ flowId, state: step.name, key: k, value: ctx.snapshot().get(k) });
116
+ this.stateLogger({ flowId, flowName: this.name, state: step.name, key: k, value: ctx.snapshot().get(k) });
117
117
  }
118
118
  }
119
119
  }
@@ -3,7 +3,7 @@ import type { FlowContext } from './flow-context.js';
3
3
  /** State configuration: terminal and initial flags for each state. */
4
4
  export type StateConfig = {
5
5
  terminal: boolean;
6
- initial: boolean;
6
+ initial?: boolean;
7
7
  };
8
8
  /** Guard output — discriminated union (Java: sealed interface GuardOutput). */
9
9
  export type GuardOutput = {
@@ -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,23 +6,34 @@ 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;
12
13
  }
13
14
  export interface StateLogEntry {
14
15
  flowId: string;
16
+ flowName: string;
15
17
  state: string;
16
18
  key: string;
17
19
  value: unknown;
18
20
  }
19
21
  export interface ErrorLogEntry {
20
22
  flowId: string;
23
+ flowName: string;
21
24
  from: string | null;
22
25
  to: string | null;
23
26
  trigger: string;
24
27
  cause: Error | null;
25
28
  }
29
+ export interface GuardLogEntry {
30
+ flowId: string;
31
+ flowName: string;
32
+ state: string;
33
+ guardName: string;
34
+ result: 'accepted' | 'rejected' | 'expired';
35
+ reason?: string;
36
+ }
26
37
  export declare class FlowEngine {
27
38
  private readonly store;
28
39
  private readonly strictMode;
@@ -30,6 +41,7 @@ export declare class FlowEngine {
30
41
  private transitionLogger?;
31
42
  private stateLogger?;
32
43
  private errorLogger?;
44
+ private guardLogger?;
33
45
  constructor(store: InMemoryFlowStore, options?: {
34
46
  strictMode?: boolean;
35
47
  maxChainDepth?: number;
@@ -37,6 +49,7 @@ export declare class FlowEngine {
37
49
  setTransitionLogger(logger: ((entry: TransitionLogEntry) => void) | null): void;
38
50
  setStateLogger(logger: ((entry: StateLogEntry) => void) | null): void;
39
51
  setErrorLogger(logger: ((entry: ErrorLogEntry) => void) | null): void;
52
+ setGuardLogger(logger: ((entry: GuardLogEntry) => void) | null): void;
40
53
  removeAllLoggers(): void;
41
54
  startFlow<S extends string>(definition: FlowDefinition<S>, sessionId: string, initialData: Map<string, unknown>): Promise<FlowInstance<S>>;
42
55
  resumeAndExecute<S extends string>(flowId: string, definition: FlowDefinition<S>, externalData?: Map<string, unknown>): Promise<FlowInstance<S>>;
@@ -44,5 +57,10 @@ export declare class FlowEngine {
44
57
  private executeSubFlow;
45
58
  private resumeSubFlow;
46
59
  private verifyProduces;
60
+ private fireEnter;
61
+ private fireExit;
62
+ private logTransition;
63
+ private logError;
64
+ private logGuard;
47
65
  private handleError;
48
66
  }
@@ -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);
@@ -79,6 +97,7 @@ export class FlowEngine {
79
97
  const output = await guard.validate(flow.context);
80
98
  switch (output.type) {
81
99
  case 'accepted': {
100
+ this.logGuard(flow, currentState, guard.name, 'accepted');
82
101
  const backup = flow.context.snapshot();
83
102
  if (output.data) {
84
103
  for (const [key, value] of output.data)
@@ -88,8 +107,11 @@ export class FlowEngine {
88
107
  if (transition.processor)
89
108
  await transition.processor.process(flow.context);
90
109
  const from = flow.currentState;
110
+ this.fireExit(flow, from);
91
111
  flow.transitionTo(transition.to);
112
+ this.fireEnter(flow, transition.to);
92
113
  this.store.recordTransition(flow.id, from, transition.to, guard.name, flow.context);
114
+ this.logTransition(flow, from, transition.to, guard.name);
93
115
  }
94
116
  catch (e) {
95
117
  flow.context.restoreFrom(backup);
@@ -100,7 +122,8 @@ export class FlowEngine {
100
122
  break;
101
123
  }
102
124
  case 'rejected': {
103
- flow.incrementGuardFailure();
125
+ this.logGuard(flow, currentState, guard.name, 'rejected', output.reason);
126
+ flow.incrementGuardFailure(guard.name);
104
127
  if (flow.guardFailureCount >= definition.maxGuardRetries) {
105
128
  this.handleError(flow, currentState);
106
129
  }
@@ -108,6 +131,7 @@ export class FlowEngine {
108
131
  return flow;
109
132
  }
110
133
  case 'expired': {
134
+ this.logGuard(flow, currentState, guard.name, 'expired');
111
135
  flow.complete('EXPIRED');
112
136
  this.store.save(flow);
113
137
  return flow;
@@ -116,8 +140,11 @@ export class FlowEngine {
116
140
  }
117
141
  else {
118
142
  const from = flow.currentState;
143
+ this.fireExit(flow, from);
119
144
  flow.transitionTo(transition.to);
145
+ this.fireEnter(flow, transition.to);
120
146
  this.store.recordTransition(flow.id, from, transition.to, 'external', flow.context);
147
+ this.logTransition(flow, from, transition.to, 'external');
121
148
  }
122
149
  await this.executeAutoChain(flow);
123
150
  this.store.save(flow);
@@ -152,8 +179,12 @@ export class FlowEngine {
152
179
  this.verifyProduces(autoOrBranch.processor, flow.context);
153
180
  }
154
181
  const from = flow.currentState;
182
+ this.fireExit(flow, from);
155
183
  flow.transitionTo(autoOrBranch.to);
156
- this.store.recordTransition(flow.id, from, autoOrBranch.to, autoOrBranch.processor?.name ?? 'auto', flow.context);
184
+ this.fireEnter(flow, autoOrBranch.to);
185
+ const trigger = autoOrBranch.processor?.name ?? 'auto';
186
+ this.store.recordTransition(flow.id, from, autoOrBranch.to, trigger, flow.context);
187
+ this.logTransition(flow, from, autoOrBranch.to, trigger);
157
188
  }
158
189
  else {
159
190
  const branch = autoOrBranch.branch;
@@ -166,8 +197,12 @@ export class FlowEngine {
166
197
  if (specific.processor)
167
198
  await specific.processor.process(flow.context);
168
199
  const from = flow.currentState;
200
+ this.fireExit(flow, from);
169
201
  flow.transitionTo(target);
170
- this.store.recordTransition(flow.id, from, target, `${branch.name}:${label}`, flow.context);
202
+ this.fireEnter(flow, target);
203
+ const trigger = `${branch.name}:${label}`;
204
+ this.store.recordTransition(flow.id, from, target, trigger, flow.context);
205
+ this.logTransition(flow, from, target, trigger);
171
206
  }
172
207
  }
173
208
  catch (e) {
@@ -192,8 +227,12 @@ export class FlowEngine {
192
227
  const target = exitMappings.get(subFlow.exitState);
193
228
  if (target) {
194
229
  const from = parentFlow.currentState;
230
+ this.fireExit(parentFlow, from);
195
231
  parentFlow.transitionTo(target);
196
- this.store.recordTransition(parentFlow.id, from, target, `subFlow:${subDef.name}/${subFlow.exitState}`, parentFlow.context);
232
+ this.fireEnter(parentFlow, target);
233
+ const trigger = `subFlow:${subDef.name}/${subFlow.exitState}`;
234
+ this.store.recordTransition(parentFlow.id, from, target, trigger, parentFlow.context);
235
+ this.logTransition(parentFlow, from, target, trigger);
197
236
  return 1;
198
237
  }
199
238
  // Error bubbling: no exit mapping → fall back to parent's error transitions
@@ -217,8 +256,11 @@ export class FlowEngine {
217
256
  for (const [key, value] of output.data)
218
257
  parentFlow.context.put(key, value);
219
258
  }
259
+ const sfFrom = subFlow.currentState;
220
260
  subFlow.transitionTo(transition.to);
221
- this.store.recordTransition(parentFlow.id, subFlow.currentState, transition.to, guard.name, parentFlow.context);
261
+ 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');
222
264
  }
223
265
  else if (output.type === 'rejected') {
224
266
  subFlow.incrementGuardFailure();
@@ -246,8 +288,12 @@ export class FlowEngine {
246
288
  const target = subFlowT.exitMappings.get(subFlow.exitState);
247
289
  if (target) {
248
290
  const from = parentFlow.currentState;
291
+ this.fireExit(parentFlow, from);
249
292
  parentFlow.transitionTo(target);
250
- this.store.recordTransition(parentFlow.id, from, target, `subFlow:${subDef.name}/${subFlow.exitState}`, parentFlow.context);
293
+ this.fireEnter(parentFlow, target);
294
+ const trigger = `subFlow:${subDef.name}/${subFlow.exitState}`;
295
+ this.store.recordTransition(parentFlow.id, from, target, trigger, parentFlow.context);
296
+ this.logTransition(parentFlow, from, target, trigger);
251
297
  await this.executeAutoChain(parentFlow);
252
298
  }
253
299
  }
@@ -264,6 +310,25 @@ export class FlowEngine {
264
310
  }
265
311
  }
266
312
  }
313
+ fireEnter(flow, state) {
314
+ const action = flow.definition.enterAction(state);
315
+ if (action)
316
+ action(flow.context);
317
+ }
318
+ fireExit(flow, state) {
319
+ const action = flow.definition.exitAction(state);
320
+ if (action)
321
+ action(flow.context);
322
+ }
323
+ logTransition(flow, from, to, trigger) {
324
+ this.transitionLogger?.({ flowId: flow.id, flowName: flow.definition.name, from, to, trigger });
325
+ }
326
+ logError(flow, from, to, trigger, cause) {
327
+ this.errorLogger?.({ flowId: flow.id, flowName: flow.definition.name, from, to, trigger, cause });
328
+ }
329
+ logGuard(flow, state, guardName, result, reason) {
330
+ this.guardLogger?.({ flowId: flow.id, flowName: flow.definition.name, state, guardName, result, reason });
331
+ }
267
332
  handleError(flow, fromState, cause) {
268
333
  if (cause) {
269
334
  flow.setLastError(`${cause.constructor.name}: ${cause.message}`);
@@ -274,7 +339,7 @@ export class FlowEngine {
274
339
  cause.withContextSnapshot(available, new Set());
275
340
  }
276
341
  }
277
- this.errorLogger?.({ flowId: flow.id, from: fromState, to: null, trigger: 'error', cause: cause ?? null });
342
+ this.logError(flow, fromState, null, 'error', cause ?? null);
278
343
  // 1. Try exception-typed routes first (onStepError)
279
344
  if (cause && flow.definition.exceptionRoutes) {
280
345
  const routes = flow.definition.exceptionRoutes.get(fromState);
@@ -283,8 +348,10 @@ export class FlowEngine {
283
348
  if (cause instanceof route.errorClass) {
284
349
  const from = flow.currentState;
285
350
  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)
351
+ const trigger = `error:${cause.constructor.name}`;
352
+ this.store.recordTransition(flow.id, from, route.target, trigger, flow.context);
353
+ this.logTransition(flow, from, route.target, trigger);
354
+ if (flow.definition.stateConfig[route.target]?.terminal)
288
355
  flow.complete(route.target);
289
356
  return;
290
357
  }
@@ -297,7 +364,8 @@ export class FlowEngine {
297
364
  const from = flow.currentState;
298
365
  flow.transitionTo(errorTarget);
299
366
  this.store.recordTransition(flow.id, from, errorTarget, 'error', flow.context);
300
- if (flow.definition.stateConfig[errorTarget].terminal)
367
+ this.logTransition(flow, from, errorTarget, 'error');
368
+ if (flow.definition.stateConfig[errorTarget]?.terminal)
301
369
  flow.complete(errorTarget);
302
370
  }
303
371
  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,14 @@ 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
+ this.transitionLogger?.({ flowId, flowName: this.name, from: prev, to: step.name, trigger: step.name });
91
91
  const keysBefore = this.stateLogger ? new Set(ctx.snapshot().keys()) : null;
92
92
  try {
93
93
  await step.process(ctx);
94
94
  }
95
95
  catch (e) {
96
96
  const err = e instanceof Error ? e : new Error(String(e));
97
- this.errorLogger?.({ flowId, from: prev, to: step.name, trigger: step.name, cause: err });
97
+ this.errorLogger?.({ flowId, flowName: this.name, from: prev, to: step.name, trigger: step.name, cause: err });
98
98
  throw new PipelineException(step.name, [...completed], ctx, err);
99
99
  }
100
100
  if (this.strictMode) {
@@ -108,7 +108,7 @@ export class Pipeline {
108
108
  if (this.stateLogger && keysBefore) {
109
109
  for (const [k] of ctx.snapshot()) {
110
110
  if (!keysBefore.has(k)) {
111
- this.stateLogger({ flowId, state: step.name, key: k, value: ctx.snapshot().get(k) });
111
+ this.stateLogger({ flowId, flowName: this.name, state: step.name, key: k, value: ctx.snapshot().get(k) });
112
112
  }
113
113
  }
114
114
  }
@@ -3,7 +3,7 @@ import type { FlowContext } from './flow-context.js';
3
3
  /** State configuration: terminal and initial flags for each state. */
4
4
  export type StateConfig = {
5
5
  terminal: boolean;
6
- initial: boolean;
6
+ initial?: boolean;
7
7
  };
8
8
  /** Guard output — discriminated union (Java: sealed interface GuardOutput). */
9
9
  export type GuardOutput = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unlaxer/tramli",
3
- "version": "3.0.0",
3
+ "version": "3.2.0",
4
4
  "description": "Constrained flow engine — state machines that prevent invalid transitions at build time",
5
5
  "type": "module",
6
6
  "exports": {