@gobing-ai/ts-dual-workflow-engine 0.3.0 → 0.3.2
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/README.md +506 -5
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +7 -11
- package/dist/events.d.ts +49 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +0 -0
- package/dist/extensions.d.ts +60 -0
- package/dist/extensions.d.ts.map +1 -0
- package/dist/extensions.js +85 -0
- package/dist/host.d.ts +15 -2
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +42 -17
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/run-lifecycle.d.ts +58 -0
- package/dist/run-lifecycle.d.ts.map +1 -0
- package/dist/run-lifecycle.js +149 -0
- package/dist/schema-sql.d.ts +1 -0
- package/dist/schema-sql.d.ts.map +1 -1
- package/dist/schema-sql.js +1 -0
- package/dist/schema.d.ts +44 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +3 -0
- package/dist/state-machine.d.ts +7 -2
- package/dist/state-machine.d.ts.map +1 -1
- package/dist/state-machine.js +63 -72
- package/dist/transition-flow.d.ts +1 -2
- package/dist/transition-flow.d.ts.map +1 -1
- package/dist/transition-flow.js +35 -61
- package/dist/types.d.ts +14 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/variables.d.ts +6 -1
- package/dist/variables.d.ts.map +1 -1
- package/dist/variables.js +7 -0
- package/package.json +4 -4
- package/src/config.ts +8 -11
- package/src/events.ts +19 -0
- package/src/extensions.ts +163 -0
- package/src/host.ts +54 -20
- package/src/index.ts +24 -1
- package/src/run-lifecycle.ts +211 -0
- package/src/schema-sql.ts +1 -0
- package/src/schema.ts +3 -0
- package/src/state-machine.ts +78 -128
- package/src/transition-flow.ts +47 -105
- package/src/types.ts +16 -0
- package/src/variables.ts +13 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gobing-ai/ts-dual-workflow-engine",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "@gobing-ai/ts-dual-workflow-engine — State-machine and transition-flow workflow runtime.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"typescript",
|
|
@@ -48,9 +48,9 @@
|
|
|
48
48
|
"release": "echo 'Manual publish is disabled. Releases go through GitHub Actions via Trusted Publishing — push a tag: git tag @gobing-ai/ts-dual-workflow-engine-v<version> && git push --tags' && exit 1"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@gobing-ai/ts-db": "^0.3.
|
|
52
|
-
"@gobing-ai/ts-
|
|
53
|
-
"@gobing-ai/ts-
|
|
51
|
+
"@gobing-ai/ts-db": "^0.3.2",
|
|
52
|
+
"@gobing-ai/ts-infra": "^0.3.2",
|
|
53
|
+
"@gobing-ai/ts-runtime": "^0.3.2",
|
|
54
54
|
"zod": "^4.1.0"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
package/src/config.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { loadStructuredConfig, parseYamlObject } from '@gobing-ai/ts-runtime';
|
|
2
2
|
import { WorkflowValidationError } from './errors';
|
|
3
|
+
import { RUNTIME_BUILTIN_KEYS } from './run-lifecycle';
|
|
3
4
|
import { StateMachineWorkflowDefSchema, TransitionFlowWorkflowDefSchema } from './schema';
|
|
4
5
|
import type { WorkflowDef } from './types';
|
|
5
6
|
|
|
7
|
+
/** Loading options for {@link loadWorkflowDef}. */
|
|
6
8
|
export interface WorkflowLoadOptions {
|
|
7
9
|
/** When true, honor a top-level `$schema` ref. Defaults to true for file loads. */
|
|
8
10
|
validateSchema?: boolean;
|
|
@@ -177,17 +179,12 @@ function collectActionOptions(workflow: WorkflowDef): Record<string, unknown>[]
|
|
|
177
179
|
return optionSets;
|
|
178
180
|
}
|
|
179
181
|
|
|
180
|
-
/**
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
'node',
|
|
187
|
-
'iteration',
|
|
188
|
-
'run',
|
|
189
|
-
'runtime',
|
|
190
|
-
]);
|
|
182
|
+
/**
|
|
183
|
+
* Reserved template namespaces always available at runtime (not user-declared vars).
|
|
184
|
+
* Single-sourced from {@link RUNTIME_BUILTIN_KEYS} so the validator can never drift
|
|
185
|
+
* from the builtins the drivers actually inject at run time.
|
|
186
|
+
*/
|
|
187
|
+
const RUNTIME_TEMPLATE_NAMESPACES = new Set<string>(RUNTIME_BUILTIN_KEYS);
|
|
191
188
|
|
|
192
189
|
/**
|
|
193
190
|
* Check `${...}` template references inside action options resolve to something:
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Typed event map for workflow-engine run observability. All events prefixed `workflow.`. */
|
|
2
|
+
export type WorkflowEngineEvents = {
|
|
3
|
+
/** Emitted when a run begins (inside the span). */
|
|
4
|
+
'workflow.run.started': (data: { workflowName: string; mode: string; runId: string }) => void;
|
|
5
|
+
/** Emitted when a run completes successfully. */
|
|
6
|
+
'workflow.run.done': (data: { finalState: string; transitionsTaken: number }) => void;
|
|
7
|
+
/** Emitted when a run fails. */
|
|
8
|
+
'workflow.run.failed': (data: { finalState: string; reason: string }) => void;
|
|
9
|
+
/** Emitted when entering a state or node. */
|
|
10
|
+
'workflow.node.enter': (data: { node: string; transitionsTaken: number }) => void;
|
|
11
|
+
/** Emitted on a state/node transition. */
|
|
12
|
+
'workflow.node.transition': (data: { from: string; to: string; trigger: string | null }) => void;
|
|
13
|
+
/** Emitted when an action starts executing. */
|
|
14
|
+
'workflow.action.start': (data: { node: string; kind: string }) => void;
|
|
15
|
+
/** Emitted when an action finishes executing (success or failure). */
|
|
16
|
+
'workflow.action.done': (data: { node: string; kind: string; durationMs: number; ok: boolean }) => void;
|
|
17
|
+
/** Emitted when a non-fatal action failure is continued past (onError: 'continue'). */
|
|
18
|
+
'workflow.action.failed_continue': (data: { node: string; transitionsTaken: number; error?: string }) => void;
|
|
19
|
+
};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { Logger } from '@gobing-ai/ts-infra';
|
|
2
|
+
import { basenamePath, dirnamePath, SEP } from '@gobing-ai/ts-runtime';
|
|
3
|
+
import type { ExtensionRef, LoadExtensionsOptions } from '@gobing-ai/ts-runtime/plugin';
|
|
4
|
+
import { loadExtensionModules } from '@gobing-ai/ts-runtime/plugin';
|
|
5
|
+
import { WorkflowValidationError } from './errors';
|
|
6
|
+
import type { WorkflowEngineHost } from './host';
|
|
7
|
+
import type { ActionRunner, GuardRunner } from './types';
|
|
8
|
+
|
|
9
|
+
/** Minimal warning sink accepted for non-fatal extension diagnostics; a full {@link Logger} satisfies it. */
|
|
10
|
+
export type WorkflowExtensionLogger = Pick<Logger, 'warn'>;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Capability kinds a workflow extension module can contribute.
|
|
14
|
+
*
|
|
15
|
+
* Only `actions` and `guards` are supported as extension surfaces. Driver,
|
|
16
|
+
* loader, validator, and formatter registries are explicitly deferred per
|
|
17
|
+
* ADR-010 and are not extension-loadable.
|
|
18
|
+
*/
|
|
19
|
+
export type WorkflowExtensionKind = 'actions' | 'guards';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A single workflow extension module reference.
|
|
23
|
+
*
|
|
24
|
+
* The caller provides a pre-resolved absolute path; the loader adapts it to the
|
|
25
|
+
* shared `ExtensionRef` format before delegating to the generic core so the
|
|
26
|
+
* trust guard always governs the module that gets imported.
|
|
27
|
+
*/
|
|
28
|
+
export interface WorkflowExtensionRef {
|
|
29
|
+
/** Target capability registry. */
|
|
30
|
+
readonly kind: WorkflowExtensionKind;
|
|
31
|
+
/** Absolute path to the module to import. */
|
|
32
|
+
readonly absPath: string;
|
|
33
|
+
/** Name of the config declaring this extension (for diagnostics). */
|
|
34
|
+
readonly sourceName: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Options controlling workflow extension loading. */
|
|
38
|
+
export interface LoadWorkflowExtensionsOptions {
|
|
39
|
+
/**
|
|
40
|
+
* Whether to actually import extension modules. Defaults to `false`:
|
|
41
|
+
* loading arbitrary code is a trust decision the caller must make
|
|
42
|
+
* explicitly. When refs exist and this is not `true`, loading throws
|
|
43
|
+
* **before any import**.
|
|
44
|
+
*/
|
|
45
|
+
readonly allowExtensions?: boolean;
|
|
46
|
+
/** Optional sink for non-fatal warnings (e.g. built-in overrides). */
|
|
47
|
+
readonly logger?: WorkflowExtensionLogger;
|
|
48
|
+
/**
|
|
49
|
+
* Required module loader seam for tests or embedders with custom import
|
|
50
|
+
* policy. The shared core has no ambient code-loading capability of its
|
|
51
|
+
* own; the embedder supplies the import policy.
|
|
52
|
+
*/
|
|
53
|
+
readonly moduleLoader: (absPath: string) => Promise<Record<string, unknown>>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Import each extension module behind an explicit trust gate and register
|
|
58
|
+
* its actions and/or guards on the workflow host.
|
|
59
|
+
*
|
|
60
|
+
* Delegates generic loading (gate, path guard, module import, export
|
|
61
|
+
* validation) to the shared ``loadExtensionModules`` from ts-runtime/plugin,
|
|
62
|
+
* then routes each capability to ``host.registerAction`` or
|
|
63
|
+
* ``host.registerGuard`` based on ``ref.kind``.
|
|
64
|
+
*
|
|
65
|
+
* @throws When extensions are present but ``allowExtensions`` is not ``true``,
|
|
66
|
+
* when a module lacks a valid export shape, or when the module's export
|
|
67
|
+
* does not contain entries matching ``ref.kind``.
|
|
68
|
+
*/
|
|
69
|
+
export async function loadWorkflowExtensionsIntoHost(
|
|
70
|
+
host: WorkflowEngineHost,
|
|
71
|
+
refs: readonly WorkflowExtensionRef[],
|
|
72
|
+
options: LoadWorkflowExtensionsOptions,
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
if (refs.length === 0) return;
|
|
75
|
+
|
|
76
|
+
// Enforce relative-path guard before adapting to the shared format.
|
|
77
|
+
// The shared loader's assertRelativeExtensionPath applies to the derived
|
|
78
|
+
// (basename) path, which is always clean — this pre-check catches `..`
|
|
79
|
+
// traversal in the caller-supplied absPath before basename strips it (R6).
|
|
80
|
+
for (const ref of refs) {
|
|
81
|
+
const segments = ref.absPath.split(SEP);
|
|
82
|
+
if (segments.includes('..')) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`extension path "${ref.absPath}" declared by "${ref.sourceName}" must not contain ".." traversal`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Adapt WorkflowExtensionRef → shared ExtensionRef so the generic loader
|
|
90
|
+
// governs every import. The shared loader resolves (baseDir, path) ->
|
|
91
|
+
// absPath internally; we supply dirname/basename so the resolved path
|
|
92
|
+
// reconstructs the caller's original absPath. assertRelativeExtensionPath
|
|
93
|
+
// is satisfied because basenamePath() is always a simple filename.
|
|
94
|
+
const sharedRefs: ExtensionRef<WorkflowExtensionKind>[] = refs.map((ref) => ({
|
|
95
|
+
kind: ref.kind,
|
|
96
|
+
path: `./${basenamePath(ref.absPath)}`,
|
|
97
|
+
baseDir: dirnamePath(ref.absPath),
|
|
98
|
+
sourceName: ref.sourceName,
|
|
99
|
+
}));
|
|
100
|
+
|
|
101
|
+
const sharedOptions: LoadExtensionsOptions = {
|
|
102
|
+
allowExtensions: options.allowExtensions,
|
|
103
|
+
logger: options.logger,
|
|
104
|
+
moduleLoader: options.moduleLoader,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
await loadExtensionModules<WorkflowExtensionKind>(sharedRefs, sharedOptions, async (sharedRef, extension) => {
|
|
108
|
+
await registerExtensionOnHost(host, sharedRef, extension, options.logger);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Route extension-exported capabilities to the correct host registry.
|
|
114
|
+
*
|
|
115
|
+
* Validates that the module export contains entries matching `ref.kind`
|
|
116
|
+
* (wrong-kind-for-ref throws ``WorkflowValidationError``), registers each
|
|
117
|
+
* with origin ``'extension'``, and warns on built-in overrides.
|
|
118
|
+
*/
|
|
119
|
+
async function registerExtensionOnHost(
|
|
120
|
+
host: WorkflowEngineHost,
|
|
121
|
+
ref: ExtensionRef<WorkflowExtensionKind>,
|
|
122
|
+
extension: Record<string, unknown>,
|
|
123
|
+
logger?: WorkflowExtensionLogger,
|
|
124
|
+
): Promise<void> {
|
|
125
|
+
const name = extension.name as string;
|
|
126
|
+
|
|
127
|
+
if (ref.kind === 'actions') {
|
|
128
|
+
const actions = extension.actions as readonly ActionRunner[] | undefined;
|
|
129
|
+
if (!Array.isArray(actions)) {
|
|
130
|
+
throw new WorkflowValidationError(
|
|
131
|
+
`"${ref.sourceName}" extension "${name}" is referenced as kind "actions" but does not export an actions[] array`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
for (const action of actions) {
|
|
135
|
+
warnIfOverride(host, action.kind, 'action', ref.sourceName, logger);
|
|
136
|
+
host.registerAction(action, 'extension');
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
const guards = extension.guards as readonly GuardRunner[] | undefined;
|
|
140
|
+
if (!Array.isArray(guards)) {
|
|
141
|
+
throw new WorkflowValidationError(
|
|
142
|
+
`"${ref.sourceName}" extension "${name}" is referenced as kind "guards" but does not export a guards[] array`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
for (const guard of guards) {
|
|
146
|
+
warnIfOverride(host, guard.kind, 'guard', ref.sourceName, logger);
|
|
147
|
+
host.registerGuard(guard, 'extension');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function warnIfOverride(
|
|
153
|
+
host: WorkflowEngineHost,
|
|
154
|
+
kind: string,
|
|
155
|
+
capabilityType: 'action' | 'guard',
|
|
156
|
+
sourceName: string,
|
|
157
|
+
logger?: WorkflowExtensionLogger,
|
|
158
|
+
): void {
|
|
159
|
+
const origin = capabilityType === 'action' ? host.actionOrigin(kind) : host.guardOrigin(kind);
|
|
160
|
+
if (logger && origin === 'builtin') {
|
|
161
|
+
logger.warn(`"${sourceName}" extension overrides built-in ${capabilityType} "${kind}"`);
|
|
162
|
+
}
|
|
163
|
+
}
|
package/src/host.ts
CHANGED
|
@@ -1,36 +1,67 @@
|
|
|
1
1
|
import { NodeProcessExecutor, type ProcessExecutor } from '@gobing-ai/ts-runtime';
|
|
2
|
+
import { type CapabilityOrigin, CapabilityRegistry } from '@gobing-ai/ts-runtime/plugin';
|
|
2
3
|
import { WorkflowValidationError } from './errors';
|
|
3
4
|
import type { ActionResult, ActionRunContext, ActionRunner, GuardContext, GuardRunner } from './types';
|
|
4
5
|
|
|
5
6
|
/** Registry owner for workflow actions and guards. */
|
|
6
7
|
export class WorkflowEngineHost {
|
|
7
|
-
private readonly actions = new
|
|
8
|
-
private readonly guards = new
|
|
8
|
+
private readonly actions = new CapabilityRegistry<ActionRunner>('workflow action');
|
|
9
|
+
private readonly guards = new CapabilityRegistry<GuardRunner>('workflow guard');
|
|
9
10
|
|
|
10
11
|
/** Register or replace an action runner. */
|
|
11
|
-
registerAction(action: ActionRunner): this {
|
|
12
|
-
this.actions.
|
|
12
|
+
registerAction(action: ActionRunner, origin: CapabilityOrigin = 'extension'): this {
|
|
13
|
+
this.actions.register(action.kind, action, origin);
|
|
13
14
|
return this;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
/** Register or replace a guard runner. */
|
|
17
|
-
registerGuard(guard: GuardRunner): this {
|
|
18
|
-
this.guards.
|
|
18
|
+
registerGuard(guard: GuardRunner, origin: CapabilityOrigin = 'extension'): this {
|
|
19
|
+
this.guards.register(guard.kind, guard, origin);
|
|
19
20
|
return this;
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
/** Return true when an action of `kind` is registered. */
|
|
24
|
+
hasAction(kind: string): boolean {
|
|
25
|
+
return this.actions.has(kind);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Return true when a guard of `kind` is registered. */
|
|
29
|
+
hasGuard(kind: string): boolean {
|
|
30
|
+
return this.guards.has(kind);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** List registered action kinds in registration order. */
|
|
34
|
+
listActions(): string[] {
|
|
35
|
+
return this.actions.list();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** List registered guard kinds in registration order. */
|
|
39
|
+
listGuards(): string[] {
|
|
40
|
+
return this.guards.list();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Origin of a registered action, or undefined if none. Lets 0010 distinguish builtin from extension overrides. */
|
|
44
|
+
actionOrigin(kind: string): CapabilityOrigin | undefined {
|
|
45
|
+
return this.actions.getEntry(kind)?.origin;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Origin of a registered guard, or undefined if none. */
|
|
49
|
+
guardOrigin(kind: string): CapabilityOrigin | undefined {
|
|
50
|
+
return this.guards.getEntry(kind)?.origin;
|
|
51
|
+
}
|
|
52
|
+
|
|
22
53
|
/** Execute a registered action. */
|
|
23
54
|
async runAction(kind: string, options: Record<string, unknown>, context: ActionRunContext): Promise<ActionResult> {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
55
|
+
// Guard at the host boundary so unknown kinds surface as WorkflowValidationError,
|
|
56
|
+
// not the shared registry's generic Error (ADR-010 R13).
|
|
57
|
+
if (!this.actions.has(kind)) throw new WorkflowValidationError(`Unknown workflow action "${kind}"`);
|
|
58
|
+
return await this.actions.get(kind).execute(options, context);
|
|
27
59
|
}
|
|
28
60
|
|
|
29
61
|
/** Evaluate a registered guard. */
|
|
30
62
|
async evaluateGuard(kind: string, options: Record<string, unknown>, context: GuardContext): Promise<boolean> {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
return await guard.evaluate(options, context);
|
|
63
|
+
if (!this.guards.has(kind)) throw new WorkflowValidationError(`Unknown workflow guard "${kind}"`);
|
|
64
|
+
return await this.guards.get(kind).evaluate(options, context);
|
|
34
65
|
}
|
|
35
66
|
}
|
|
36
67
|
|
|
@@ -39,14 +70,17 @@ export function createDefaultWorkflowEngineHost(
|
|
|
39
70
|
options: { processExecutor?: ProcessExecutor } = {},
|
|
40
71
|
): WorkflowEngineHost {
|
|
41
72
|
const host = new WorkflowEngineHost();
|
|
42
|
-
host.registerAction(new NoteActionRunner());
|
|
43
|
-
host.registerAction(new ShellActionRunner(options.processExecutor ?? new NodeProcessExecutor()));
|
|
44
|
-
host.registerGuard({ kind: 'always', evaluate: async () => true });
|
|
45
|
-
host.registerGuard({ kind: 'never', evaluate: async () => false });
|
|
46
|
-
host.registerGuard(
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
73
|
+
host.registerAction(new NoteActionRunner(), 'builtin');
|
|
74
|
+
host.registerAction(new ShellActionRunner(options.processExecutor ?? new NodeProcessExecutor()), 'builtin');
|
|
75
|
+
host.registerGuard({ kind: 'always', evaluate: async () => true }, 'builtin');
|
|
76
|
+
host.registerGuard({ kind: 'never', evaluate: async () => false }, 'builtin');
|
|
77
|
+
host.registerGuard(
|
|
78
|
+
{
|
|
79
|
+
kind: 'action-ok',
|
|
80
|
+
evaluate: async (_options, context) => context.lastActionResult?.ok === true,
|
|
81
|
+
},
|
|
82
|
+
'builtin',
|
|
83
|
+
);
|
|
50
84
|
return host;
|
|
51
85
|
}
|
|
52
86
|
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
export { loadWorkflowDef, loadWorkflowDefFromText, validateWorkflowDef } from './config';
|
|
2
2
|
export { FSMError, RunCollisionError, WorkflowValidationError } from './errors';
|
|
3
|
+
export type { WorkflowEngineEvents } from './events';
|
|
4
|
+
export {
|
|
5
|
+
type LoadWorkflowExtensionsOptions,
|
|
6
|
+
loadWorkflowExtensionsIntoHost,
|
|
7
|
+
type WorkflowExtensionKind,
|
|
8
|
+
type WorkflowExtensionLogger,
|
|
9
|
+
type WorkflowExtensionRef,
|
|
10
|
+
} from './extensions';
|
|
3
11
|
export {
|
|
4
12
|
createDefaultWorkflowEngineHost,
|
|
5
13
|
NoteActionRunner,
|
|
@@ -11,6 +19,14 @@ export {
|
|
|
11
19
|
DbWorkflowPersistenceAdapter,
|
|
12
20
|
MemoryWorkflowPersistenceAdapter,
|
|
13
21
|
} from './persistence';
|
|
22
|
+
export {
|
|
23
|
+
allowedEnv,
|
|
24
|
+
RUNTIME_BUILTIN_KEYS,
|
|
25
|
+
RunLifecycle,
|
|
26
|
+
type RunLifecycleDeps,
|
|
27
|
+
runtimeBuiltins,
|
|
28
|
+
type WorkflowMode,
|
|
29
|
+
} from './run-lifecycle';
|
|
14
30
|
export {
|
|
15
31
|
ActionDefSchema,
|
|
16
32
|
GuardDefSchema,
|
|
@@ -33,6 +49,7 @@ export type {
|
|
|
33
49
|
GuardContext,
|
|
34
50
|
GuardDef,
|
|
35
51
|
GuardRunner,
|
|
52
|
+
OnErrorPolicy,
|
|
36
53
|
StateDef,
|
|
37
54
|
StateMachineWorkflowDef,
|
|
38
55
|
TransitionDef,
|
|
@@ -45,4 +62,10 @@ export type {
|
|
|
45
62
|
WorkflowRunResult,
|
|
46
63
|
WorkflowStatus,
|
|
47
64
|
} from './types';
|
|
48
|
-
export {
|
|
65
|
+
export {
|
|
66
|
+
mergeVars,
|
|
67
|
+
resolveOnErrorPolicy,
|
|
68
|
+
resolveTemplateString,
|
|
69
|
+
resolveTemplates,
|
|
70
|
+
type VariableContext,
|
|
71
|
+
} from './variables';
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { addSpanEvent, type EventBus, getLogger, type Logger, traceAsync } from '@gobing-ai/ts-infra';
|
|
2
|
+
import { getProcessEnv } from '@gobing-ai/ts-runtime';
|
|
3
|
+
import type { WorkflowEngineEvents } from './events';
|
|
4
|
+
import type {
|
|
5
|
+
WorkflowPersistenceAdapter,
|
|
6
|
+
WorkflowRunOptions,
|
|
7
|
+
WorkflowRunRecord,
|
|
8
|
+
WorkflowRunResult,
|
|
9
|
+
WorkflowStatus,
|
|
10
|
+
} from './types';
|
|
11
|
+
|
|
12
|
+
/** Workflow dialect carried on every run, span, and persisted record. */
|
|
13
|
+
export type WorkflowMode = WorkflowRunResult['mode'];
|
|
14
|
+
|
|
15
|
+
/** Keys of the runtime builtin namespace, single-sourced for resolver + validator. */
|
|
16
|
+
export const RUNTIME_BUILTIN_KEYS = [
|
|
17
|
+
'workflow',
|
|
18
|
+
'runId',
|
|
19
|
+
'task',
|
|
20
|
+
'state',
|
|
21
|
+
'node',
|
|
22
|
+
'iteration',
|
|
23
|
+
'run',
|
|
24
|
+
'runtime',
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
/** Built-in bare template values available to action options (state-machine + transition-flow). */
|
|
28
|
+
export function runtimeBuiltins(
|
|
29
|
+
workflowName: string,
|
|
30
|
+
stateOrNodeId: string,
|
|
31
|
+
runId: string,
|
|
32
|
+
transitionsTaken: number,
|
|
33
|
+
mode: WorkflowMode,
|
|
34
|
+
): Record<(typeof RUNTIME_BUILTIN_KEYS)[number], string | number> {
|
|
35
|
+
return {
|
|
36
|
+
workflow: workflowName,
|
|
37
|
+
runId,
|
|
38
|
+
task: workflowName,
|
|
39
|
+
state: stateOrNodeId,
|
|
40
|
+
node: stateOrNodeId,
|
|
41
|
+
iteration: transitionsTaken,
|
|
42
|
+
run: runId,
|
|
43
|
+
runtime: mode,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Project the env allowlist over a source map, dropping unset names. */
|
|
48
|
+
export function allowedEnv(
|
|
49
|
+
names: readonly string[],
|
|
50
|
+
source: Record<string, string | undefined> = getProcessEnv(),
|
|
51
|
+
): Record<string, string> {
|
|
52
|
+
return Object.fromEntries(
|
|
53
|
+
names.flatMap((name) => (source[name] === undefined ? [] : [[name, source[name] as string]])),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Dependencies a driver hands to {@link RunLifecycle}. */
|
|
58
|
+
export interface RunLifecycleDeps {
|
|
59
|
+
readonly persistence: WorkflowPersistenceAdapter;
|
|
60
|
+
/** Observability sink; defaults to the shared `workflow` category logger. */
|
|
61
|
+
readonly logger?: Logger;
|
|
62
|
+
/** Optional event bus for structured in-process run observability. */
|
|
63
|
+
readonly events?: EventBus<WorkflowEngineEvents>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Owns the run-level bookkeeping shared by both drivers: run identity, the
|
|
68
|
+
* create→phase→finalize persistence sequence, and observability (one OTel span
|
|
69
|
+
* per run plus structured log lines). The two driver control loops stay
|
|
70
|
+
* dialect-specific (ADR-006 §7) and call into this for every persistence touch.
|
|
71
|
+
*/
|
|
72
|
+
export class RunLifecycle {
|
|
73
|
+
readonly runId: string;
|
|
74
|
+
private readonly persistence: WorkflowPersistenceAdapter;
|
|
75
|
+
private readonly events: EventBus<WorkflowEngineEvents> | undefined;
|
|
76
|
+
private readonly logger: Logger;
|
|
77
|
+
private readonly startedAt: string;
|
|
78
|
+
|
|
79
|
+
private constructor(
|
|
80
|
+
runId: string,
|
|
81
|
+
private readonly workflowName: string,
|
|
82
|
+
private readonly mode: WorkflowMode,
|
|
83
|
+
deps: RunLifecycleDeps,
|
|
84
|
+
) {
|
|
85
|
+
this.runId = runId;
|
|
86
|
+
this.persistence = deps.persistence;
|
|
87
|
+
this.events = deps.events;
|
|
88
|
+
this.startedAt = new Date().toISOString();
|
|
89
|
+
this.logger = (deps.logger ?? getLogger('workflow')).child({ runId, workflow: workflowName, mode });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Create the run record and execute `loop` inside the run's OTel span. The
|
|
94
|
+
* driver's control loop is the body; it receives this lifecycle to drive
|
|
95
|
+
* per-step persistence and terminal results.
|
|
96
|
+
*/
|
|
97
|
+
static async run(
|
|
98
|
+
workflowName: string,
|
|
99
|
+
mode: WorkflowMode,
|
|
100
|
+
deps: RunLifecycleDeps,
|
|
101
|
+
options: WorkflowRunOptions,
|
|
102
|
+
loop: (lifecycle: RunLifecycle) => Promise<WorkflowRunResult>,
|
|
103
|
+
): Promise<WorkflowRunResult> {
|
|
104
|
+
const runId = options.runId ?? crypto.randomUUID();
|
|
105
|
+
const lifecycle = new RunLifecycle(runId, workflowName, mode, deps);
|
|
106
|
+
return await traceAsync(
|
|
107
|
+
'workflow.run',
|
|
108
|
+
async () => {
|
|
109
|
+
await lifecycle.persistence.createRun(lifecycle.runRecord(options.metadata));
|
|
110
|
+
lifecycle.logger.info('workflow run started');
|
|
111
|
+
addSpanEvent('workflow.run.started', { workflowName, mode, runId });
|
|
112
|
+
void lifecycle.events?.emit('workflow.run.started', { workflowName, mode, runId });
|
|
113
|
+
return await loop(lifecycle);
|
|
114
|
+
},
|
|
115
|
+
{ attributes: { 'workflow.name': workflowName, 'workflow.mode': mode, 'workflow.run_id': runId } },
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Persist the current state/node snapshot and mark its phase running. */
|
|
120
|
+
async enter(stateOrNodeId: string, transitionsTaken: number): Promise<void> {
|
|
121
|
+
await this.persistence.saveWorkflowState(this.runId, stateOrNodeId, { transitionsTaken });
|
|
122
|
+
await this.persistence.savePhase(this.runId, stateOrNodeId, 'running');
|
|
123
|
+
addSpanEvent('workflow.node.enter', { node: stateOrNodeId, transitionsTaken });
|
|
124
|
+
void this.events?.emit('workflow.node.enter', { node: stateOrNodeId, transitionsTaken });
|
|
125
|
+
this.logger.debug('entered', { node: stateOrNodeId, transitionsTaken });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Persist a transition and emit its observability event. */
|
|
129
|
+
async recordTransition(from: string, to: string, trigger: string | null): Promise<void> {
|
|
130
|
+
await this.persistence.saveTransition(this.runId, from, to, trigger);
|
|
131
|
+
addSpanEvent('workflow.node.transition', { from, to, ...(trigger === null ? {} : { trigger }) });
|
|
132
|
+
void this.events?.emit('workflow.node.transition', { from, to, trigger });
|
|
133
|
+
this.logger.debug('transition', { from, to, trigger });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Finalize the run as succeeded and return its result. */
|
|
137
|
+
async done(finalState: string, transitionsTaken: number): Promise<WorkflowRunResult> {
|
|
138
|
+
await this.persistence.savePhase(this.runId, finalState, 'done');
|
|
139
|
+
await this.persistence.finalizeRun(this.runId, 'done', new Date().toISOString());
|
|
140
|
+
this.logger.info('workflow run done', { finalState, transitionsTaken });
|
|
141
|
+
addSpanEvent('workflow.run.done', { finalState, transitionsTaken });
|
|
142
|
+
void this.events?.emit('workflow.run.done', { finalState, transitionsTaken });
|
|
143
|
+
return this.result('done', finalState, transitionsTaken);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Finalize the run as failed and return its result. */
|
|
147
|
+
async fail(finalState: string, transitionsTaken: number, reason = 'failed'): Promise<WorkflowRunResult> {
|
|
148
|
+
await this.persistence.savePhase(this.runId, finalState, 'failed');
|
|
149
|
+
await this.persistence.finalizeRun(this.runId, 'failed', new Date().toISOString());
|
|
150
|
+
addSpanEvent('workflow.run.failed', { finalState, reason });
|
|
151
|
+
void this.events?.emit('workflow.run.failed', { finalState, reason });
|
|
152
|
+
this.logger.warn('workflow run failed', { finalState, transitionsTaken, reason });
|
|
153
|
+
return this.result('failed', finalState, transitionsTaken, reason);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Emit action-level observability before a host action is invoked. */
|
|
157
|
+
actionStart(stateOrNodeId: string, kind: string): void {
|
|
158
|
+
addSpanEvent('workflow.action.start', { node: stateOrNodeId, kind });
|
|
159
|
+
void this.events?.emit('workflow.action.start', { node: stateOrNodeId, kind });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Emit action-level observability after a host action settles. */
|
|
163
|
+
actionDone(stateOrNodeId: string, kind: string, durationMs: number, ok: boolean): void {
|
|
164
|
+
addSpanEvent('workflow.action.done', { node: stateOrNodeId, kind, durationMs, ok });
|
|
165
|
+
void this.events?.emit('workflow.action.done', { node: stateOrNodeId, kind, durationMs, ok });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Log and trace a non-fatal action failure for the 'continue' error policy (ADR-013 observability seam). */
|
|
169
|
+
warnActionFailed(stateOrNodeId: string, transitionsTaken: number, error?: string): void {
|
|
170
|
+
addSpanEvent('workflow.action.failed_continue', {
|
|
171
|
+
node: stateOrNodeId,
|
|
172
|
+
transitionsTaken,
|
|
173
|
+
...(error === undefined ? {} : { error }),
|
|
174
|
+
});
|
|
175
|
+
void this.events?.emit('workflow.action.failed_continue', {
|
|
176
|
+
node: stateOrNodeId,
|
|
177
|
+
transitionsTaken,
|
|
178
|
+
...(error === undefined ? {} : { error }),
|
|
179
|
+
});
|
|
180
|
+
this.logger.warn('action failed (continuing)', { node: stateOrNodeId, transitionsTaken, error });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private result(
|
|
184
|
+
status: WorkflowStatus,
|
|
185
|
+
finalState: string,
|
|
186
|
+
transitionsTaken: number,
|
|
187
|
+
reason?: string,
|
|
188
|
+
): WorkflowRunResult {
|
|
189
|
+
return {
|
|
190
|
+
runId: this.runId,
|
|
191
|
+
workflowName: this.workflowName,
|
|
192
|
+
mode: this.mode,
|
|
193
|
+
status,
|
|
194
|
+
finalState,
|
|
195
|
+
transitionsTaken,
|
|
196
|
+
...(reason === undefined ? {} : { reason }),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private runRecord(metadata: unknown): WorkflowRunRecord {
|
|
201
|
+
return {
|
|
202
|
+
id: this.runId,
|
|
203
|
+
workflow_name: this.workflowName,
|
|
204
|
+
mode: this.mode,
|
|
205
|
+
status: 'running',
|
|
206
|
+
started_at: this.startedAt,
|
|
207
|
+
metadata_json: JSON.stringify(metadata ?? {}),
|
|
208
|
+
completed_at: null,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
}
|
package/src/schema-sql.ts
CHANGED
package/src/schema.ts
CHANGED
|
@@ -27,6 +27,7 @@ const EnvSchema = z.object({
|
|
|
27
27
|
export const ActionDefSchema = z.object({
|
|
28
28
|
kind: z.string().min(1),
|
|
29
29
|
options: z.record(z.string(), z.unknown()).optional(),
|
|
30
|
+
onError: z.enum(['fail', 'continue']).optional(),
|
|
30
31
|
});
|
|
31
32
|
|
|
32
33
|
/** Zod schema for workflow guard definitions. */
|
|
@@ -47,6 +48,7 @@ export const StateMachineWorkflowDefSchema = z
|
|
|
47
48
|
initialState: z.string().min(1),
|
|
48
49
|
terminalStates: z.array(z.string().min(1)).optional(),
|
|
49
50
|
iterationBound: z.number().int().positive().optional(),
|
|
51
|
+
defaultOnError: z.enum(['fail', 'continue']).optional(),
|
|
50
52
|
vars: VarsSchema.optional(),
|
|
51
53
|
env: EnvSchema.optional(),
|
|
52
54
|
states: z.array(
|
|
@@ -85,6 +87,7 @@ export const TransitionFlowWorkflowDefSchema = z
|
|
|
85
87
|
initialNode: z.string().min(1),
|
|
86
88
|
terminalNodes: z.array(z.string().min(1)).optional(),
|
|
87
89
|
iterationBound: z.number().int().positive().optional(),
|
|
90
|
+
defaultOnError: z.enum(['fail', 'continue']).optional(),
|
|
88
91
|
vars: VarsSchema.optional(),
|
|
89
92
|
env: EnvSchema.optional(),
|
|
90
93
|
nodes: z.array(
|