@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/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: { runId: string; finalState: string; reason: string; externalKey?: string }) => void;
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: { runId: string; node: string; transitionsTaken: number }) => void;
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> }) => void;
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: { runId: string; node: string; kind: string; message: string }) => void;
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: { runId: string; node: string; externalKey?: string }) => void;
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 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.
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
- /** Absolute path to the module to import. */
32
- readonly absPath: string;
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
- // Enforce relative-path guard before adapting to the shared format.
83
- // The shared loader's assertRelativeExtensionPath applies to the derived
84
- // (basename) path, which is always clean this pre-check catches `..`
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: `./${basenamePath(ref.absPath)}`,
103
- baseDir: dirnamePath(ref.absPath),
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,
@@ -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
- _redactor?: ActionRedactor,
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.db.queryFirst<{ state: string }>(
249
- 'SELECT state FROM workflow_states WHERE run_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1',
250
- runId,
251
- );
252
- await this.saveWorkflowState(runId, newState, { reseeded: true, reseededAt: new Date(now).toISOString() });
253
- await this.saveTransition(runId, previous?.state ?? '', newState, '__reseed__');
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
- _redactor?: ActionRedactor,
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.states.findLast((state) => state.runId === runId);
434
- await this.saveWorkflowState(runId, newState, { reseeded: true, reseededAt: new Date().toISOString() });
435
- await this.saveTransition(runId, previous?.state ?? '', newState, '__reseed__');
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
 
@@ -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', { runId: this.runId, node, externalKey: this.externalKey });
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', { runId: this.runId, node: stateOrNodeId, kind });
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', { runId: this.runId, node, ok });
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', { runId, node: currentState, externalKey: extKey });
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>;