@gobing-ai/ts-dual-workflow-engine 0.3.16 → 0.3.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +22 -6
  2. package/dist/action-step.d.ts +61 -0
  3. package/dist/action-step.d.ts.map +1 -0
  4. package/dist/action-step.js +81 -0
  5. package/dist/errors.d.ts +5 -0
  6. package/dist/errors.d.ts.map +1 -1
  7. package/dist/errors.js +9 -0
  8. package/dist/events.d.ts +41 -0
  9. package/dist/events.d.ts.map +1 -1
  10. package/dist/host.d.ts +10 -1
  11. package/dist/host.d.ts.map +1 -1
  12. package/dist/host.js +41 -2
  13. package/dist/index.d.ts +3 -3
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -2
  16. package/dist/persistence.d.ts +35 -1
  17. package/dist/persistence.d.ts.map +1 -1
  18. package/dist/persistence.js +112 -5
  19. package/dist/run-lifecycle.d.ts +21 -2
  20. package/dist/run-lifecycle.d.ts.map +1 -1
  21. package/dist/run-lifecycle.js +80 -16
  22. package/dist/schema-sql.d.ts.map +1 -1
  23. package/dist/schema-sql.js +5 -0
  24. package/dist/schema.d.ts +4 -0
  25. package/dist/schema.d.ts.map +1 -1
  26. package/dist/schema.js +4 -0
  27. package/dist/service.d.ts +36 -2
  28. package/dist/service.d.ts.map +1 -1
  29. package/dist/service.js +184 -0
  30. package/dist/state-machine.d.ts +2 -7
  31. package/dist/state-machine.d.ts.map +1 -1
  32. package/dist/state-machine.js +72 -71
  33. package/dist/transition-flow.d.ts +2 -0
  34. package/dist/transition-flow.d.ts.map +1 -1
  35. package/dist/transition-flow.js +46 -43
  36. package/dist/types.d.ts +59 -3
  37. package/dist/types.d.ts.map +1 -1
  38. package/package.json +4 -4
  39. package/schemas/state-machine-workflow.schema.json +5 -1
  40. package/schemas/transition-flow-workflow.schema.json +5 -1
  41. package/src/action-step.ts +130 -0
  42. package/src/errors.ts +11 -0
  43. package/src/events.ts +54 -4
  44. package/src/host.ts +53 -3
  45. package/src/index.ts +7 -1
  46. package/src/persistence.ts +137 -6
  47. package/src/run-lifecycle.ts +113 -15
  48. package/src/schema-sql.ts +5 -0
  49. package/src/schema.ts +4 -0
  50. package/src/service.ts +255 -1
  51. package/src/state-machine.ts +86 -114
  52. package/src/transition-flow.ts +59 -46
  53. package/src/types.ts +61 -2
package/dist/service.js CHANGED
@@ -1,10 +1,14 @@
1
1
  import { loadWorkflowDef } from './config.js';
2
+ import { FSMError, WorkflowResumeError } from './errors.js';
3
+ import { RunLifecycle } from './run-lifecycle.js';
2
4
  import { StateMachineDriver } from './state-machine.js';
3
5
  import { TransitionFlowDriver } from './transition-flow.js';
4
6
  /** High-level workflow service for loading, running, and listing persisted workflow runs. */
