@gobing-ai/ts-dual-workflow-engine 0.3.1 → 0.3.3

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 (45) hide show
  1. package/README.md +506 -5
  2. package/dist/config.d.ts +1 -0
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +7 -11
  5. package/dist/events.d.ts +49 -0
  6. package/dist/events.d.ts.map +1 -0
  7. package/dist/events.js +0 -0
  8. package/dist/extensions.d.ts +60 -0
  9. package/dist/extensions.d.ts.map +1 -0
  10. package/dist/extensions.js +85 -0
  11. package/dist/index.d.ts +5 -2
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +3 -1
  14. package/dist/run-lifecycle.d.ts +58 -0
  15. package/dist/run-lifecycle.d.ts.map +1 -0
  16. package/dist/run-lifecycle.js +149 -0
  17. package/dist/schema-sql.d.ts +1 -0
  18. package/dist/schema-sql.d.ts.map +1 -1
  19. package/dist/schema-sql.js +1 -0
  20. package/dist/schema.d.ts +44 -0
  21. package/dist/schema.d.ts.map +1 -1
  22. package/dist/schema.js +3 -0
  23. package/dist/state-machine.d.ts +7 -2
  24. package/dist/state-machine.d.ts.map +1 -1
  25. package/dist/state-machine.js +63 -72
  26. package/dist/transition-flow.d.ts +1 -2
  27. package/dist/transition-flow.d.ts.map +1 -1
  28. package/dist/transition-flow.js +35 -61
  29. package/dist/types.d.ts +14 -0
  30. package/dist/types.d.ts.map +1 -1
  31. package/dist/variables.d.ts +6 -1
  32. package/dist/variables.d.ts.map +1 -1
  33. package/dist/variables.js +7 -0
  34. package/package.json +4 -4
  35. package/src/config.ts +8 -11
  36. package/src/events.ts +19 -0
  37. package/src/extensions.ts +163 -0
  38. package/src/index.ts +24 -1
  39. package/src/run-lifecycle.ts +211 -0
  40. package/src/schema-sql.ts +1 -0
  41. package/src/schema.ts +3 -0
  42. package/src/state-machine.ts +78 -128
  43. package/src/transition-flow.ts +47 -105
  44. package/src/types.ts +16 -0
  45. package/src/variables.ts +13 -1
@@ -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/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 { mergeVars, resolveTemplateString, resolveTemplates, type VariableContext } from './variables';
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
@@ -1,3 +1,4 @@
1
+ /** SQL DDL for the dual-workflow engine's persistent schema — runs, phase_runs, transition_runs, and workflow_states tables. */
1
2
  export const WORKFLOW_ENGINE_SCHEMA_SQL = `
2
3
  CREATE TABLE IF NOT EXISTS runs (
3
4
  id TEXT PRIMARY KEY,
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(