@gobing-ai/ts-dual-workflow-engine 0.4.28 → 0.4.31
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 +47 -4
- package/dist/action-step.d.ts.map +1 -1
- package/dist/action-step.js +2 -1
- package/dist/events.d.ts +19 -0
- package/dist/events.d.ts.map +1 -1
- package/dist/extensions.d.ts +18 -5
- package/dist/extensions.d.ts.map +1 -1
- package/dist/extensions.js +25 -18
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +2 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/persistence.d.ts +12 -3
- package/dist/persistence.d.ts.map +1 -1
- package/dist/persistence.js +47 -12
- package/dist/run-lifecycle.d.ts.map +1 -1
- package/dist/run-lifecycle.js +29 -4
- package/dist/schema.d.ts +25 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +34 -0
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +9 -1
- package/dist/types.d.ts +21 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -4
- package/schemas/state-machine-workflow.schema.json +23 -1
- package/schemas/transition-flow-workflow.schema.json +23 -1
- package/src/action-step.ts +3 -0
- package/src/events.ts +42 -8
- package/src/extensions.ts +37 -27
- package/src/host.ts +2 -1
- package/src/index.ts +3 -0
- package/src/persistence.ts +53 -15
- package/src/run-lifecycle.ts +29 -4
- package/src/schema.ts +35 -0
- package/src/service.ts +9 -1
- package/src/types.ts +22 -1
package/src/events.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { EventSeverity } from '@gobing-ai/ts-infra';
|
|
2
|
+
|
|
1
3
|
/** Typed event map for workflow-engine run observability. All events prefixed `workflow.`. */
|
|
2
4
|
export type WorkflowEngineEvents = {
|
|
3
5
|
/** Emitted when a run begins (inside the span). */
|
|
@@ -7,6 +9,7 @@ export type WorkflowEngineEvents = {
|
|
|
7
9
|
runId: string;
|
|
8
10
|
dryRun: boolean;
|
|
9
11
|
externalKey?: string;
|
|
12
|
+
severity: EventSeverity;
|
|
10
13
|
}) => void;
|
|
11
14
|
/** Emitted when a run completes successfully. */
|
|
12
15
|
'workflow.run.done': (data: {
|
|
@@ -14,11 +17,23 @@ export type WorkflowEngineEvents = {
|
|
|
14
17
|
finalState: string;
|
|
15
18
|
transitionsTaken: number;
|
|
16
19
|
externalKey?: string;
|
|
20
|
+
severity: EventSeverity;
|
|
17
21
|
}) => void;
|
|
18
22
|
/** Emitted when a run fails. */
|
|
19
|
-
'workflow.run.failed': (data: {
|
|
23
|
+
'workflow.run.failed': (data: {
|
|
24
|
+
runId: string;
|
|
25
|
+
finalState: string;
|
|
26
|
+
reason: string;
|
|
27
|
+
externalKey?: string;
|
|
28
|
+
severity: EventSeverity;
|
|
29
|
+
}) => void;
|
|
20
30
|
/** Emitted when entering a state or node. */
|
|
21
|
-
'workflow.node.enter': (data: {
|
|
31
|
+
'workflow.node.enter': (data: {
|
|
32
|
+
runId: string;
|
|
33
|
+
node: string;
|
|
34
|
+
transitionsTaken: number;
|
|
35
|
+
severity: EventSeverity;
|
|
36
|
+
}) => void;
|
|
22
37
|
/** Emitted on a state/node transition. */
|
|
23
38
|
'workflow.node.transition': (data: {
|
|
24
39
|
runId: string;
|
|
@@ -26,9 +41,10 @@ export type WorkflowEngineEvents = {
|
|
|
26
41
|
to: string;
|
|
27
42
|
trigger: string | null;
|
|
28
43
|
externalKey?: string;
|
|
44
|
+
severity: EventSeverity;
|
|
29
45
|
}) => void;
|
|
30
46
|
/** Emitted when an action starts executing. */
|
|
31
|
-
'workflow.action.start': (data: { runId: string; node: string; kind: string }) => void;
|
|
47
|
+
'workflow.action.start': (data: { runId: string; node: string; kind: string; severity: EventSeverity }) => void;
|
|
32
48
|
/** Emitted when an action finishes executing (success or failure). */
|
|
33
49
|
'workflow.action.done': (data: {
|
|
34
50
|
runId: string;
|
|
@@ -36,6 +52,7 @@ export type WorkflowEngineEvents = {
|
|
|
36
52
|
kind: string;
|
|
37
53
|
durationMs: number;
|
|
38
54
|
ok: boolean;
|
|
55
|
+
severity: EventSeverity;
|
|
39
56
|
}) => void;
|
|
40
57
|
/** Emitted when a non-fatal action failure is continued past (onError: 'continue'). */
|
|
41
58
|
'workflow.action.failed_continue': (data: {
|
|
@@ -43,11 +60,12 @@ export type WorkflowEngineEvents = {
|
|
|
43
60
|
node: string;
|
|
44
61
|
transitionsTaken: number;
|
|
45
62
|
error?: string;
|
|
63
|
+
severity: EventSeverity;
|
|
46
64
|
}) => void;
|
|
47
65
|
/** Emitted by the builtin `event.emit` action for custom user-defined events. */
|
|
48
|
-
'workflow.custom': (data: { name: string; payload: Record<string, unknown
|
|
66
|
+
'workflow.custom': (data: { name: string; payload: Record<string, unknown>; severity: EventSeverity }) => void;
|
|
49
67
|
/** Emitted by the builtin `note` action for workflow-visible annotations. */
|
|
50
|
-
'workflow.hitl.note': (data: { runId: string; node: string; message: string }) => void;
|
|
68
|
+
'workflow.hitl.note': (data: { runId: string; node: string; message: string; severity: EventSeverity }) => void;
|
|
51
69
|
/** Emitted when a guard condition is evaluated. Fires for every guard, including rejected ones. */
|
|
52
70
|
'workflow.guard.evaluated': (data: {
|
|
53
71
|
runId: string;
|
|
@@ -56,17 +74,25 @@ export type WorkflowEngineEvents = {
|
|
|
56
74
|
kind: string;
|
|
57
75
|
passed: boolean;
|
|
58
76
|
externalKey?: string;
|
|
77
|
+
severity: EventSeverity;
|
|
59
78
|
}) => void;
|
|
60
79
|
/** Emitted when an interactive HITL prompt is presented and the engine waits for input. */
|
|
61
|
-
'workflow.hitl.ask': (data: {
|
|
80
|
+
'workflow.hitl.ask': (data: {
|
|
81
|
+
runId: string;
|
|
82
|
+
node: string;
|
|
83
|
+
kind: string;
|
|
84
|
+
message: string;
|
|
85
|
+
severity: EventSeverity;
|
|
86
|
+
}) => void;
|
|
62
87
|
/** Emitted when an interactive HITL prompt receives a response. */
|
|
63
|
-
'workflow.hitl.response': (data: { runId: string; node: string; ok: boolean }) => void;
|
|
88
|
+
'workflow.hitl.response': (data: { runId: string; node: string; ok: boolean; severity: EventSeverity }) => void;
|
|
64
89
|
/** Emitted when a run's state is force-set via reseed (consumer-side authority reconciliation). */
|
|
65
90
|
'workflow.run.reseeded': (data: {
|
|
66
91
|
runId: string;
|
|
67
92
|
fromState: string;
|
|
68
93
|
toState: string;
|
|
69
94
|
externalKey?: string;
|
|
95
|
+
severity: EventSeverity;
|
|
70
96
|
}) => void;
|
|
71
97
|
/** Emitted when an external transition request is allowed and committed. */
|
|
72
98
|
'workflow.transition.requested': (data: {
|
|
@@ -75,6 +101,7 @@ export type WorkflowEngineEvents = {
|
|
|
75
101
|
to: string;
|
|
76
102
|
trigger: string | null;
|
|
77
103
|
externalKey?: string;
|
|
104
|
+
severity: EventSeverity;
|
|
78
105
|
}) => void;
|
|
79
106
|
/** Emitted when an external transition request is denied. */
|
|
80
107
|
'workflow.transition.denied': (data: {
|
|
@@ -83,6 +110,7 @@ export type WorkflowEngineEvents = {
|
|
|
83
110
|
to: string;
|
|
84
111
|
reason: string;
|
|
85
112
|
externalKey?: string;
|
|
113
|
+
severity: EventSeverity;
|
|
86
114
|
}) => void;
|
|
87
115
|
/** Emitted when a run pauses at a declared pause point. */
|
|
88
116
|
'workflow.run.paused': (data: {
|
|
@@ -90,7 +118,13 @@ export type WorkflowEngineEvents = {
|
|
|
90
118
|
node: string;
|
|
91
119
|
transitionsTaken: number;
|
|
92
120
|
externalKey?: string;
|
|
121
|
+
severity: EventSeverity;
|
|
93
122
|
}) => void;
|
|
94
123
|
/** Emitted when a paused run is resumed. */
|
|
95
|
-
'workflow.run.resumed': (data: {
|
|
124
|
+
'workflow.run.resumed': (data: {
|
|
125
|
+
runId: string;
|
|
126
|
+
node: string;
|
|
127
|
+
externalKey?: string;
|
|
128
|
+
severity: EventSeverity;
|
|
129
|
+
}) => void;
|
|
96
130
|
};
|
package/src/extensions.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import type { Logger } from '@gobing-ai/ts-infra';
|
|
2
|
-
import { basenamePath, dirnamePath, SEP } from '@gobing-ai/ts-runtime';
|
|
3
2
|
import type { ExtensionRef, LoadExtensionsOptions } from '@gobing-ai/ts-runtime/extension';
|
|
4
3
|
import { loadExtensionModules } from '@gobing-ai/ts-runtime/extension';
|
|
5
4
|
import { WorkflowValidationError } from './errors';
|
|
6
5
|
import type { WorkflowEngineHost } from './host';
|
|
7
|
-
import type { ActionRunner, GuardRunner } from './types';
|
|
6
|
+
import type { ActionRunner, GuardRunner, WorkflowExtensions } from './types';
|
|
8
7
|
|
|
9
8
|
/** Minimal warning sink accepted for non-fatal extension diagnostics; a full {@link Logger} satisfies it. */
|
|
10
9
|
export type WorkflowExtensionLogger = Pick<Logger, 'warn'>;
|
|
@@ -21,15 +20,17 @@ export type WorkflowExtensionKind = 'actions' | 'guards';
|
|
|
21
20
|
/**
|
|
22
21
|
* A single workflow extension module reference.
|
|
23
22
|
*
|
|
24
|
-
* The caller
|
|
25
|
-
* shared
|
|
26
|
-
*
|
|
23
|
+
* The caller supplies the authored relative path plus the declaring directory; the
|
|
24
|
+
* shared loader resolves `(baseDir, path)` and applies its traversal + symlink
|
|
25
|
+
* guards to the authored string exactly as declared (task 0060 C2).
|
|
27
26
|
*/
|
|
28
27
|
export interface WorkflowExtensionRef {
|
|
29
28
|
/** Target capability registry. */
|
|
30
29
|
readonly kind: WorkflowExtensionKind;
|
|
31
|
-
/**
|
|
32
|
-
readonly
|
|
30
|
+
/** Relative path as authored (e.g. `./exts/foo.ts`). */
|
|
31
|
+
readonly path: string;
|
|
32
|
+
/** Absolute directory the authored `path` is resolved against. */
|
|
33
|
+
readonly baseDir: string;
|
|
33
34
|
/** Name of the config declaring this extension (for diagnostics). */
|
|
34
35
|
readonly sourceName: string;
|
|
35
36
|
}
|
|
@@ -59,6 +60,30 @@ export interface LoadWorkflowExtensionsOptions {
|
|
|
59
60
|
readonly realPath?: (absPath: string) => string;
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Build `WorkflowExtensionRef[]` from a YAML `extensions` block without importing
|
|
65
|
+
* or resolving anything.
|
|
66
|
+
*
|
|
67
|
+
* Kind order is `actions` then `guards`. Paths are kept **as authored** (no
|
|
68
|
+
* basename smash, no `resolve(sourceDir, path)`) so the shared loader sees the
|
|
69
|
+
* real declaration — a `..` segment or absolute path is rejected there by
|
|
70
|
+
* `assertRelativeExtensionPath`, not after a rewrite here.
|
|
71
|
+
*/
|
|
72
|
+
export function collectWorkflowExtensions(
|
|
73
|
+
sourceName: string,
|
|
74
|
+
sourceDir: string,
|
|
75
|
+
extensions: WorkflowExtensions | undefined,
|
|
76
|
+
): WorkflowExtensionRef[] {
|
|
77
|
+
if (extensions === undefined) return [];
|
|
78
|
+
const refs: WorkflowExtensionRef[] = [];
|
|
79
|
+
for (const kind of ['actions', 'guards'] as const) {
|
|
80
|
+
for (const path of extensions[kind] ?? []) {
|
|
81
|
+
refs.push({ kind, sourceName, path, baseDir: sourceDir });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return refs;
|
|
85
|
+
}
|
|
86
|
+
|
|
62
87
|
/**
|
|
63
88
|
* Import each extension module behind an explicit trust gate and register
|
|
64
89
|
* its actions and/or guards on the workflow host.
|
|
@@ -79,28 +104,13 @@ export async function loadWorkflowExtensionsIntoHost(
|
|
|
79
104
|
): Promise<void> {
|
|
80
105
|
if (refs.length === 0) return;
|
|
81
106
|
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
// traversal in the caller-supplied absPath before basename strips it (R6).
|
|
86
|
-
for (const ref of refs) {
|
|
87
|
-
const segments = ref.absPath.split(SEP);
|
|
88
|
-
if (segments.includes('..')) {
|
|
89
|
-
throw new Error(
|
|
90
|
-
`extension path "${ref.absPath}" declared by "${ref.sourceName}" must not contain ".." traversal`,
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Adapt WorkflowExtensionRef → shared ExtensionRef so the generic loader
|
|
96
|
-
// governs every import. The shared loader resolves (baseDir, path) ->
|
|
97
|
-
// absPath internally; we supply dirname/basename so the resolved path
|
|
98
|
-
// reconstructs the caller's original absPath. assertRelativeExtensionPath
|
|
99
|
-
// is satisfied because basenamePath() is always a simple filename.
|
|
107
|
+
// Map 1:1 onto the shared ref shape — no dirname/basename smash, so the shared
|
|
108
|
+
// loader's assertRelativeExtensionPath + realPath confinement govern the authored
|
|
109
|
+
// path exactly as declared (task 0060 C2).
|
|
100
110
|
const sharedRefs: ExtensionRef<WorkflowExtensionKind>[] = refs.map((ref) => ({
|
|
101
111
|
kind: ref.kind,
|
|
102
|
-
path:
|
|
103
|
-
baseDir:
|
|
112
|
+
path: ref.path,
|
|
113
|
+
baseDir: ref.baseDir,
|
|
104
114
|
sourceName: ref.sourceName,
|
|
105
115
|
}));
|
|
106
116
|
|
package/src/host.ts
CHANGED
|
@@ -123,6 +123,7 @@ export class NoteActionRunner implements ActionRunner {
|
|
|
123
123
|
runId: context.runId,
|
|
124
124
|
node: context.stateOrNodeId,
|
|
125
125
|
message,
|
|
126
|
+
severity: 'info',
|
|
126
127
|
});
|
|
127
128
|
return { ok: true, data: { message } };
|
|
128
129
|
}
|
|
@@ -136,7 +137,7 @@ export class EventEmitActionRunner implements ActionRunner {
|
|
|
136
137
|
const name = String(options.name ?? '');
|
|
137
138
|
if (!name) return { ok: false, error: 'event.emit requires a non-empty "name" option' };
|
|
138
139
|
const payload = (options.payload as Record<string, unknown>) ?? {};
|
|
139
|
-
void context?.events?.emit('workflow.custom', { name, payload });
|
|
140
|
+
void context?.events?.emit('workflow.custom', { name, payload, severity: 'info' });
|
|
140
141
|
return { ok: true, data: { name, payload } };
|
|
141
142
|
}
|
|
142
143
|
}
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ export { loadWorkflowDef, loadWorkflowDefFromText, validateWorkflowDef } from '.
|
|
|
2
2
|
export { FSMError, RunCollisionError, WorkflowResumeError, WorkflowValidationError } from './errors';
|
|
3
3
|
export type { WorkflowEngineEvents } from './events';
|
|
4
4
|
export {
|
|
5
|
+
collectWorkflowExtensions,
|
|
5
6
|
type LoadWorkflowExtensionsOptions,
|
|
6
7
|
loadWorkflowExtensionsIntoHost,
|
|
7
8
|
type WorkflowExtensionKind,
|
|
@@ -36,6 +37,7 @@ export {
|
|
|
36
37
|
StateMachineWorkflowDefSchema,
|
|
37
38
|
TransitionFlowWorkflowDefSchema,
|
|
38
39
|
WorkflowDefSchema,
|
|
40
|
+
WorkflowExtensionsSchema,
|
|
39
41
|
} from './schema';
|
|
40
42
|
export { WORKFLOW_ENGINE_SCHEMA_SQL } from './schema-sql';
|
|
41
43
|
export { WorkflowService } from './service';
|
|
@@ -66,6 +68,7 @@ export type {
|
|
|
66
68
|
TransitionRequestResult,
|
|
67
69
|
Vars,
|
|
68
70
|
WorkflowDef,
|
|
71
|
+
WorkflowExtensions,
|
|
69
72
|
WorkflowPersistenceAdapter,
|
|
70
73
|
WorkflowRunOptions,
|
|
71
74
|
WorkflowRunRecord,
|
package/src/persistence.ts
CHANGED
|
@@ -17,6 +17,36 @@ export async function applyWorkflowEngineSchema(db: DbAdapter): Promise<void> {
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/** Narrow to a non-array object record. */
|
|
21
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
22
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Default result redactor (task 0060 F4): shell action results keep `ok`/`exitCode`
|
|
27
|
+
* but drop raw `stdout`/`stderr`, which may carry secrets, from the persisted row.
|
|
28
|
+
* Non-shell results pass through unchanged.
|
|
29
|
+
*/
|
|
30
|
+
export function defaultActionRedactor(kind: string, payload: Record<string, unknown>): Record<string, unknown> {
|
|
31
|
+
if (kind !== 'shell') return payload;
|
|
32
|
+
const data = isRecord(payload.data) ? { ...payload.data } : payload.data;
|
|
33
|
+
if (isRecord(data)) {
|
|
34
|
+
if ('stdout' in data) data.stdout = '[redacted]';
|
|
35
|
+
if ('stderr' in data) data.stderr = '[redacted]';
|
|
36
|
+
}
|
|
37
|
+
return { ...payload, data };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Apply the caller redactor, or the built-in default when none is supplied. */
|
|
41
|
+
export function applyRedactor(
|
|
42
|
+
redactor: ActionRedactor | undefined,
|
|
43
|
+
kind: string,
|
|
44
|
+
result: unknown,
|
|
45
|
+
): Record<string, unknown> {
|
|
46
|
+
const payload = isRecord(result) ? result : { value: result };
|
|
47
|
+
return redactor !== undefined ? redactor(kind, payload) : defaultActionRedactor(kind, payload);
|
|
48
|
+
}
|
|
49
|
+
|
|
20
50
|
/** SQLite/D1-compatible workflow persistence adapter backed by ts-db. */
|
|
21
51
|
export class DbWorkflowPersistenceAdapter implements WorkflowPersistenceAdapter {
|
|
22
52
|
/** Memoized schema-ensure; the DDL runs at most once per adapter instance. */
|
|
@@ -180,8 +210,9 @@ export class DbWorkflowPersistenceAdapter implements WorkflowPersistenceAdapter
|
|
|
180
210
|
status: WorkflowStatus,
|
|
181
211
|
durationMs: number,
|
|
182
212
|
ok: boolean,
|
|
213
|
+
kind: string,
|
|
183
214
|
result?: unknown,
|
|
184
|
-
|
|
215
|
+
redactor?: ActionRedactor,
|
|
185
216
|
): Promise<void> {
|
|
186
217
|
const now = Date.now();
|
|
187
218
|
await this.db.run(
|
|
@@ -191,7 +222,7 @@ export class DbWorkflowPersistenceAdapter implements WorkflowPersistenceAdapter
|
|
|
191
222
|
status,
|
|
192
223
|
durationMs,
|
|
193
224
|
ok ? 1 : 0,
|
|
194
|
-
result !== undefined ? JSON.stringify(result) : null,
|
|
225
|
+
result !== undefined ? JSON.stringify(applyRedactor(redactor, kind, result)) : null,
|
|
195
226
|
new Date(now).toISOString(),
|
|
196
227
|
now,
|
|
197
228
|
actionId,
|
|
@@ -241,16 +272,18 @@ export class DbWorkflowPersistenceAdapter implements WorkflowPersistenceAdapter
|
|
|
241
272
|
return { ...record };
|
|
242
273
|
}
|
|
243
274
|
|
|
244
|
-
/** Force-set the current state of a run (reseed).
|
|
275
|
+
/** Force-set the current state of a run (reseed). Atomic: snapshot + `__reseed__` transition
|
|
276
|
+
* commit in one `db.batch`, and the previous snapshot's `effectiveVars` survive (task 0060 F6). */
|
|
245
277
|
async reseedRun(runId: string, newState: string): Promise<WorkflowReseedResult> {
|
|
246
|
-
const now = Date.now();
|
|
247
278
|
await this.ensureSchema();
|
|
248
|
-
const previous = await this.
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
279
|
+
const previous = await this.loadLatestStateSnapshot(runId);
|
|
280
|
+
const now = Date.now();
|
|
281
|
+
const data = {
|
|
282
|
+
...(previous?.data ?? {}),
|
|
283
|
+
reseeded: true,
|
|
284
|
+
reseededAt: new Date(now).toISOString(),
|
|
285
|
+
};
|
|
286
|
+
await this.commitTransition(runId, previous?.state ?? '', newState, '__reseed__', newState, data);
|
|
254
287
|
return { fromState: previous?.state ?? null, toState: newState };
|
|
255
288
|
}
|
|
256
289
|
|
|
@@ -357,15 +390,16 @@ export class MemoryWorkflowPersistenceAdapter implements WorkflowPersistenceAdap
|
|
|
357
390
|
status: WorkflowStatus,
|
|
358
391
|
durationMs: number,
|
|
359
392
|
ok: boolean,
|
|
393
|
+
kind: string,
|
|
360
394
|
result?: unknown,
|
|
361
|
-
|
|
395
|
+
redactor?: ActionRedactor,
|
|
362
396
|
): Promise<void> {
|
|
363
397
|
const row = this.actionRuns.find((a) => a.id === actionId);
|
|
364
398
|
if (row === undefined) return;
|
|
365
399
|
row.status = status;
|
|
366
400
|
row.durationMs = durationMs;
|
|
367
401
|
row.ok = ok ? 1 : 0;
|
|
368
|
-
row.resultJson = result !== undefined ? JSON.stringify(result) : null;
|
|
402
|
+
row.resultJson = result !== undefined ? JSON.stringify(applyRedactor(redactor, kind, result)) : null;
|
|
369
403
|
}
|
|
370
404
|
|
|
371
405
|
/** Save one phase/state execution record. */
|
|
@@ -430,9 +464,13 @@ export class MemoryWorkflowPersistenceAdapter implements WorkflowPersistenceAdap
|
|
|
430
464
|
|
|
431
465
|
/** Force-set the current state of a run (reseed). */
|
|
432
466
|
async reseedRun(runId: string, newState: string): Promise<WorkflowReseedResult> {
|
|
433
|
-
const previous = this.
|
|
434
|
-
|
|
435
|
-
|
|
467
|
+
const previous = await this.loadLatestStateSnapshot(runId);
|
|
468
|
+
const data = {
|
|
469
|
+
...(previous?.data ?? {}),
|
|
470
|
+
reseeded: true,
|
|
471
|
+
reseededAt: new Date().toISOString(),
|
|
472
|
+
};
|
|
473
|
+
await this.commitTransition(runId, previous?.state ?? '', newState, '__reseed__', newState, data);
|
|
436
474
|
return { fromState: previous?.state ?? null, toState: newState };
|
|
437
475
|
}
|
|
438
476
|
|
package/src/run-lifecycle.ts
CHANGED
|
@@ -165,6 +165,7 @@ export class RunLifecycle {
|
|
|
165
165
|
runId: lifecycle.runId,
|
|
166
166
|
dryRun: options.dryRun ?? false,
|
|
167
167
|
externalKey: extKey,
|
|
168
|
+
severity: 'info',
|
|
168
169
|
});
|
|
169
170
|
return await loop(lifecycle);
|
|
170
171
|
},
|
|
@@ -217,6 +218,7 @@ export class RunLifecycle {
|
|
|
217
218
|
runId: this.runId,
|
|
218
219
|
node: stateOrNodeId,
|
|
219
220
|
transitionsTaken,
|
|
221
|
+
severity: 'info',
|
|
220
222
|
});
|
|
221
223
|
}
|
|
222
224
|
|
|
@@ -251,6 +253,7 @@ export class RunLifecycle {
|
|
|
251
253
|
to,
|
|
252
254
|
trigger,
|
|
253
255
|
externalKey: this.externalKey,
|
|
256
|
+
severity: 'info',
|
|
254
257
|
});
|
|
255
258
|
}
|
|
256
259
|
async recordTransition(from: string, to: string, trigger: string | null): Promise<void> {
|
|
@@ -267,6 +270,7 @@ export class RunLifecycle {
|
|
|
267
270
|
to,
|
|
268
271
|
trigger,
|
|
269
272
|
externalKey: this.externalKey,
|
|
273
|
+
severity: 'info',
|
|
270
274
|
});
|
|
271
275
|
}
|
|
272
276
|
|
|
@@ -281,6 +285,7 @@ export class RunLifecycle {
|
|
|
281
285
|
finalState,
|
|
282
286
|
transitionsTaken,
|
|
283
287
|
externalKey: this.externalKey,
|
|
288
|
+
severity: 'info',
|
|
284
289
|
});
|
|
285
290
|
return this.result('done', finalState, transitionsTaken);
|
|
286
291
|
}
|
|
@@ -295,6 +300,7 @@ export class RunLifecycle {
|
|
|
295
300
|
finalState,
|
|
296
301
|
reason,
|
|
297
302
|
externalKey: this.externalKey,
|
|
303
|
+
severity: 'error',
|
|
298
304
|
});
|
|
299
305
|
this.logger.warn('workflow run failed', { finalState, transitionsTaken, reason });
|
|
300
306
|
return this.result('failed', finalState, transitionsTaken, reason);
|
|
@@ -320,6 +326,7 @@ export class RunLifecycle {
|
|
|
320
326
|
node: stateOrNodeId,
|
|
321
327
|
transitionsTaken,
|
|
322
328
|
externalKey: this.externalKey,
|
|
329
|
+
severity: 'warning',
|
|
323
330
|
});
|
|
324
331
|
return this.result('paused', stateOrNodeId, transitionsTaken);
|
|
325
332
|
}
|
|
@@ -327,13 +334,23 @@ export class RunLifecycle {
|
|
|
327
334
|
/** Emit the resumed event (called by WorkflowService after re-creating a lifecycle for resume). */
|
|
328
335
|
emitResumed(node: string): void {
|
|
329
336
|
addSpanEvent('workflow.run.resumed', { runId: this.runId, node });
|
|
330
|
-
void this.events?.emit('workflow.run.resumed', {
|
|
337
|
+
void this.events?.emit('workflow.run.resumed', {
|
|
338
|
+
runId: this.runId,
|
|
339
|
+
node,
|
|
340
|
+
externalKey: this.externalKey,
|
|
341
|
+
severity: 'info',
|
|
342
|
+
});
|
|
331
343
|
}
|
|
332
344
|
|
|
333
345
|
/** Emit action-level observability before a host action is invoked. */
|
|
334
346
|
actionStart(stateOrNodeId: string, kind: string): void {
|
|
335
347
|
addSpanEvent('workflow.action.start', { runId: this.runId, node: stateOrNodeId, kind });
|
|
336
|
-
void this.events?.emit('workflow.action.start', {
|
|
348
|
+
void this.events?.emit('workflow.action.start', {
|
|
349
|
+
runId: this.runId,
|
|
350
|
+
node: stateOrNodeId,
|
|
351
|
+
kind,
|
|
352
|
+
severity: 'info',
|
|
353
|
+
});
|
|
337
354
|
}
|
|
338
355
|
|
|
339
356
|
/** Emit action-level observability after a host action settles. */
|
|
@@ -345,6 +362,7 @@ export class RunLifecycle {
|
|
|
345
362
|
kind,
|
|
346
363
|
durationMs,
|
|
347
364
|
ok,
|
|
365
|
+
severity: ok ? 'info' : 'error',
|
|
348
366
|
});
|
|
349
367
|
}
|
|
350
368
|
|
|
@@ -361,6 +379,7 @@ export class RunLifecycle {
|
|
|
361
379
|
node: stateOrNodeId,
|
|
362
380
|
transitionsTaken,
|
|
363
381
|
...(error === undefined ? {} : { error }),
|
|
382
|
+
severity: 'warning',
|
|
364
383
|
});
|
|
365
384
|
this.logger.warn('action failed (continuing)', { node: stateOrNodeId, transitionsTaken, error });
|
|
366
385
|
}
|
|
@@ -374,17 +393,23 @@ export class RunLifecycle {
|
|
|
374
393
|
kind,
|
|
375
394
|
passed,
|
|
376
395
|
externalKey: this.externalKey,
|
|
396
|
+
severity: passed ? 'info' : 'warning',
|
|
377
397
|
});
|
|
378
398
|
}
|
|
379
399
|
|
|
380
400
|
/** Emit when an interactive HITL prompt is presented. */
|
|
381
401
|
hitlAsk(node: string, kind: string, message: string): void {
|
|
382
|
-
void this.events?.emit('workflow.hitl.ask', { runId: this.runId, node, kind, message });
|
|
402
|
+
void this.events?.emit('workflow.hitl.ask', { runId: this.runId, node, kind, message, severity: 'info' });
|
|
383
403
|
}
|
|
384
404
|
|
|
385
405
|
/** Emit when an interactive HITL prompt resolves. */
|
|
386
406
|
hitlResponse(node: string, ok: boolean): void {
|
|
387
|
-
void this.events?.emit('workflow.hitl.response', {
|
|
407
|
+
void this.events?.emit('workflow.hitl.response', {
|
|
408
|
+
runId: this.runId,
|
|
409
|
+
node,
|
|
410
|
+
ok,
|
|
411
|
+
severity: ok ? 'info' : 'warning',
|
|
412
|
+
});
|
|
388
413
|
}
|
|
389
414
|
|
|
390
415
|
private result(
|
package/src/schema.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertRelativeExtensionPath } from '@gobing-ai/ts-runtime/extension';
|
|
1
2
|
import { z } from 'zod';
|
|
2
3
|
|
|
3
4
|
/** Identifier names reserved for runtime template namespaces; not allowed as user vars. */
|
|
@@ -23,6 +24,38 @@ const EnvSchema = z.object({
|
|
|
23
24
|
allow: z.array(z.string().regex(IDENTIFIER, 'env.allow entries must be valid identifiers')).optional(),
|
|
24
25
|
});
|
|
25
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Extension module paths must be relative and must not escape the declaring
|
|
29
|
+
* directory. Mirrors rule-engine's private helper; the real guard is the shared
|
|
30
|
+
* `assertRelativeExtensionPath` (ADR-010) so schema-time and load-time
|
|
31
|
+
* validation share one source of truth.
|
|
32
|
+
*/
|
|
33
|
+
const relativeExtensionPath = z
|
|
34
|
+
.string()
|
|
35
|
+
.min(1)
|
|
36
|
+
.superRefine((value, ctx) => {
|
|
37
|
+
try {
|
|
38
|
+
assertRelativeExtensionPath(value);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
ctx.addIssue({
|
|
41
|
+
code: 'custom',
|
|
42
|
+
message: error instanceof Error ? error.message : 'invalid extension path',
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Rule-style `extensions` block for workflow YAML: relative module paths for
|
|
49
|
+
* the two extension-loadable capability kinds. `.strict()` rejects unknown keys
|
|
50
|
+
* (e.g. `evaluators` or `plugins`).
|
|
51
|
+
*/
|
|
52
|
+
export const WorkflowExtensionsSchema = z
|
|
53
|
+
.object({
|
|
54
|
+
actions: z.array(relativeExtensionPath).optional(),
|
|
55
|
+
guards: z.array(relativeExtensionPath).optional(),
|
|
56
|
+
})
|
|
57
|
+
.strict();
|
|
58
|
+
|
|
26
59
|
/** Zod schema for workflow action definitions. */
|
|
27
60
|
export const ActionDefSchema = z.object({
|
|
28
61
|
kind: z.string().min(1),
|
|
@@ -75,6 +108,7 @@ export const StateMachineWorkflowDefSchema = z
|
|
|
75
108
|
})
|
|
76
109
|
.strict(),
|
|
77
110
|
),
|
|
111
|
+
extensions: WorkflowExtensionsSchema.optional(),
|
|
78
112
|
})
|
|
79
113
|
.strict();
|
|
80
114
|
|
|
@@ -115,6 +149,7 @@ export const TransitionFlowWorkflowDefSchema = z
|
|
|
115
149
|
})
|
|
116
150
|
.strict(),
|
|
117
151
|
),
|
|
152
|
+
extensions: WorkflowExtensionsSchema.optional(),
|
|
118
153
|
})
|
|
119
154
|
.strict();
|
|
120
155
|
|
package/src/service.ts
CHANGED
|
@@ -147,6 +147,7 @@ export class WorkflowService {
|
|
|
147
147
|
fromState: result.fromState ?? '',
|
|
148
148
|
toState: result.toState,
|
|
149
149
|
externalKey: extKey,
|
|
150
|
+
severity: 'warning',
|
|
150
151
|
});
|
|
151
152
|
}
|
|
152
153
|
|
|
@@ -176,7 +177,12 @@ export class WorkflowService {
|
|
|
176
177
|
const extKey = run.external_key ?? undefined;
|
|
177
178
|
await this.persistence.finalizeRun(runId, 'running', '');
|
|
178
179
|
const events = this.resolveEvents(mergedOptions.events);
|
|
179
|
-
void events?.emit('workflow.run.resumed', {
|
|
180
|
+
void events?.emit('workflow.run.resumed', {
|
|
181
|
+
runId,
|
|
182
|
+
node: currentState,
|
|
183
|
+
externalKey: extKey,
|
|
184
|
+
severity: 'info',
|
|
185
|
+
});
|
|
180
186
|
|
|
181
187
|
// Resume through the appropriate driver, starting from the paused state (skip on-enter).
|
|
182
188
|
if (workflow.kind === 'transition-flow') {
|
|
@@ -305,6 +311,7 @@ export class WorkflowService {
|
|
|
305
311
|
to: toState,
|
|
306
312
|
trigger,
|
|
307
313
|
externalKey: extKey,
|
|
314
|
+
severity: 'info',
|
|
308
315
|
});
|
|
309
316
|
return { allowed: true, fromState: currentState, toState };
|
|
310
317
|
}
|
|
@@ -328,6 +335,7 @@ export class WorkflowService {
|
|
|
328
335
|
to,
|
|
329
336
|
reason: denial.reason,
|
|
330
337
|
externalKey,
|
|
338
|
+
severity: 'error',
|
|
331
339
|
});
|
|
332
340
|
return { allowed: false, ...denial };
|
|
333
341
|
}
|
package/src/types.ts
CHANGED
|
@@ -29,6 +29,17 @@ export interface GuardDef {
|
|
|
29
29
|
readonly options?: Record<string, unknown>;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Rule-style `extensions` block: relative module paths for the two
|
|
34
|
+
* extension-loadable capability kinds (`actions`, `guards`). Mirrors
|
|
35
|
+
* rule-engine's preset extensions; paths are resolved by the caller against
|
|
36
|
+
* the declaring YAML directory.
|
|
37
|
+
*/
|
|
38
|
+
export interface WorkflowExtensions {
|
|
39
|
+
readonly actions?: readonly string[];
|
|
40
|
+
readonly guards?: readonly string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
32
43
|
/** One state in a state-machine workflow. */
|
|
33
44
|
export interface StateDef {
|
|
34
45
|
readonly id: string;
|
|
@@ -72,6 +83,7 @@ export interface StateMachineWorkflowDef {
|
|
|
72
83
|
readonly env?: Env;
|
|
73
84
|
readonly states: readonly StateDef[];
|
|
74
85
|
readonly transitions: readonly TransitionDef[];
|
|
86
|
+
readonly extensions?: WorkflowExtensions;
|
|
75
87
|
}
|
|
76
88
|
|
|
77
89
|
/** Transition-flow node definition. */
|
|
@@ -111,6 +123,7 @@ export interface TransitionFlowWorkflowDef {
|
|
|
111
123
|
readonly env?: Env;
|
|
112
124
|
readonly nodes: readonly FlowNodeDef[];
|
|
113
125
|
readonly edges: readonly FlowEdgeDef[];
|
|
126
|
+
readonly extensions?: WorkflowExtensions;
|
|
114
127
|
}
|
|
115
128
|
|
|
116
129
|
/** Discriminated workflow definition union. */
|
|
@@ -178,6 +191,13 @@ export interface WorkflowRunOptions {
|
|
|
178
191
|
readonly events?: EventBus<WorkflowEngineEvents>;
|
|
179
192
|
/** Run-level error policy override. Lowest precedence; action-level wins. */
|
|
180
193
|
readonly onError?: OnErrorPolicy;
|
|
194
|
+
/**
|
|
195
|
+
* Optional result redaction hook applied before an action row is finalized.
|
|
196
|
+
* When omitted, the built-in default scrubs shell stdout/stderr so raw command
|
|
197
|
+
* output never lands in the audit table. Callers that need raw output in
|
|
198
|
+
* `action_runs.result_json` must supply their own redactor (task 0060 F4).
|
|
199
|
+
*/
|
|
200
|
+
readonly redactor?: ActionRedactor;
|
|
181
201
|
/** Validate the definition and walk the transition graph without executing actions. */
|
|
182
202
|
readonly dryRun?: boolean;
|
|
183
203
|
/** Optional caller-supplied external key, unique per workflow definition. */
|
|
@@ -287,12 +307,13 @@ export interface WorkflowPersistenceAdapter {
|
|
|
287
307
|
* persistence implementations ignore it (mirror-only — no new column, no alter, no redaction).
|
|
288
308
|
*/
|
|
289
309
|
saveActionStart(runId: string, node: string, kind: string, options?: Record<string, unknown>): Promise<string>;
|
|
290
|
-
/** Finalize an action row with duration, ok, result. */
|
|
310
|
+
/** Finalize an action row with duration, ok, result. `kind` names the action for the redactor. */
|
|
291
311
|
saveActionFinalize(
|
|
292
312
|
actionId: string,
|
|
293
313
|
status: WorkflowStatus,
|
|
294
314
|
durationMs: number,
|
|
295
315
|
ok: boolean,
|
|
316
|
+
kind: string,
|
|
296
317
|
result?: unknown,
|
|
297
318
|
redactor?: ActionRedactor,
|
|
298
319
|
): Promise<void>;
|