@gobing-ai/ts-dual-workflow-engine 0.2.1
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 +4 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +51 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +25 -0
- package/dist/host.d.ts +34 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +93 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/persistence.d.ts +58 -0
- package/dist/persistence.d.ts.map +1 -0
- package/dist/persistence.js +98 -0
- package/dist/schema-sql.d.ts +2 -0
- package/dist/schema-sql.d.ts.map +1 -0
- package/dist/schema-sql.js +48 -0
- package/dist/schema.d.ts +140 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +54 -0
- package/dist/service.d.ts +17 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +31 -0
- package/dist/state-machine.d.ts +18 -0
- package/dist/state-machine.d.ts.map +1 -0
- package/dist/state-machine.js +123 -0
- package/dist/transition-flow.d.ts +17 -0
- package/dist/transition-flow.d.ts.map +1 -0
- package/dist/transition-flow.js +114 -0
- package/dist/types.d.ts +141 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +0 -0
- package/dist/variables.d.ts +14 -0
- package/dist/variables.d.ts.map +1 -0
- package/dist/variables.js +45 -0
- package/package.json +61 -0
- package/src/config.ts +57 -0
- package/src/errors.ts +29 -0
- package/src/host.ts +100 -0
- package/src/index.ts +48 -0
- package/src/persistence.ts +155 -0
- package/src/schema-sql.ts +48 -0
- package/src/schema.ts +67 -0
- package/src/service.ts +39 -0
- package/src/state-machine.ts +223 -0
- package/src/transition-flow.ts +178 -0
- package/src/types.ts +160 -0
- package/src/variables.ts +57 -0
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gobing-ai/ts-dual-workflow-engine",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "@gobing-ai/ts-dual-workflow-engine — State-machine and transition-flow workflow runtime.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"typescript",
|
|
7
|
+
"workflow",
|
|
8
|
+
"state-machine",
|
|
9
|
+
"transition-flow",
|
|
10
|
+
"dag"
|
|
11
|
+
],
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/gobing-ai/ts-libs.git",
|
|
15
|
+
"directory": "packages/dual-workflow-engine"
|
|
16
|
+
},
|
|
17
|
+
"author": "Robin Min <minlongbing@gmail.com>",
|
|
18
|
+
"contributors": [
|
|
19
|
+
"Robin Min <minlongbing@gmail.com>"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"private": false,
|
|
23
|
+
"sideEffects": false,
|
|
24
|
+
"license": "Apache-2.0",
|
|
25
|
+
"main": "./dist/index.js",
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"import": "./dist/index.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"src",
|
|
36
|
+
"README.md"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc -p tsconfig.build.json && bun ../../scripts/builder.ts fix-dist-esm-extensions dist",
|
|
40
|
+
"test": "NODE_ENV=test bun test --coverage --coverage-dir=.coverage --reporter=dots",
|
|
41
|
+
"test:full": "NODE_ENV=test bun test --update-snapshots --coverage --coverage-dir=.coverage",
|
|
42
|
+
"typecheck": "tsc --noEmit",
|
|
43
|
+
"lint": "biome check . && bun run typecheck",
|
|
44
|
+
"format": "biome check . --write",
|
|
45
|
+
"check": "bun run lint && bun run test",
|
|
46
|
+
"prepublishOnly": "bun run build",
|
|
47
|
+
"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"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@gobing-ai/ts-db": "workspace:*",
|
|
51
|
+
"@gobing-ai/ts-runtime": "workspace:*",
|
|
52
|
+
"yaml": "^2.7.0",
|
|
53
|
+
"zod": "^4.1.0"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@types/bun": "1.3.14"
|
|
57
|
+
},
|
|
58
|
+
"publishConfig": {
|
|
59
|
+
"access": "public"
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { getFs } from '@gobing-ai/ts-runtime';
|
|
2
|
+
import { parse } from 'yaml';
|
|
3
|
+
import { WorkflowValidationError } from './errors';
|
|
4
|
+
import { WorkflowDefSchema } from './schema';
|
|
5
|
+
import type { WorkflowDef } from './types';
|
|
6
|
+
|
|
7
|
+
/** Load a workflow definition from YAML or JSON text. */
|
|
8
|
+
export function loadWorkflowDefFromText(text: string, source = '<inline>'): WorkflowDef {
|
|
9
|
+
const parsed = source.endsWith('.json') ? JSON.parse(text) : parse(text);
|
|
10
|
+
const result = WorkflowDefSchema.safeParse(parsed);
|
|
11
|
+
if (!result.success) {
|
|
12
|
+
throw new WorkflowValidationError(
|
|
13
|
+
`Workflow definition failed schema validation for ${source}: ${result.error.issues.map((issue) => issue.message).join('; ')}`,
|
|
14
|
+
result.error.issues,
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
validateWorkflowDef(result.data as WorkflowDef);
|
|
18
|
+
return result.data as WorkflowDef;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Load a workflow definition from a filesystem path. */
|
|
22
|
+
export async function loadWorkflowDef(path: string): Promise<WorkflowDef> {
|
|
23
|
+
return loadWorkflowDefFromText(await getFs().readFile(path), path);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Validate semantic workflow invariants beyond structural Zod checks. */
|
|
27
|
+
export function validateWorkflowDef(workflow: WorkflowDef): void {
|
|
28
|
+
if (workflow.kind === 'transition-flow') {
|
|
29
|
+
validateTransitionFlow(workflow);
|
|
30
|
+
} else {
|
|
31
|
+
validateStateMachine(workflow);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function validateStateMachine(workflow: Extract<WorkflowDef, { kind?: 'state-machine' }>): void {
|
|
36
|
+
const states = new Set(workflow.states.map((state) => state.id));
|
|
37
|
+
if (!states.has(workflow.initialState)) {
|
|
38
|
+
throw new WorkflowValidationError(`Initial state "${workflow.initialState}" is not declared`);
|
|
39
|
+
}
|
|
40
|
+
for (const transition of workflow.transitions) {
|
|
41
|
+
if (!states.has(transition.from))
|
|
42
|
+
throw new WorkflowValidationError(`Transition source "${transition.from}" is not declared`);
|
|
43
|
+
if (!states.has(transition.to))
|
|
44
|
+
throw new WorkflowValidationError(`Transition target "${transition.to}" is not declared`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function validateTransitionFlow(workflow: Extract<WorkflowDef, { kind: 'transition-flow' }>): void {
|
|
49
|
+
const nodes = new Set(workflow.nodes.map((node) => node.id));
|
|
50
|
+
if (!nodes.has(workflow.initialNode)) {
|
|
51
|
+
throw new WorkflowValidationError(`Initial node "${workflow.initialNode}" is not declared`);
|
|
52
|
+
}
|
|
53
|
+
for (const edge of workflow.edges) {
|
|
54
|
+
if (!nodes.has(edge.from)) throw new WorkflowValidationError(`Edge source "${edge.from}" is not declared`);
|
|
55
|
+
if (!nodes.has(edge.to)) throw new WorkflowValidationError(`Edge target "${edge.to}" is not declared`);
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Error raised when workflow definition validation fails. */
|
|
2
|
+
export class WorkflowValidationError extends Error {
|
|
3
|
+
constructor(
|
|
4
|
+
message: string,
|
|
5
|
+
readonly details?: unknown,
|
|
6
|
+
) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'WorkflowValidationError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Error raised by the state-machine driver for invalid runtime state. */
|
|
13
|
+
export class FSMError extends Error {
|
|
14
|
+
constructor(
|
|
15
|
+
message: string,
|
|
16
|
+
readonly details?: unknown,
|
|
17
|
+
) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = 'FSMError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Error raised when a run id collides with an existing persisted run. */
|
|
24
|
+
export class RunCollisionError extends Error {
|
|
25
|
+
constructor(runId: string) {
|
|
26
|
+
super(`Workflow run "${runId}" already exists`);
|
|
27
|
+
this.name = 'RunCollisionError';
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/host.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { NodeProcessExecutor, type ProcessExecutor } from '@gobing-ai/ts-runtime';
|
|
2
|
+
import { WorkflowValidationError } from './errors';
|
|
3
|
+
import type { ActionResult, ActionRunContext, ActionRunner, GuardContext, GuardRunner } from './types';
|
|
4
|
+
|
|
5
|
+
/** Registry owner for workflow actions and guards. */
|
|
6
|
+
export class WorkflowEngineHost {
|
|
7
|
+
private readonly actions = new Map<string, ActionRunner>();
|
|
8
|
+
private readonly guards = new Map<string, GuardRunner>();
|
|
9
|
+
|
|
10
|
+
/** Register or replace an action runner. */
|
|
11
|
+
registerAction(action: ActionRunner): this {
|
|
12
|
+
this.actions.set(action.kind, action);
|
|
13
|
+
return this;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Register or replace a guard runner. */
|
|
17
|
+
registerGuard(guard: GuardRunner): this {
|
|
18
|
+
this.guards.set(guard.kind, guard);
|
|
19
|
+
return this;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Execute a registered action. */
|
|
23
|
+
async runAction(kind: string, options: Record<string, unknown>, context: ActionRunContext): Promise<ActionResult> {
|
|
24
|
+
const action = this.actions.get(kind);
|
|
25
|
+
if (action === undefined) throw new WorkflowValidationError(`Unknown workflow action "${kind}"`);
|
|
26
|
+
return await action.execute(options, context);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Evaluate a registered guard. */
|
|
30
|
+
async evaluateGuard(kind: string, options: Record<string, unknown>, context: GuardContext): Promise<boolean> {
|
|
31
|
+
const guard = this.guards.get(kind);
|
|
32
|
+
if (guard === undefined) throw new WorkflowValidationError(`Unknown workflow guard "${kind}"`);
|
|
33
|
+
return await guard.evaluate(options, context);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Create a workflow host with built-in note, shell, always, and action-ok capabilities. */
|
|
38
|
+
export function createDefaultWorkflowEngineHost(
|
|
39
|
+
options: { processExecutor?: ProcessExecutor } = {},
|
|
40
|
+
): WorkflowEngineHost {
|
|
41
|
+
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
|
+
kind: 'action-ok',
|
|
48
|
+
evaluate: async (_options, context) => context.lastActionResult?.ok === true,
|
|
49
|
+
});
|
|
50
|
+
return host;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Built-in action that records a note in result data. */
|
|
54
|
+
export class NoteActionRunner implements ActionRunner {
|
|
55
|
+
readonly kind = 'note';
|
|
56
|
+
|
|
57
|
+
/** Execute a no-op note action. */
|
|
58
|
+
async execute(options: Record<string, unknown>): Promise<ActionResult> {
|
|
59
|
+
return { ok: true, data: { message: String(options.message ?? '') } };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Built-in shell action backed by ts-runtime ProcessExecutor. */
|
|
64
|
+
export class ShellActionRunner implements ActionRunner {
|
|
65
|
+
readonly kind = 'shell';
|
|
66
|
+
|
|
67
|
+
constructor(private readonly processExecutor: ProcessExecutor) {}
|
|
68
|
+
|
|
69
|
+
/** Execute a shell command with optional args and cwd. */
|
|
70
|
+
async execute(options: Record<string, unknown>, context: ActionRunContext): Promise<ActionResult> {
|
|
71
|
+
const command = stringOption(options, 'command');
|
|
72
|
+
const args = arrayOption(options, 'args');
|
|
73
|
+
const result = await this.processExecutor.run({
|
|
74
|
+
command,
|
|
75
|
+
args,
|
|
76
|
+
cwd: stringOption(options, 'cwd', context.workdir),
|
|
77
|
+
rejectOnError: false,
|
|
78
|
+
forceBuffered: true,
|
|
79
|
+
});
|
|
80
|
+
return {
|
|
81
|
+
ok: result.exitCode === 0,
|
|
82
|
+
data: { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode },
|
|
83
|
+
...(result.exitCode === 0 ? {} : { error: `Command "${command}" exited with ${result.exitCode}` }),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function stringOption(options: Record<string, unknown>, key: string, fallback?: string): string {
|
|
89
|
+
const value = options[key];
|
|
90
|
+
if (typeof value === 'string') return value;
|
|
91
|
+
if (fallback !== undefined) return fallback;
|
|
92
|
+
throw new WorkflowValidationError(`Action option "${key}" must be a string`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function arrayOption(options: Record<string, unknown>, key: string): string[] {
|
|
96
|
+
const value = options[key];
|
|
97
|
+
if (value === undefined) return [];
|
|
98
|
+
if (Array.isArray(value) && value.every((entry) => typeof entry === 'string')) return value;
|
|
99
|
+
throw new WorkflowValidationError(`Action option "${key}" must be a string array`);
|
|
100
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export { loadWorkflowDef, loadWorkflowDefFromText, validateWorkflowDef } from './config';
|
|
2
|
+
export { FSMError, RunCollisionError, WorkflowValidationError } from './errors';
|
|
3
|
+
export {
|
|
4
|
+
createDefaultWorkflowEngineHost,
|
|
5
|
+
NoteActionRunner,
|
|
6
|
+
ShellActionRunner,
|
|
7
|
+
WorkflowEngineHost,
|
|
8
|
+
} from './host';
|
|
9
|
+
export {
|
|
10
|
+
applyWorkflowEngineSchema,
|
|
11
|
+
DbWorkflowPersistenceAdapter,
|
|
12
|
+
MemoryWorkflowPersistenceAdapter,
|
|
13
|
+
} from './persistence';
|
|
14
|
+
export {
|
|
15
|
+
ActionDefSchema,
|
|
16
|
+
GuardDefSchema,
|
|
17
|
+
StateMachineWorkflowDefSchema,
|
|
18
|
+
TransitionFlowWorkflowDefSchema,
|
|
19
|
+
WorkflowDefSchema,
|
|
20
|
+
} from './schema';
|
|
21
|
+
export { WORKFLOW_ENGINE_SCHEMA_SQL } from './schema-sql';
|
|
22
|
+
export { WorkflowService } from './service';
|
|
23
|
+
export { StateMachineDriver, type StateMachineDriverOptions } from './state-machine';
|
|
24
|
+
export { TransitionFlowDriver, type TransitionFlowDriverOptions } from './transition-flow';
|
|
25
|
+
export type {
|
|
26
|
+
ActionDef,
|
|
27
|
+
ActionResult,
|
|
28
|
+
ActionRunContext,
|
|
29
|
+
ActionRunner,
|
|
30
|
+
Env,
|
|
31
|
+
FlowEdgeDef,
|
|
32
|
+
FlowNodeDef,
|
|
33
|
+
GuardContext,
|
|
34
|
+
GuardDef,
|
|
35
|
+
GuardRunner,
|
|
36
|
+
StateDef,
|
|
37
|
+
StateMachineWorkflowDef,
|
|
38
|
+
TransitionDef,
|
|
39
|
+
TransitionFlowWorkflowDef,
|
|
40
|
+
Vars,
|
|
41
|
+
WorkflowDef,
|
|
42
|
+
WorkflowPersistenceAdapter,
|
|
43
|
+
WorkflowRunOptions,
|
|
44
|
+
WorkflowRunRecord,
|
|
45
|
+
WorkflowRunResult,
|
|
46
|
+
WorkflowStatus,
|
|
47
|
+
} from './types';
|
|
48
|
+
export { mergeVars, resolveTemplateString, resolveTemplates, type VariableContext } from './variables';
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { DbAdapter } from '@gobing-ai/ts-db';
|
|
2
|
+
import { RunCollisionError } from './errors';
|
|
3
|
+
import { WORKFLOW_ENGINE_SCHEMA_SQL } from './schema-sql';
|
|
4
|
+
import type { WorkflowPersistenceAdapter, WorkflowRunRecord, WorkflowStatus } from './types';
|
|
5
|
+
|
|
6
|
+
/** Apply workflow-engine-owned schema to a database adapter. */
|
|
7
|
+
export async function applyWorkflowEngineSchema(db: DbAdapter): Promise<void> {
|
|
8
|
+
for (const statement of WORKFLOW_ENGINE_SCHEMA_SQL.split(';')) {
|
|
9
|
+
const sql = statement.trim();
|
|
10
|
+
if (sql.length > 0) await db.exec(sql);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** SQLite/D1-compatible workflow persistence adapter backed by ts-db. */
|
|
15
|
+
export class DbWorkflowPersistenceAdapter implements WorkflowPersistenceAdapter {
|
|
16
|
+
constructor(private readonly db: DbAdapter) {}
|
|
17
|
+
|
|
18
|
+
/** Create a run row, rejecting duplicate run ids. */
|
|
19
|
+
async createRun(record: WorkflowRunRecord): Promise<void> {
|
|
20
|
+
const existing = await this.loadRun(record.id);
|
|
21
|
+
if (existing !== undefined) throw new RunCollisionError(record.id);
|
|
22
|
+
await applyWorkflowEngineSchema(this.db);
|
|
23
|
+
await this.db.run(
|
|
24
|
+
`INSERT INTO runs (id, workflow_name, mode, status, started_at, completed_at, metadata_json, created_at, updated_at)
|
|
25
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
26
|
+
record.id,
|
|
27
|
+
record.workflow_name,
|
|
28
|
+
record.mode,
|
|
29
|
+
record.status,
|
|
30
|
+
record.started_at,
|
|
31
|
+
record.completed_at,
|
|
32
|
+
record.metadata_json,
|
|
33
|
+
Date.now(),
|
|
34
|
+
Date.now(),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Finalize a run with terminal status and timestamp. */
|
|
39
|
+
async finalizeRun(runId: string, status: WorkflowStatus, completedAt: string): Promise<void> {
|
|
40
|
+
await this.db.run(
|
|
41
|
+
'UPDATE runs SET status = ?, completed_at = ?, updated_at = ? WHERE id = ?',
|
|
42
|
+
status,
|
|
43
|
+
completedAt,
|
|
44
|
+
Date.now(),
|
|
45
|
+
runId,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Save one phase/state execution record. */
|
|
50
|
+
async savePhase(runId: string, phase: string, status: WorkflowStatus): Promise<void> {
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
await this.db.run(
|
|
53
|
+
`INSERT INTO phase_runs (id, run_id, phase, status, started_at, completed_at, created_at, updated_at)
|
|
54
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
55
|
+
`${runId}:phase:${phase}:${crypto.randomUUID()}`,
|
|
56
|
+
runId,
|
|
57
|
+
phase,
|
|
58
|
+
status,
|
|
59
|
+
new Date(now).toISOString(),
|
|
60
|
+
status === 'running' ? null : new Date(now).toISOString(),
|
|
61
|
+
now,
|
|
62
|
+
now,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Save one transition record. */
|
|
67
|
+
async saveTransition(runId: string, from: string, to: string, trigger: string | null): Promise<void> {
|
|
68
|
+
const now = Date.now();
|
|
69
|
+
await this.db.run(
|
|
70
|
+
`INSERT INTO transition_runs (id, run_id, from_state, to_state, trigger, status, created_at, updated_at)
|
|
71
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
72
|
+
`${runId}:transition:${from}:${to}:${crypto.randomUUID()}`,
|
|
73
|
+
runId,
|
|
74
|
+
from,
|
|
75
|
+
to,
|
|
76
|
+
trigger,
|
|
77
|
+
'done',
|
|
78
|
+
now,
|
|
79
|
+
now,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Save the latest workflow state snapshot. */
|
|
84
|
+
async saveWorkflowState(runId: string, state: string, data: Record<string, unknown>): Promise<void> {
|
|
85
|
+
const now = Date.now();
|
|
86
|
+
await this.db.run(
|
|
87
|
+
`INSERT INTO workflow_states (id, run_id, state, data_json, created_at, updated_at)
|
|
88
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
89
|
+
`${runId}:state:${state}:${crypto.randomUUID()}`,
|
|
90
|
+
runId,
|
|
91
|
+
state,
|
|
92
|
+
JSON.stringify(data),
|
|
93
|
+
now,
|
|
94
|
+
now,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Load a single run by id. */
|
|
99
|
+
async loadRun(runId: string): Promise<WorkflowRunRecord | undefined> {
|
|
100
|
+
await applyWorkflowEngineSchema(this.db);
|
|
101
|
+
const row = await this.db.queryFirst<WorkflowRunRecord>('SELECT * FROM runs WHERE id = ?', runId);
|
|
102
|
+
return row ?? undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** List persisted workflow runs. */
|
|
106
|
+
async listRuns(): Promise<readonly WorkflowRunRecord[]> {
|
|
107
|
+
await applyWorkflowEngineSchema(this.db);
|
|
108
|
+
return await this.db.queryAll<WorkflowRunRecord>('SELECT * FROM runs ORDER BY started_at DESC');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** In-memory persistence adapter for tests and embedding. */
|
|
113
|
+
export class MemoryWorkflowPersistenceAdapter implements WorkflowPersistenceAdapter {
|
|
114
|
+
readonly runs = new Map<string, WorkflowRunRecord>();
|
|
115
|
+
readonly phases: Array<{ runId: string; phase: string; status: WorkflowStatus }> = [];
|
|
116
|
+
readonly transitions: Array<{ runId: string; from: string; to: string; trigger: string | null }> = [];
|
|
117
|
+
readonly states: Array<{ runId: string; state: string; data: Record<string, unknown> }> = [];
|
|
118
|
+
|
|
119
|
+
/** Create a run row, rejecting duplicate run ids. */
|
|
120
|
+
async createRun(record: WorkflowRunRecord): Promise<void> {
|
|
121
|
+
if (this.runs.has(record.id)) throw new RunCollisionError(record.id);
|
|
122
|
+
this.runs.set(record.id, record);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Finalize a run with terminal status and timestamp. */
|
|
126
|
+
async finalizeRun(runId: string, status: WorkflowStatus, completedAt: string): Promise<void> {
|
|
127
|
+
const run = this.runs.get(runId);
|
|
128
|
+
if (run !== undefined) this.runs.set(runId, { ...run, status, completed_at: completedAt });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Save one phase/state execution record. */
|
|
132
|
+
async savePhase(runId: string, phase: string, status: WorkflowStatus): Promise<void> {
|
|
133
|
+
this.phases.push({ runId, phase, status });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Save one transition record. */
|
|
137
|
+
async saveTransition(runId: string, from: string, to: string, trigger: string | null): Promise<void> {
|
|
138
|
+
this.transitions.push({ runId, from, to, trigger });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Save the latest workflow state snapshot. */
|
|
142
|
+
async saveWorkflowState(runId: string, state: string, data: Record<string, unknown>): Promise<void> {
|
|
143
|
+
this.states.push({ runId, state, data });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Load a single run by id. */
|
|
147
|
+
async loadRun(runId: string): Promise<WorkflowRunRecord | undefined> {
|
|
148
|
+
return this.runs.get(runId);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** List persisted workflow runs. */
|
|
152
|
+
async listRuns(): Promise<readonly WorkflowRunRecord[]> {
|
|
153
|
+
return [...this.runs.values()];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export const WORKFLOW_ENGINE_SCHEMA_SQL = `
|
|
2
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
3
|
+
id TEXT PRIMARY KEY,
|
|
4
|
+
workflow_name TEXT,
|
|
5
|
+
mode TEXT,
|
|
6
|
+
status TEXT NOT NULL,
|
|
7
|
+
agent TEXT,
|
|
8
|
+
started_at TEXT NOT NULL,
|
|
9
|
+
completed_at TEXT,
|
|
10
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
11
|
+
created_at INTEGER NOT NULL DEFAULT 0,
|
|
12
|
+
updated_at INTEGER NOT NULL DEFAULT 0
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
CREATE TABLE IF NOT EXISTS phase_runs (
|
|
16
|
+
id TEXT PRIMARY KEY,
|
|
17
|
+
run_id TEXT NOT NULL,
|
|
18
|
+
phase TEXT NOT NULL,
|
|
19
|
+
status TEXT NOT NULL,
|
|
20
|
+
started_at TEXT,
|
|
21
|
+
completed_at TEXT,
|
|
22
|
+
created_at INTEGER NOT NULL DEFAULT 0,
|
|
23
|
+
updated_at INTEGER NOT NULL DEFAULT 0,
|
|
24
|
+
FOREIGN KEY (run_id) REFERENCES runs(id)
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
CREATE TABLE IF NOT EXISTS transition_runs (
|
|
28
|
+
id TEXT PRIMARY KEY,
|
|
29
|
+
run_id TEXT NOT NULL,
|
|
30
|
+
from_state TEXT NOT NULL,
|
|
31
|
+
to_state TEXT NOT NULL,
|
|
32
|
+
trigger TEXT,
|
|
33
|
+
status TEXT NOT NULL,
|
|
34
|
+
created_at INTEGER NOT NULL DEFAULT 0,
|
|
35
|
+
updated_at INTEGER NOT NULL DEFAULT 0,
|
|
36
|
+
FOREIGN KEY (run_id) REFERENCES runs(id)
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
CREATE TABLE IF NOT EXISTS workflow_states (
|
|
40
|
+
id TEXT PRIMARY KEY,
|
|
41
|
+
run_id TEXT NOT NULL,
|
|
42
|
+
state TEXT NOT NULL,
|
|
43
|
+
data_json TEXT NOT NULL,
|
|
44
|
+
created_at INTEGER NOT NULL DEFAULT 0,
|
|
45
|
+
updated_at INTEGER NOT NULL DEFAULT 0,
|
|
46
|
+
FOREIGN KEY (run_id) REFERENCES runs(id)
|
|
47
|
+
);
|
|
48
|
+
`.trim();
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/** Zod schema for workflow action definitions. */
|
|
4
|
+
export const ActionDefSchema = z.object({
|
|
5
|
+
kind: z.string().min(1),
|
|
6
|
+
options: z.record(z.string(), z.unknown()).optional(),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
/** Zod schema for workflow guard definitions. */
|
|
10
|
+
export const GuardDefSchema = z.object({
|
|
11
|
+
kind: z.string().min(1),
|
|
12
|
+
options: z.record(z.string(), z.unknown()).optional(),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/** Zod schema for state-machine workflow definitions. */
|
|
16
|
+
export const StateMachineWorkflowDefSchema = z.object({
|
|
17
|
+
kind: z.literal('state-machine').optional(),
|
|
18
|
+
name: z.string().min(1),
|
|
19
|
+
initialState: z.string().min(1),
|
|
20
|
+
terminalStates: z.array(z.string().min(1)).optional(),
|
|
21
|
+
iterationBound: z.number().int().positive().optional(),
|
|
22
|
+
vars: z.record(z.string(), z.string()).optional(),
|
|
23
|
+
env: z.object({ allow: z.array(z.string()).optional() }).optional(),
|
|
24
|
+
states: z.array(
|
|
25
|
+
z.object({
|
|
26
|
+
id: z.string().min(1),
|
|
27
|
+
onEnter: z.array(ActionDefSchema).optional(),
|
|
28
|
+
onExit: z.array(ActionDefSchema).optional(),
|
|
29
|
+
}),
|
|
30
|
+
),
|
|
31
|
+
transitions: z.array(
|
|
32
|
+
z.object({
|
|
33
|
+
from: z.string().min(1),
|
|
34
|
+
to: z.string().min(1),
|
|
35
|
+
trigger: z.string().optional(),
|
|
36
|
+
guard: GuardDefSchema.optional(),
|
|
37
|
+
}),
|
|
38
|
+
),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** Zod schema for transition-flow workflow definitions. */
|
|
42
|
+
export const TransitionFlowWorkflowDefSchema = z.object({
|
|
43
|
+
kind: z.literal('transition-flow'),
|
|
44
|
+
name: z.string().min(1),
|
|
45
|
+
initialNode: z.string().min(1),
|
|
46
|
+
terminalNodes: z.array(z.string().min(1)).optional(),
|
|
47
|
+
iterationBound: z.number().int().positive().optional(),
|
|
48
|
+
vars: z.record(z.string(), z.string()).optional(),
|
|
49
|
+
env: z.object({ allow: z.array(z.string()).optional() }).optional(),
|
|
50
|
+
nodes: z.array(
|
|
51
|
+
z.object({
|
|
52
|
+
id: z.string().min(1),
|
|
53
|
+
type: z.enum(['action', 'gate', 'parallel', 'decision']).optional(),
|
|
54
|
+
action: ActionDefSchema.optional(),
|
|
55
|
+
}),
|
|
56
|
+
),
|
|
57
|
+
edges: z.array(
|
|
58
|
+
z.object({
|
|
59
|
+
from: z.string().min(1),
|
|
60
|
+
to: z.string().min(1),
|
|
61
|
+
condition: GuardDefSchema.optional(),
|
|
62
|
+
}),
|
|
63
|
+
),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/** Zod schema for either supported workflow definition shape. */
|
|
67
|
+
export const WorkflowDefSchema = z.union([StateMachineWorkflowDefSchema, TransitionFlowWorkflowDefSchema]);
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { loadWorkflowDef } from './config';
|
|
2
|
+
import type { WorkflowEngineHost } from './host';
|
|
3
|
+
import { StateMachineDriver } from './state-machine';
|
|
4
|
+
import { TransitionFlowDriver } from './transition-flow';
|
|
5
|
+
import type { WorkflowDef, WorkflowPersistenceAdapter, WorkflowRunOptions, WorkflowRunResult } from './types';
|
|
6
|
+
|
|
7
|
+
/** High-level workflow service for loading, running, and listing persisted workflow runs. */
|
|
8
|
+
export class WorkflowService {
|
|
9
|
+
constructor(
|
|
10
|
+
private readonly host: WorkflowEngineHost,
|
|
11
|
+
private readonly persistence: WorkflowPersistenceAdapter,
|
|
12
|
+
) {}
|
|
13
|
+
|
|
14
|
+
/** Load a workflow file and validate it. */
|
|
15
|
+
async load(path: string): Promise<WorkflowDef> {
|
|
16
|
+
return await loadWorkflowDef(path);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Run an already-loaded workflow definition. */
|
|
20
|
+
async run(workflow: WorkflowDef, options: WorkflowRunOptions = {}): Promise<WorkflowRunResult> {
|
|
21
|
+
if (workflow.kind === 'transition-flow') {
|
|
22
|
+
return await new TransitionFlowDriver({ host: this.host, persistence: this.persistence }).run(
|
|
23
|
+
workflow,
|
|
24
|
+
options,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return await new StateMachineDriver({ host: this.host, persistence: this.persistence }).run(workflow, options);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Load and run a workflow file. */
|
|
31
|
+
async runFile(path: string, options: WorkflowRunOptions = {}): Promise<WorkflowRunResult> {
|
|
32
|
+
return await this.run(await this.load(path), options);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** List persisted workflow runs. */
|
|
36
|
+
async listRuns() {
|
|
37
|
+
return await this.persistence.listRuns();
|
|
38
|
+
}
|
|
39
|
+
}
|