5
7
  export class WorkflowService {
6
8
  host;
7
9
  persistence;
10
+ /** Per-run serialization: ensures concurrent requestTransition calls serialize. */
11
+ runLocks = new Map();
8
12
  constructor(host, persistence) {
9
13
  this.host = host;
10
14
  this.persistence = persistence;
@@ -28,4 +32,184 @@ export class WorkflowService {
28
32
  async listRuns() {
29
33
  return await this.persistence.listRuns();
30
34
  }
35
+ /** Find a run by its external key within a workflow definition. */
36
+ async findRunByKey(workflowName, externalKey) {
37
+ return await this.persistence.findRunByKey(workflowName, externalKey);
38
+ }
39
+ /** Create a new run or attach to an existing one identified by external key. */
40
+ async createOrAttachRun(record) {
41
+ return await this.persistence.createOrAttachRun(record);
42
+ }
43
+ async reseedRun(workflowOrRunId, runIdOrNewState, newStateOrOptions, maybeOptions) {
44
+ // Normalize the two overloads to a single typed shape exactly once, so the
45
+ // commit path below never re-discriminates or casts.
46
+ const args = typeof workflowOrRunId === 'string'
47
+ ? {
48
+ workflow: undefined,
49
+ runId: workflowOrRunId,
50
+ newState: runIdOrNewState,
51
+ options: newStateOrOptions,
52
+ }
53
+ : {
54
+ workflow: workflowOrRunId,
55
+ runId: runIdOrNewState,
56
+ newState: newStateOrOptions,
57
+ options: maybeOptions,
58
+ };
59
+ if (args.workflow !== undefined) {
60
+ this.assertReseedTargetDeclared(args.workflow, args.runId, args.newState);
61
+ }
62
+ await this.commitReseed(args.runId, args.newState, args.options);
63
+ }
64
+ /** Reject a reseed target the workflow definition does not allow (state-machine states only). */
65
+ assertReseedTargetDeclared(workflow, runId, newState) {
66
+ if (workflow.kind === 'transition-flow') {
67
+ throw new FSMError('reseedRun only supports state-machine workflows');
68
+ }
69
+ if (!workflow.states.some((state) => state.id === newState)) {
70
+ throw new FSMError(`Cannot reseed run "${runId}" to undeclared state "${newState}"`);
71
+ }
72
+ }
73
+ /** Persist the reseed and emit the corrective event with the run's external key. */
74
+ async commitReseed(runId, newState, options) {
75
+ const run = await this.persistence.loadRun(runId);
76
+ const extKey = run?.external_key ?? undefined;
77
+ const result = await this.persistence.reseedRun(runId, newState);
78
+ void options?.events?.emit('workflow.run.reseeded', {
79
+ runId,
80
+ fromState: result.fromState ?? '',
81
+ toState: result.toState,
82
+ externalKey: extKey,
83
+ });
84
+ }
85
+ /** Resume a paused run, continuing execution from where it stopped. */
86
+ async resumeRun(workflow, runId, options) {
87
+ const run = await this.persistence.loadRun(runId);
88
+ if (run === undefined) {
89
+ throw new WorkflowResumeError(`Run "${runId}" not found`);
90
+ }
91
+ if (run.status !== 'paused') {
92
+ throw new WorkflowResumeError(`Run "${runId}" is not paused (status: ${run.status})`);
93
+ }
94
+ const currentState = await this.persistence.loadCurrentState(runId);
95
+ if (currentState === undefined) {
96
+ throw new WorkflowResumeError(`Run "${runId}" has no persisted state to resume from`);
97
+ }
98
+ // Re-open the run as running.
99
+ const extKey = run.external_key ?? undefined;
100
+ await this.persistence.finalizeRun(runId, 'running', '');
101
+ void options?.events?.emit('workflow.run.resumed', { runId, node: currentState, externalKey: extKey });
102
+ // Resume through the appropriate driver, starting from the paused state (skip on-enter).
103
+ if (workflow.kind === 'transition-flow') {
104
+ return await new TransitionFlowDriver({
105
+ host: this.host,
106
+ persistence: this.persistence,
107
+ }).resume(workflow, runId, currentState, extKey, options);
108
+ }
109
+ return await new StateMachineDriver({
110
+ host: this.host,
111
+ persistence: this.persistence,
112
+ }).resume(workflow, runId, currentState, extKey, options);
113
+ }
114
+ /** List runs currently paused. Optional filters and ordering. */
115
+ async listPausedRuns(options) {
116
+ return await this.persistence.listPausedRuns(options);
117
+ }
118
+ /**
119
+ * Request an external state transition on a run. Evaluates whether the
120
+ * transition exists and its guard passes; commits or denies atomically.
121
+ * Concurrent requests on the same run serialize — the loser re-evaluates
122
+ * against the new state.
123
+ */
124
+ async requestTransition(workflow, runId, toState, options) {
125
+ // Serialize per-run: chain onto the existing lock, then clean up.
126
+ let release;
127
+ const previous = this.runLocks.get(runId) ?? Promise.resolve();
128
+ const next = previous.then(() => new Promise((resolve) => {
129
+ release = resolve;
130
+ }));
131
+ this.runLocks.set(runId, next);
132
+ await previous;
133
+ try {
134
+ return await this.evaluateAndCommit(workflow, runId, toState, options);
135
+ }
136
+ finally {
137
+ release();
138
+ if (this.runLocks.get(runId) === next) {
139
+ this.runLocks.delete(runId);
140
+ }
141
+ }
142
+ }
143
+ async evaluateAndCommit(workflow, runId, toState, options) {
144
+ const currentState = await this.persistence.loadCurrentState(runId);
145
+ const run = await this.persistence.loadRun(runId);
146
+ const extKey = run?.external_key ?? undefined;
147
+ if (currentState === undefined) {
148
+ // No state recorded yet: nothing to transition from, and no `from` to
149
+ // address a denial event at — return the denial without emitting.
150
+ return {
151
+ allowed: false,
152
+ reason: 'no-such-transition',
153
+ detail: `No state recorded for run "${runId}"`,
154
+ };
155
+ }
156
+ // An external transition is a single guarded hop on an existing run, not a
157
+ // run itself — borrow a span-free lifecycle so the transition persist+emit
158
+ // mechanics reuse the same seam the drivers use (no new workflow.run span).
159
+ const lifecycle = RunLifecycle.forExternalTransition(workflow.name, runId, { persistence: this.persistence, events: options?.events }, extKey);
160
+ // Find the matching transition from current state to requested state.
161
+ const transition = workflow.transitions.find((t) => t.from === currentState && t.to === toState);
162
+ if (transition === undefined) {
163
+ return this.denyTransition(options, runId, currentState, toState, extKey, {
164
+ reason: 'no-such-transition',
165
+ detail: `No transition from "${currentState}" to "${toState}"`,
166
+ });
167
+ }
168
+ // Evaluate guard if present.
169
+ if (transition.guard !== undefined) {
170
+ const guardResult = await this.host.evaluateGuardResult(transition.guard.kind, transition.guard.options ?? {}, {
171
+ runId,
172
+ current: currentState,
173
+ vars: {},
174
+ workdir: options?.workdir,
175
+ });
176
+ lifecycle.guardEvaluated(currentState, toState, transition.guard.kind, guardResult.passed);
177
+ if (!guardResult.passed) {
178
+ return this.denyTransition(options, runId, currentState, toState, extKey, {
179
+ reason: 'guard-failed',
180
+ detail: `Guard "${transition.guard.kind}" denied transition from "${currentState}" to "${toState}"`,
181
+ guardKind: transition.guard.kind,
182
+ ...(guardResult.report === undefined ? {} : { guardReport: guardResult.report }),
183
+ });
184
+ }
185
+ }
186
+ // Commit: persist the transition + emit node.transition through the shared
187
+ // lifecycle seam, then the external-only state snapshot and requested event.
188
+ const trigger = transition.trigger ?? null;
189
+ await lifecycle.recordTransition(currentState, toState, trigger);
190
+ await this.persistence.saveWorkflowState(runId, toState, {});
191
+ void options?.events?.emit('workflow.transition.requested', {
192
+ runId,
193
+ from: currentState,
194
+ to: toState,
195
+ trigger,
196
+ externalKey: extKey,
197
+ });
198
+ return { allowed: true, fromState: currentState, toState };
199
+ }
200
+ /**
201
+ * Build a `TransitionDenied` result and emit its `workflow.transition.denied`
202
+ * event in one place — the single denial seam for the external-transition path,
203
+ * so the event payload and the returned reason can never drift apart.
204
+ */
205
+ denyTransition(options, runId, from, to, externalKey, denial) {
206
+ void options?.events?.emit('workflow.transition.denied', {
207
+ runId,
208
+ from,
209
+ to,
210
+ reason: denial.reason,
211
+ externalKey,
212
+ });
213
+ return { allowed: false, ...denial };
214
+ }
31
215
  }
@@ -11,13 +11,8 @@ export declare class StateMachineDriver {
11
11
  constructor(options: StateMachineDriverOptions);
12
12
  /** Run a state-machine workflow to completion or failure. */
13
13
  run(workflow: StateMachineWorkflowDef, options?: WorkflowRunOptions): Promise<WorkflowRunResult>;
14
+ /** Resume a paused state-machine run from the given state, skipping on-enter. */
15
+ resume(workflow: StateMachineWorkflowDef, runId: string, resumeFromState: string, externalKey: string | undefined, options?: WorkflowRunOptions): Promise<WorkflowRunResult>;
14
16
  private loop;
15
- /**
16
- * Run a state's actions in order. Returns the last action result (retained even
17
- * when a failure was continued past, so downstream guards can inspect it) plus an
18
- * `outcome` discriminator: `terminal` (an action declared terminal success),
19
- * `fail` (a failure under a 'fail' policy — caller must halt), or `completed`.
20
- */
21
- private runActions;
22
17
  }
23
18
  //# sourceMappingURL=state-machine.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"state-machine.d.ts","sourceRoot":"","sources":["../src/state-machine.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,QAAQ,CAAC;AAEjD,OAAO,KAAK,EAIR,uBAAuB,EACvB,0BAA0B,EAC1B,kBAAkB,EAClB,iBAAiB,EACpB,MAAM,SAAS,CAAC;AAGjB,yDAAyD;AACzD,MAAM,WAAW,yBAAyB;IACtC,QAAQ,CAAC,IAAI,EAAE,kBAAkB,CAAC;IAClC,QAAQ,CAAC,WAAW,EAAE,0BAA0B,CAAC;CACpD;AAED,wEAAwE;AACxE,qBAAa,kBAAkB;IACf,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE,yBAAyB;IAE/D,6DAA6D;IACvD,GAAG,CAAC,QAAQ,EAAE,uBAAuB,EAAE,OAAO,GAAE,kBAAuB,GAAG,OAAO,CAAC,iBAAiB,CAAC;YAU5F,IAAI;IAoGlB;;;;;OAKG;YACW,UAAU;CAwD3B"}
1
+ {"version":3,"file":"state-machine.d.ts","sourceRoot":"","sources":["../src/state-machine.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,QAAQ,CAAC;AAEjD,OAAO,KAAK,EAER,uBAAuB,EACvB,0BAA0B,EAC1B,kBAAkB,EAClB,iBAAiB,EACpB,MAAM,SAAS,CAAC;AAGjB,yDAAyD;AACzD,MAAM,WAAW,yBAAyB;IACtC,QAAQ,CAAC,IAAI,EAAE,kBAAkB,CAAC;IAClC,QAAQ,CAAC,WAAW,EAAE,0BAA0B,CAAC;CACpD;AAED,wEAAwE;AACxE,qBAAa,kBAAkB;IACf,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE,yBAAyB;IAE/D,6DAA6D;IACvD,GAAG,CAAC,QAAQ,EAAE,uBAAuB,EAAE,OAAO,GAAE,kBAAuB,GAAG,OAAO,CAAC,iBAAiB,CAAC;IAU1G,iFAAiF;IAC3E,MAAM,CACR,QAAQ,EAAE,uBAAuB,EACjC,KAAK,EAAE,MAAM,EACb,eAAe,EAAE,MAAM,EACvB,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,OAAO,GAAE,kBAAuB,GACjC,OAAO,CAAC,iBAAiB,CAAC;YAWf,IAAI;CAwHrB"}
@@ -1,6 +1,7 @@
1
+ import { runActionSequence } from './action-step.js';
1
2
  import { FSMError } from './errors.js';
2
- import { allowedEnv, RunLifecycle, runtimeBuiltins } from './run-lifecycle.js';
3
- import { mergeSetVars, mergeVars, resolveOnErrorPolicy, resolveTemplates } from './variables.js';
3
+ import { allowedEnv, RunLifecycle } from './run-lifecycle.js';
4
+ import { mergeSetVars, mergeVars } from './variables.js';
4
5
  /** State-machine workflow driver with an R7 single control function. */
5
6
  export class StateMachineDriver {
6
7
  options;
@@ -11,38 +12,69 @@ export class StateMachineDriver {
11
12
  async run(workflow, options = {}) {
12
13
  return await RunLifecycle.run(workflow.name, 'state-machine', { persistence: this.options.persistence, events: options.events }, options, (lifecycle) => this.loop(workflow, options, lifecycle));
13
14
  }
14
- async loop(workflow, options, lifecycle) {
15
+ /** Resume a paused state-machine run from the given state, skipping on-enter. */
16
+ async resume(workflow, runId, resumeFromState, externalKey, options = {}) {
17
+ return await RunLifecycle.resume(workflow.name, 'state-machine', { persistence: this.options.persistence, events: options.events }, runId, externalKey, (lifecycle) => this.loop(workflow, options, lifecycle, resumeFromState));
18
+ }
19
+ async loop(workflow, options, lifecycle, resumeFromState) {
15
20
  const runId = lifecycle.runId;
16
21
  const states = new Map(workflow.states.map((state) => [state.id, state]));
17
22
  const terminal = new Set(workflow.terminalStates ?? []);
18
23
  let vars = mergeVars(workflow.vars, options.vars);
19
24
  const env = allowedEnv(workflow.env?.allow ?? [], options.env);
20
- let current = states.get(workflow.initialState);
25
+ let current = resumeFromState !== undefined ? states.get(resumeFromState) : states.get(workflow.initialState);
21
26
  let transitionsTaken = 0;
22
27
  let lastActionResult;
23
28
  const iterationBound = workflow.iterationBound ?? 50;
24
29
  const defaultOnError = workflow.defaultOnError;
25
- if (current === undefined)
26
- throw new FSMError(`Initial state "${workflow.initialState}" is not declared`);
30
+ let isResume = resumeFromState !== undefined;
31
+ if (current === undefined) {
32
+ const label = resumeFromState ?? workflow.initialState;
33
+ throw new FSMError(`State "${label}" is not declared`);
34
+ }
27
35
  while (true) {
28
- // 1. Persist current state snapshot before work starts.
29
- await lifecycle.enter(current.id, transitionsTaken);
30
- // 2. Execute this state's on-enter actions in declaration order.
31
- const enter = await this.runActions(current.onEnter ?? [], workflow.name, current.id, runId, vars, env, options, transitionsTaken, lifecycle, defaultOnError);
32
- // Retain the last action result (including failures the policy continued
33
- // past) so downstream guards can inspect it — matching the transition-flow
34
- // driver's `continue` semantics. A state with no enter actions must not
35
- // erase the previous result.
36
- if (enter.result !== undefined)
37
- lastActionResult = enter.result;
38
- if (enter.result?.setVars)
39
- vars = mergeSetVars(vars, enter.result.setVars);
40
- if (enter.outcome === 'terminal') {
41
- return await lifecycle.done(current.id, transitionsTaken);
36
+ if (isResume) {
37
+ // Resume: skip enter actions on the first iteration (already ran before pause).
38
+ isResume = false;
42
39
  }
43
- // 4. Halt only when an action failed under a 'fail' policy.
44
- if (enter.outcome === 'fail') {
45
- return await lifecycle.fail(current.id, transitionsTaken, lastActionResult?.error);
40
+ else {
41
+ // 1. Persist current state snapshot before work starts.
42
+ await lifecycle.enter(current.id, transitionsTaken);
43
+ // 2. Execute this state's on-enter actions in declaration order.
44
+ const enter = options.dryRun
45
+ ? EMPTY_OUTCOME
46
+ : await runActionSequence(current.onEnter ?? [], vars, {
47
+ host: this.options.host,
48
+ persistence: this.options.persistence,
49
+ lifecycle,
50
+ workflowName: workflow.name,
51
+ stateOrNodeId: current.id,
52
+ runId,
53
+ mode: 'state-machine',
54
+ transitionsTaken,
55
+ env,
56
+ options,
57
+ defaultOnError,
58
+ });
59
+ // Retain the last action result (including failures the policy continued
60
+ // past) so downstream guards can inspect it — matching the transition-flow
61
+ // driver's `continue` semantics. A state with no enter actions must not
62
+ // erase the previous result.
63
+ if (enter.result !== undefined)
64
+ lastActionResult = enter.result;
65
+ if (enter.result?.setVars)
66
+ vars = mergeSetVars(vars, enter.result.setVars);
67
+ if (enter.outcome === 'terminal') {
68
+ return await lifecycle.done(current.id, transitionsTaken);
69
+ }
70
+ // 4. Halt only when an action failed under a 'fail' policy.
71
+ if (enter.outcome === 'fail') {
72
+ return await lifecycle.fail(current.id, transitionsTaken, lastActionResult?.error);
73
+ }
74
+ // Pause: if the state declares pause, stop advancing and persist the paused position.
75
+ if (current.pause === true) {
76
+ return await lifecycle.pause(current.id, transitionsTaken);
77
+ }
46
78
  }
47
79
  const outbound = workflow.transitions.filter((transition) => transition.from === current?.id);
48
80
  if (terminal.has(current.id) || outbound.length === 0) {
@@ -59,7 +91,21 @@ export class StateMachineDriver {
59
91
  return await lifecycle.fail(current.id, transitionsTaken, 'no-passing-transition');
60
92
  }
61
93
  // 6. Execute this state's on-exit actions before changing state.
62
- const exit = await this.runActions(current.onExit ?? [], workflow.name, current.id, runId, vars, env, options, transitionsTaken, lifecycle, defaultOnError);
94
+ const exit = options.dryRun
95
+ ? EMPTY_OUTCOME
96
+ : await runActionSequence(current.onExit ?? [], vars, {
97
+ host: this.options.host,
98
+ persistence: this.options.persistence,
99
+ lifecycle,
100
+ workflowName: workflow.name,
101
+ stateOrNodeId: current.id,
102
+ runId,
103
+ mode: 'state-machine',
104
+ transitionsTaken,
105
+ env,
106
+ options,
107
+ defaultOnError,
108
+ });
63
109
  if (exit.result !== undefined)
64
110
  lastActionResult = exit.result;
65
111
  if (exit.result?.setVars)
@@ -78,54 +124,9 @@ export class StateMachineDriver {
78
124
  current = nextState;
79
125
  }
80
126
  }
81
- /**
82
- * Run a state's actions in order. Returns the last action result (retained even
83
- * when a failure was continued past, so downstream guards can inspect it) plus an
84
- * `outcome` discriminator: `terminal` (an action declared terminal success),
85
- * `fail` (a failure under a 'fail' policy — caller must halt), or `completed`.
86
- */
87
- async runActions(actions, workflowName, stateId, runId, vars, env, options, transitionsTaken, lifecycle, defaultOnError) {
88
- if (options.dryRun) {
89
- return { outcome: 'completed', result: undefined };
90
- }
91
- let last;
92
- for (const action of actions) {
93
- const resolved = resolveTemplates(action.options ?? {}, {
94
- vars,
95
- env,
96
- builtins: runtimeBuiltins(workflowName, stateId, runId, transitionsTaken, 'state-machine'),
97
- });
98
- const actionId = await this.options.persistence.saveActionStart(runId, stateId, action.kind);
99
- const actionStartMs = Date.now();
100
- lifecycle.actionStart(stateId, action.kind);
101
- try {
102
- last = await this.options.host.runAction(action.kind, resolved, {
103
- runId,
104
- workdir: options.workdir,
105
- stateOrNodeId: stateId,
106
- vars,
107
- env,
108
- metadata: options.metadata,
109
- events: options.events,
110
- });
111
- }
112
- finally {
113
- const durationMs = Date.now() - actionStartMs;
114
- lifecycle.actionDone(stateId, action.kind, durationMs, last?.ok ?? false);
115
- void this.options.persistence.saveActionFinalize(actionId, last?.ok !== false ? 'done' : 'failed', durationMs, last?.ok ?? false, last);
116
- }
117
- if (last.terminal === true)
118
- return { outcome: 'terminal', result: last };
119
- if (!last.ok) {
120
- const policy = resolveOnErrorPolicy(action.onError, defaultOnError, options.onError);
121
- if (policy === 'fail')
122
- return { outcome: 'fail', result: last };
123
- lifecycle.warnActionFailed(stateId, transitionsTaken, last.error);
124
- }
125
- }
126
- return { outcome: 'completed', result: last };
127
- }
128
127
  }
128
+ /** Dry-run sentinel: no action ran, so there is nothing to retain and nothing to halt on. */
129
+ const EMPTY_OUTCOME = { outcome: 'completed', result: undefined };
129
130
  async function firstPassingTransition(transitions, host, context, lifecycle) {
130
131
  for (const transition of transitions) {
131
132
  if (transition.guard === undefined)
@@ -11,6 +11,8 @@ export declare class TransitionFlowDriver {
11
11
  constructor(options: TransitionFlowDriverOptions);
12
12
  /** Run a transition-flow workflow to completion or failure. */
13
13
  run(workflow: TransitionFlowWorkflowDef, options?: WorkflowRunOptions): Promise<WorkflowRunResult>;
14
+ /** Resume a paused transition-flow run from the given node, skipping node action. */
15
+ resume(workflow: TransitionFlowWorkflowDef, runId: string, resumeFromNode: string, externalKey: string | undefined, options?: WorkflowRunOptions): Promise<WorkflowRunResult>;
14
16
  private loop;
15
17
  }
16
18
  //# sourceMappingURL=transition-flow.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"transition-flow.d.ts","sourceRoot":"","sources":["../src/transition-flow.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,QAAQ,CAAC;AAEjD,OAAO,KAAK,EAER,yBAAyB,EACzB,0BAA0B,EAC1B,kBAAkB,EAClB,iBAAiB,EACpB,MAAM,SAAS,CAAC;AAGjB,2DAA2D;AAC3D,MAAM,WAAW,2BAA2B;IACxC,QAAQ,CAAC,IAAI,EAAE,kBAAkB,CAAC;IAClC,QAAQ,CAAC,WAAW,EAAE,0BAA0B,CAAC;CACpD;AAED,0EAA0E;AAC1E,qBAAa,oBAAoB;IACjB,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE,2BAA2B;IAEjE,+DAA+D;IACzD,GAAG,CAAC,QAAQ,EAAE,yBAAyB,EAAE,OAAO,GAAE,kBAAuB,GAAG,OAAO,CAAC,iBAAiB,CAAC;YAU9F,IAAI;CA6GrB"}
1
+ {"version":3,"file":"transition-flow.d.ts","sourceRoot":"","sources":["../src/transition-flow.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,QAAQ,CAAC;AAEjD,OAAO,KAAK,EAER,yBAAyB,EACzB,0BAA0B,EAC1B,kBAAkB,EAClB,iBAAiB,EACpB,MAAM,SAAS,CAAC;AAGjB,2DAA2D;AAC3D,MAAM,WAAW,2BAA2B;IACxC,QAAQ,CAAC,IAAI,EAAE,kBAAkB,CAAC;IAClC,QAAQ,CAAC,WAAW,EAAE,0BAA0B,CAAC;CACpD;AAED,0EAA0E;AAC1E,qBAAa,oBAAoB;IACjB,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE,2BAA2B;IAEjE,+DAA+D;IACzD,GAAG,CAAC,QAAQ,EAAE,yBAAyB,EAAE,OAAO,GAAE,kBAAuB,GAAG,OAAO,CAAC,iBAAiB,CAAC;IAU5G,qFAAqF;IAC/E,MAAM,CACR,QAAQ,EAAE,yBAAyB,EACnC,KAAK,EAAE,MAAM,EACb,cAAc,EAAE,MAAM,EACtB,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,OAAO,GAAE,kBAAuB,GACjC,OAAO,CAAC,iBAAiB,CAAC;YAWf,IAAI;CAuGrB"}
@@ -1,6 +1,7 @@
1
+ import { runActionStep } from './action-step.js';
1
2
  import { FSMError } from './errors.js';
2
- import { allowedEnv, RunLifecycle, runtimeBuiltins } from './run-lifecycle.js';
3
- import { mergeSetVars, mergeVars, resolveOnErrorPolicy, resolveTemplates } from './variables.js';
3
+ import { allowedEnv, RunLifecycle } from './run-lifecycle.js';
4
+ import { mergeSetVars, mergeVars } from './variables.js';
4
5
  /** Transition-flow workflow driver with an R7 single control function. */
5
6
  export class TransitionFlowDriver {
6
7
  options;
@@ -11,65 +12,67 @@ export class TransitionFlowDriver {
11
12
  async run(workflow, options = {}) {
12
13
  return await RunLifecycle.run(workflow.name, 'transition-flow', { persistence: this.options.persistence, events: options.events }, options, (lifecycle) => this.loop(workflow, options, lifecycle));
13
14
  }
14
- async loop(workflow, options, lifecycle) {
15
+ /** Resume a paused transition-flow run from the given node, skipping node action. */
16
+ async resume(workflow, runId, resumeFromNode, externalKey, options = {}) {
17
+ return await RunLifecycle.resume(workflow.name, 'transition-flow', { persistence: this.options.persistence, events: options.events }, runId, externalKey, (lifecycle) => this.loop(workflow, options, lifecycle, resumeFromNode));
18
+ }
19
+ async loop(workflow, options, lifecycle, resumeFromNode) {
15
20
  const runId = lifecycle.runId;
16
21
  const nodes = new Map(workflow.nodes.map((node) => [node.id, node]));
17
22
  const terminal = new Set(workflow.terminalNodes ?? []);
18
23
  let vars = mergeVars(workflow.vars, options.vars);
19
24
  const env = allowedEnv(workflow.env?.allow ?? [], options.env);
20
- let current = nodes.get(workflow.initialNode);
25
+ let current = resumeFromNode !== undefined ? nodes.get(resumeFromNode) : nodes.get(workflow.initialNode);
21
26
  let transitionsTaken = 0;
22
27
  let lastActionResult;
23
28
  const iterationBound = workflow.iterationBound ?? 50;
24
29
  const defaultOnError = workflow.defaultOnError;
30
+ let isResume = resumeFromNode !== undefined;
25
31
  if (current === undefined) {
26
- throw new FSMError(`Initial node "${workflow.initialNode}" is not declared`);
32
+ const label = resumeFromNode ?? workflow.initialNode;
33
+ throw new FSMError(`Node "${label}" is not declared`);
27
34
  }
28
35
  while (true) {
29
- // 1. Persist current node snapshot before action execution.
30
- await lifecycle.enter(current.id, transitionsTaken);
31
- // 2. Execute the node action when one is configured (skipped in dry-run).
32
- if (options.dryRun) {
33
- if (current.action !== undefined) {
34
- lastActionResult = undefined;
35
- }
36
+ if (isResume) {
37
+ // Resume: skip enter + node action on the first iteration (already ran before pause).
38
+ isResume = false;
36
39
  }
37
- else if (current.action !== undefined) {
38
- const resolved = resolveTemplates(current.action.options ?? {}, {
39
- vars,
40
- env,
41
- builtins: runtimeBuiltins(workflow.name, current.id, runId, transitionsTaken, 'transition-flow'),
42
- });
43
- const actionId = await this.options.persistence.saveActionStart(runId, current.id, current.action.kind);
44
- const actionStartMs = Date.now();
45
- lifecycle.actionStart(current.id, current.action.kind);
46
- try {
47
- lastActionResult = await this.options.host.runAction(current.action.kind, resolved, {
48
- runId,
49
- workdir: options.workdir,
40
+ else {
41
+ // 1. Persist current node snapshot before action execution.
42
+ await lifecycle.enter(current.id, transitionsTaken);
43
+ // 2. Execute the node action when one is configured (skipped in dry-run).
44
+ if (options.dryRun) {
45
+ if (current.action !== undefined) {
46
+ lastActionResult = undefined;
47
+ }
48
+ }
49
+ else if (current.action !== undefined) {
50
+ const step = await runActionStep(current.action, vars, {
51
+ host: this.options.host,
52
+ persistence: this.options.persistence,
53
+ lifecycle,
54
+ workflowName: workflow.name,
50
55
  stateOrNodeId: current.id,
51
- vars,
56
+ runId,
57
+ mode: 'transition-flow',
58
+ transitionsTaken,
52
59
  env,
53
- metadata: options.metadata,
54
- events: options.events,
60
+ options,
61
+ defaultOnError,
55
62
  });
56
- }
57
- finally {
58
- const durationMs = Date.now() - actionStartMs;
59
- lifecycle.actionDone(current.id, current.action.kind, durationMs, lastActionResult?.ok ?? false);
60
- void this.options.persistence.saveActionFinalize(actionId, lastActionResult?.ok !== false ? 'done' : 'failed', durationMs, lastActionResult?.ok ?? false, lastActionResult);
61
- }
62
- if (lastActionResult.setVars)
63
- vars = mergeSetVars(vars, lastActionResult.setVars);
64
- if (!lastActionResult.ok) {
65
- const policy = resolveOnErrorPolicy(current.action.onError, defaultOnError, options.onError);
66
- if (policy === 'fail') {
67
- return await lifecycle.fail(current.id, transitionsTaken, lastActionResult.error);
63
+ lastActionResult = step.result;
64
+ if (step.result?.setVars)
65
+ vars = mergeSetVars(vars, step.result.setVars);
66
+ if (step.outcome === 'terminal') {
67
+ return await lifecycle.done(current.id, transitionsTaken);
68
+ }
69
+ if (step.outcome === 'fail') {
70
+ return await lifecycle.fail(current.id, transitionsTaken, step.result?.error);
68
71
  }
69
- lifecycle.warnActionFailed(current.id, transitionsTaken, lastActionResult.error);
70
72
  }
71
- if (lastActionResult.terminal === true) {
72
- return await lifecycle.done(current.id, transitionsTaken);
73
+ // Pause: if the node declares pause, stop advancing and persist the paused position.
74
+ if (current.pause === true) {
75
+ return await lifecycle.pause(current.id, transitionsTaken);
73
76
  }
74
77
  }
75
78
  // 3. Stop when the node is terminal or no outgoing edge exists.
package/dist/types.d.ts CHANGED
@@ -3,7 +3,7 @@ export type OnErrorPolicy = 'fail' | 'continue';
3
3
  import type { EventBus } from '@gobing-ai/ts-infra';
4
4
  import type { WorkflowEngineEvents } from './events';
5
5
  /** Workflow execution status persisted for runs and phases. */
6
- export type WorkflowStatus = 'running' | 'done' | 'failed';
6
+ export type WorkflowStatus = 'running' | 'done' | 'failed' | 'paused';
7
7
  /** Runtime variables and user variables available to workflow definitions. */
8
8
  export type Vars = Record<string, string>;
9
9
  /** Environment allowlist carried by a workflow definition. */
@@ -29,6 +29,8 @@ export interface StateDef {
29
29
  readonly description?: string;
30
30
  readonly onEnter?: readonly ActionDef[];
31
31
  readonly onExit?: readonly ActionDef[];
32
+ /** When true, the engine pauses the run at this state instead of auto-advancing. */
33
+ readonly pause?: boolean;
32
34
  }
33
35
  /** One transition in a state-machine workflow. */
34
36
  export interface TransitionDef {
@@ -64,6 +66,8 @@ export interface FlowNodeDef {
64
66
  readonly description?: string;
65
67
  readonly type?: 'action' | 'gate' | 'parallel' | 'decision';
66
68
  readonly action?: ActionDef;
69
+ /** When true, the engine pauses the run at this node instead of auto-advancing. */
70
+ readonly pause?: boolean;
67
71
  }
68
72
  /** Transition-flow edge definition. */
69
73
  export interface FlowEdgeDef {
@@ -123,12 +127,18 @@ export interface GuardContext {
123
127
  readonly runId: string;
124
128
  readonly current: string;
125
129
  readonly vars: Vars;
130
+ readonly workdir?: string;
126
131
  readonly lastActionResult?: ActionResult;
127
132
  }
133
+ /** Rich guard evaluation result. Boolean guard runners remain supported for compatibility. */
134
+ export interface GuardEvaluationResult {
135
+ readonly passed: boolean;
136
+ readonly report?: unknown;
137
+ }
128
138
  /** Guard runner implementation registered in the workflow host. */
129
139
  export interface GuardRunner {
130
140
  readonly kind: string;
131
- evaluate(options: Record<string, unknown>, context: GuardContext): Promise<boolean>;
141
+ evaluate(options: Record<string, unknown>, context: GuardContext): Promise<boolean | GuardEvaluationResult>;
132
142
  }
133
143
  /** Input for running a workflow. */
134
144
  export interface WorkflowRunOptions {
@@ -143,6 +153,8 @@ export interface WorkflowRunOptions {
143
153
  readonly onError?: OnErrorPolicy;
144
154
  /** Validate the definition and walk the transition graph without executing actions. */
145
155
  readonly dryRun?: boolean;
156
+ /** Optional caller-supplied external key, unique per workflow definition. */
157
+ readonly externalKey?: string;
146
158
  }
147
159
  /** Result returned by both driver loops. */
148
160
  export interface WorkflowRunResult {
@@ -163,7 +175,38 @@ export interface WorkflowRunRecord {
163
175
  readonly started_at: string;
164
176
  readonly completed_at: string | null;
165
177
  readonly metadata_json: string;
166
- }
178
+ /** Optional caller-supplied external key, unique per workflow definition. */
179
+ readonly external_key?: string | null;
180
+ }
181
+ /** Result of force-setting the current state of a run. */
182
+ export interface WorkflowReseedResult {
183
+ readonly fromState: string | null;
184
+ readonly toState: string;
185
+ }
186
+ /** Reason categories when an external transition request is denied. */
187
+ export type TransitionDeniedReason = 'no-such-transition' | 'guard-failed';
188
+ /** Result when an external transition request is allowed. */
189
+ export interface TransitionAllowed {
190
+ readonly allowed: true;
191
+ /** The state the run has moved to. */
192
+ readonly toState: string;
193
+ /** The state the run moved from. */
194
+ readonly fromState: string;
195
+ }
196
+ /** Result when an external transition request is denied. */
197
+ export interface TransitionDenied {
198
+ readonly allowed: false;
199
+ /** Machine-readable reason category. */
200
+ readonly reason: TransitionDeniedReason;
201
+ /** Human-readable detail from the guard (when guard-failed) or transition lookup. */
202
+ readonly detail: string;
203
+ /** The guard kind that was evaluated (when guard-failed). */
204
+ readonly guardKind?: string;
205
+ /** Machine-readable guard report/output when a guard was evaluated. */
206
+ readonly guardReport?: unknown;
207
+ }
208
+ /** Discriminated union result for external transition requests. */
209
+ export type TransitionRequestResult = TransitionAllowed | TransitionDenied;
167
210
  /** Persisted action run record — one row per action executed in a workflow run. */
168
211
  export interface ActionRunRecord {
169
212
  readonly id: string;
@@ -192,5 +235,18 @@ export interface WorkflowPersistenceAdapter {
192
235
  saveActionFinalize(actionId: string, status: WorkflowStatus, durationMs: number, ok: boolean, result?: unknown, redactor?: ActionRedactor): Promise<void>;
193
236
  loadRun(runId: string): Promise<WorkflowRunRecord | undefined>;
194
237
  listRuns(): Promise<readonly WorkflowRunRecord[]>;
238
+ /** Look up a run by its external key within a workflow definition. Returns undefined if not found. */
239
+ findRunByKey(workflowName: string, externalKey: string): Promise<WorkflowRunRecord | undefined>;
240
+ /** Create a run or attach to an existing one by external key. Atomic create-or-attach semantics. */
241
+ createOrAttachRun(record: WorkflowRunRecord): Promise<WorkflowRunRecord>;
242
+ /** Force-set the current state of a run (consumer-side authority reconciliation). */
243
+ reseedRun(runId: string, newState: string): Promise<WorkflowReseedResult>;
244
+ /** Load the current state name for a run (latest state snapshot). Returns undefined if no state recorded. */
245
+ loadCurrentState(runId: string): Promise<string | undefined>;
246
+ /** List runs with status 'paused'. Optional filters: workflow name, limit. Ordered most-recent-first. */
247
+ listPausedRuns(options?: {
248
+ workflowName?: string;
249
+ limit?: number;
250
+ }): Promise<readonly WorkflowRunRecord[]>;
195
251
  }
196
252
  //# sourceMappingURL=types.d.ts.map