@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.
- package/dist/cjs/flow-context.d.ts +12 -0
- package/dist/cjs/flow-context.js +27 -0
- package/dist/cjs/flow-definition.d.ts +17 -1
- package/dist/cjs/flow-definition.js +38 -13
- package/dist/cjs/flow-engine.d.ts +21 -0
- package/dist/cjs/flow-engine.js +97 -12
- package/dist/cjs/flow-instance.d.ts +4 -1
- package/dist/cjs/flow-instance.js +17 -2
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/pipeline.js +5 -3
- package/dist/esm/flow-context.d.ts +12 -0
- package/dist/esm/flow-context.js +27 -0
- package/dist/esm/flow-definition.d.ts +17 -1
- package/dist/esm/flow-definition.js +38 -13
- package/dist/esm/flow-engine.d.ts +21 -0
- package/dist/esm/flow-engine.js +97 -12
- package/dist/esm/flow-instance.d.ts +4 -1
- package/dist/esm/flow-instance.js +17 -2
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/pipeline.js +5 -3
- package/package.json +1 -1
|
@@ -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
|
}
|
package/dist/cjs/flow-context.js
CHANGED
|
@@ -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.
|
|
208
|
+
if (!this._perpetual)
|
|
209
|
+
this.checkPathToTerminal(def, errors);
|
|
174
210
|
this.checkDag(def, errors);
|
|
175
|
-
|
|
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
|
}
|
package/dist/cjs/flow-engine.js
CHANGED
|
@@ -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
|
-
|
|
69
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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.
|
|
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.
|
|
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
|
-
|
|
290
|
-
|
|
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
|
-
|
|
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) {
|
|
104
|
-
|
|
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; }
|
package/dist/cjs/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/cjs/pipeline.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|
package/dist/esm/flow-context.js
CHANGED
|
@@ -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.
|
|
204
|
+
if (!this._perpetual)
|
|
205
|
+
this.checkPathToTerminal(def, errors);
|
|
170
206
|
this.checkDag(def, errors);
|
|
171
|
-
|
|
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
|
}
|
package/dist/esm/flow-engine.js
CHANGED
|
@@ -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
|
-
|
|
66
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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.
|
|
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.
|
|
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
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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) {
|
|
101
|
-
|
|
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; }
|
package/dist/esm/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/esm/pipeline.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|