@duckflux/core 0.6.8

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.
@@ -0,0 +1,43 @@
1
+ import { parseDuration } from "./errors";
2
+
3
+ export class TimeoutError extends Error {
4
+ timeoutMs: number;
5
+ constructor(message: string, timeoutMs: number) {
6
+ super(message);
7
+ this.name = "TimeoutError";
8
+ this.timeoutMs = timeoutMs;
9
+ }
10
+ }
11
+
12
+ export async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number): Promise<T> {
13
+ let timer: NodeJS.Timeout | undefined;
14
+ const timeoutPromise = new Promise<never>((_, reject) => {
15
+ timer = setTimeout(() => reject(new TimeoutError(`operation timed out after ${timeoutMs}ms`, timeoutMs)), timeoutMs);
16
+ });
17
+ try {
18
+ return await Promise.race([fn(), timeoutPromise]) as T;
19
+ } finally {
20
+ if (timer) clearTimeout(timer);
21
+ }
22
+ }
23
+
24
+ export function resolveTimeout(
25
+ flowOverride?: { timeout?: string } | null,
26
+ participant?: { timeout?: string } | null,
27
+ defaults?: { timeout?: string } | null
28
+ ): number | undefined {
29
+ const t = (flowOverride && flowOverride.timeout) ?? (participant && participant.timeout) ?? (defaults && defaults.timeout);
30
+ if (!t) return undefined;
31
+ try {
32
+ return parseDuration(t);
33
+ } catch (err) {
34
+ // If parsing fails, rethrow as Error with context
35
+ throw new Error(`invalid timeout value '${t}': ${(err && (err as Error).message) || err}`);
36
+ }
37
+ }
38
+
39
+ export default {
40
+ TimeoutError,
41
+ withTimeout,
42
+ resolveTimeout,
43
+ };
@@ -0,0 +1,102 @@
1
+ import { evaluateCel } from "../cel/index";
2
+ import { parseDuration } from "./errors";
3
+ import type { WorkflowState } from "./state";
4
+
5
+ function sleep(ms: number): Promise<void> {
6
+ return new Promise((resolve) => setTimeout(resolve, ms));
7
+ }
8
+
9
+ export async function executeWait(
10
+ state: WorkflowState,
11
+ waitDef: {
12
+ event?: string;
13
+ match?: string;
14
+ until?: string;
15
+ poll?: string;
16
+ timeout?: string;
17
+ onTimeout?: string;
18
+ },
19
+ chain?: unknown,
20
+ hub?: { subscribe(event: string, signal?: AbortSignal): AsyncIterable<{ name: string; payload: unknown }> },
21
+ signal?: AbortSignal,
22
+ ): Promise<unknown> {
23
+ const timeoutMs = waitDef.timeout ? parseDuration(waitDef.timeout) : undefined;
24
+ const onTimeout = waitDef.onTimeout ?? "fail";
25
+
26
+ // Sleep mode: only timeout, no event or until
27
+ if (!waitDef.event && !waitDef.until && waitDef.timeout) {
28
+ await sleep(parseDuration(waitDef.timeout));
29
+ return chain;
30
+ }
31
+
32
+ // Polling mode: until + optional poll + optional timeout
33
+ if (waitDef.until) {
34
+ const pollMs = waitDef.poll ? parseDuration(waitDef.poll) : 1000;
35
+ const deadline = timeoutMs ? Date.now() + timeoutMs : undefined;
36
+
37
+ while (true) {
38
+ if (signal?.aborted) {
39
+ throw new Error("wait aborted");
40
+ }
41
+
42
+ const result = evaluateCel(waitDef.until, state.toCelContext());
43
+ if (result === true) {
44
+ return chain;
45
+ }
46
+
47
+ if (deadline && Date.now() >= deadline) {
48
+ if (onTimeout === "skip") return chain;
49
+ throw new Error(`wait polling timed out after ${waitDef.timeout}`);
50
+ }
51
+
52
+ await sleep(pollMs);
53
+ }
54
+ }
55
+
56
+ // Event mode: event + optional match + optional timeout
57
+ if (waitDef.event) {
58
+ if (!hub) {
59
+ throw new Error("wait.event requires an event hub but none was provided");
60
+ }
61
+
62
+ const controller = new AbortController();
63
+ const combinedSignal = signal
64
+ ? AbortSignal.any([signal, controller.signal])
65
+ : controller.signal;
66
+
67
+ let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
68
+ if (timeoutMs) {
69
+ timeoutTimer = setTimeout(() => controller.abort(), timeoutMs);
70
+ }
71
+
72
+ try {
73
+ for await (const envelope of hub.subscribe(waitDef.event, combinedSignal)) {
74
+ if (waitDef.match) {
75
+ // Evaluate match condition with event payload in context
76
+ const prevEvent = state.eventPayload;
77
+ state.eventPayload = envelope.payload;
78
+ const matched = evaluateCel(waitDef.match, state.toCelContext());
79
+ if (matched !== true) {
80
+ // Restore previous event payload on non-match
81
+ state.eventPayload = prevEvent;
82
+ continue;
83
+ }
84
+ }
85
+
86
+ // Event matched
87
+ state.eventPayload = envelope.payload;
88
+ return envelope.payload;
89
+ }
90
+ } catch (err) {
91
+ if ((err as Error)?.name === "AbortError" || controller.signal.aborted) {
92
+ if (onTimeout === "skip") return chain;
93
+ throw new Error(`wait.event timed out after ${waitDef.timeout}`);
94
+ }
95
+ throw err;
96
+ } finally {
97
+ if (timeoutTimer) clearTimeout(timeoutTimer);
98
+ }
99
+ }
100
+
101
+ return chain;
102
+ }
@@ -0,0 +1,24 @@
1
+ export type { EventHub, EventEnvelope, EventHubConfig } from "./types";
2
+ export { MemoryHub } from "./memory";
3
+
4
+ import type { EventHub, EventHubConfig } from "./types";
5
+ import { MemoryHub } from "./memory";
6
+
7
+ export async function createHub(config: EventHubConfig): Promise<EventHub> {
8
+ switch (config.backend) {
9
+ case "memory":
10
+ return new MemoryHub();
11
+ case "nats":
12
+ throw new Error(
13
+ "NATS backend has been moved to @duckflux/hub-nats. " +
14
+ "Install it and pass a NatsHub instance via ExecuteOptions.hub instead.",
15
+ );
16
+ case "redis":
17
+ throw new Error(
18
+ "Redis backend has been moved to @duckflux/hub-redis. " +
19
+ "Install it and pass a RedisHub instance via ExecuteOptions.hub instead.",
20
+ );
21
+ default:
22
+ throw new Error(`unknown event hub backend: ${(config as { backend: string }).backend}`);
23
+ }
24
+ }
@@ -0,0 +1,106 @@
1
+ import type { EventEnvelope, EventHub } from "./types";
2
+
3
+ type Listener = (envelope: EventEnvelope) => void;
4
+
5
+ export class MemoryHub implements EventHub {
6
+ private listeners = new Map<string, Set<Listener>>();
7
+ private buffer = new Map<string, EventEnvelope[]>();
8
+ private closed = false;
9
+
10
+ async publish(event: string, payload: unknown): Promise<void> {
11
+ if (this.closed) throw new Error("hub is closed");
12
+
13
+ const envelope: EventEnvelope = { name: event, payload };
14
+
15
+ // Buffer for replay
16
+ let buf = this.buffer.get(event);
17
+ if (!buf) {
18
+ buf = [];
19
+ this.buffer.set(event, buf);
20
+ }
21
+ buf.push(envelope);
22
+
23
+ // Fan-out to current listeners
24
+ const listeners = this.listeners.get(event);
25
+ if (listeners) {
26
+ for (const listener of listeners) {
27
+ listener(envelope);
28
+ }
29
+ }
30
+ }
31
+
32
+ async publishAndWaitAck(event: string, payload: unknown, _timeoutMs: number): Promise<void> {
33
+ // In-memory delivery is synchronous, so ack is immediate
34
+ await this.publish(event, payload);
35
+ }
36
+
37
+ async *subscribe(event: string, signal?: AbortSignal): AsyncIterable<EventEnvelope> {
38
+ if (this.closed) return;
39
+
40
+ // Replay buffered events first
41
+ const buffered = this.buffer.get(event);
42
+ if (buffered) {
43
+ for (const envelope of buffered) {
44
+ if (signal?.aborted) return;
45
+ yield envelope;
46
+ }
47
+ }
48
+
49
+ // Then listen for new events
50
+ const queue: EventEnvelope[] = [];
51
+ let resolve: (() => void) | null = null;
52
+
53
+ const listener: Listener = (envelope) => {
54
+ queue.push(envelope);
55
+ if (resolve) {
56
+ resolve();
57
+ resolve = null;
58
+ }
59
+ };
60
+
61
+ let listeners = this.listeners.get(event);
62
+ if (!listeners) {
63
+ listeners = new Set();
64
+ this.listeners.set(event, listeners);
65
+ }
66
+ listeners.add(listener);
67
+
68
+ const onAbort = () => {
69
+ listeners!.delete(listener);
70
+ if (resolve) {
71
+ resolve();
72
+ resolve = null;
73
+ }
74
+ };
75
+
76
+ if (signal) {
77
+ signal.addEventListener("abort", onAbort);
78
+ }
79
+
80
+ try {
81
+ while (!this.closed && !signal?.aborted) {
82
+ if (queue.length > 0) {
83
+ yield queue.shift()!;
84
+ } else {
85
+ await new Promise<void>((r) => {
86
+ resolve = r;
87
+ });
88
+ }
89
+ }
90
+ } finally {
91
+ listeners.delete(listener);
92
+ if (signal) {
93
+ signal.removeEventListener("abort", onAbort);
94
+ }
95
+ }
96
+ }
97
+
98
+ async close(): Promise<void> {
99
+ this.closed = true;
100
+ // Wake up all waiting subscribers
101
+ for (const listeners of this.listeners.values()) {
102
+ listeners.clear();
103
+ }
104
+ this.listeners.clear();
105
+ }
106
+ }
@@ -0,0 +1,17 @@
1
+ export interface EventEnvelope {
2
+ name: string;
3
+ payload: unknown;
4
+ }
5
+
6
+ export interface EventHubConfig {
7
+ backend: "memory" | "nats" | "redis";
8
+ nats?: { url: string; stream?: string };
9
+ redis?: { addr?: string; db?: number; consumerGroup?: string };
10
+ }
11
+
12
+ export interface EventHub {
13
+ publish(event: string, payload: unknown): Promise<void>;
14
+ publishAndWaitAck(event: string, payload: unknown, timeoutMs: number): Promise<void>;
15
+ subscribe(event: string, signal?: AbortSignal): AsyncIterable<EventEnvelope>;
16
+ close(): Promise<void>;
17
+ }
package/src/index.ts ADDED
@@ -0,0 +1,51 @@
1
+ // Parser
2
+ export { parseWorkflow, parseWorkflowFile } from "./parser/parser";
3
+ export { validateSchema } from "./parser/schema";
4
+ export { validateSemantic } from "./parser/validate";
5
+ export { validateInputs } from "./parser/validate_inputs";
6
+
7
+ // CEL
8
+ export { validateCelExpression, evaluateCel, evaluateCelStrict, buildCelContext, evalMaybeCel } from "./cel/index";
9
+
10
+ // Engine
11
+ export { executeWorkflow, runWorkflowFromFile } from "./engine/engine";
12
+ export type { EventHub as EngineEventHub, ExecuteOptions } from "./engine/engine";
13
+ export { WorkflowState } from "./engine/state";
14
+ export { mergeChainedInput } from "./engine/sequential";
15
+ export { executeWait } from "./engine/wait";
16
+
17
+ // Event Hub
18
+ export { MemoryHub } from "./eventhub/memory";
19
+ export { createHub } from "./eventhub/index";
20
+ export type { EventHub, EventEnvelope, EventHubConfig } from "./eventhub/types";
21
+
22
+ // Participants
23
+ export { executeEmit } from "./participant/emit";
24
+
25
+ // Types
26
+ export type {
27
+ Workflow,
28
+ Participant,
29
+ ParticipantBase,
30
+ FlowStep,
31
+ ExecParticipant,
32
+ HttpParticipant,
33
+ McpParticipant,
34
+ WorkflowParticipant,
35
+ EmitParticipant,
36
+ InlineParticipant,
37
+ WaitStep,
38
+ LoopStep,
39
+ ParallelStep,
40
+ IfStep,
41
+ FlowStepOverride,
42
+ StepResult,
43
+ WorkflowResult,
44
+ WorkflowOutput,
45
+ ValidationResult,
46
+ ValidationError,
47
+ WorkflowDefaults,
48
+ ErrorStrategy,
49
+ RetryConfig,
50
+ InputDefinition,
51
+ } from "./model/index";
@@ -0,0 +1,183 @@
1
+ export interface Workflow {
2
+ id?: string;
3
+ name?: string;
4
+ version?: string | number;
5
+ defaults?: WorkflowDefaults;
6
+ inputs?: Record<string, InputDefinition | null>;
7
+ participants?: Record<string, Participant>;
8
+ flow: FlowStep[];
9
+ output?: WorkflowOutput;
10
+ }
11
+
12
+ export interface WorkflowDefaults {
13
+ timeout?: string;
14
+ cwd?: string;
15
+ onError?: ErrorStrategy;
16
+ }
17
+
18
+ export type ErrorStrategy = "fail" | "skip" | "retry" | string;
19
+
20
+ export interface RetryConfig {
21
+ max: number;
22
+ backoff?: string;
23
+ factor?: number;
24
+ }
25
+
26
+ export interface ParticipantBase {
27
+ type: string;
28
+ as?: string;
29
+ timeout?: string;
30
+ onError?: ErrorStrategy;
31
+ retry?: RetryConfig;
32
+ input?: string | Record<string, string>;
33
+ output?: Record<string, unknown>;
34
+ when?: string;
35
+ }
36
+
37
+ export interface ExecParticipant extends ParticipantBase {
38
+ type: "exec";
39
+ run?: string;
40
+ env?: Record<string, string>;
41
+ cwd?: string;
42
+ }
43
+
44
+ export interface HttpParticipant extends ParticipantBase {
45
+ type: "http";
46
+ method?: string;
47
+ url: string;
48
+ headers?: Record<string, string>;
49
+ body?: string | Record<string, unknown>;
50
+ }
51
+
52
+ export interface McpParticipant extends ParticipantBase {
53
+ type: "mcp";
54
+ server?: string;
55
+ tool?: string;
56
+ }
57
+
58
+ export interface WorkflowParticipant extends ParticipantBase {
59
+ type: "workflow";
60
+ path: string;
61
+ }
62
+
63
+ export interface EmitParticipant extends ParticipantBase {
64
+ type: "emit";
65
+ event: string;
66
+ payload?: string | Record<string, unknown>;
67
+ ack?: boolean;
68
+ onTimeout?: "fail" | "skip";
69
+ }
70
+
71
+ export type Participant =
72
+ | ExecParticipant
73
+ | HttpParticipant
74
+ | McpParticipant
75
+ | WorkflowParticipant
76
+ | EmitParticipant;
77
+
78
+ export type InlineParticipant = Participant & {
79
+ as?: string;
80
+ when?: string;
81
+ };
82
+
83
+ export interface WaitStep {
84
+ wait: {
85
+ event?: string;
86
+ match?: string;
87
+ until?: string;
88
+ poll?: string;
89
+ timeout?: string;
90
+ onTimeout?: string;
91
+ };
92
+ }
93
+
94
+ export interface SetStep {
95
+ set: Record<string, string>;
96
+ }
97
+
98
+ export type FlowStep = string | FlowStepOverride | LoopStep | ParallelStep | IfStep | WaitStep | SetStep | InlineParticipant;
99
+
100
+ export interface FlowStepOverride {
101
+ [participantName: string]: {
102
+ timeout?: string;
103
+ onError?: ErrorStrategy;
104
+ when?: string;
105
+ input?: string | Record<string, string>;
106
+ retry?: RetryConfig;
107
+ workflow?: string;
108
+ };
109
+ }
110
+
111
+ export interface LoopStep {
112
+ loop: {
113
+ as?: string;
114
+ until?: string;
115
+ max?: number | string;
116
+ steps: FlowStep[];
117
+ };
118
+ }
119
+
120
+ export interface ParallelStep {
121
+ parallel: FlowStep[];
122
+ }
123
+
124
+ export interface IfStep {
125
+ if: {
126
+ condition: string;
127
+ then: FlowStep[];
128
+ else?: FlowStep[];
129
+ };
130
+ }
131
+
132
+ export interface InputDefinition {
133
+ type?: string;
134
+ description?: string;
135
+ required?: boolean;
136
+ default?: unknown;
137
+ format?: string;
138
+ enum?: unknown[];
139
+ minimum?: number;
140
+ maximum?: number;
141
+ minLength?: number;
142
+ maxLength?: number;
143
+ pattern?: string;
144
+ items?: InputDefinition;
145
+ }
146
+
147
+ export type WorkflowOutput =
148
+ | string
149
+ | Record<string, string>
150
+ | { schema: Record<string, InputDefinition>; map: Record<string, string> };
151
+
152
+ export interface StepResult {
153
+ status: "success" | "failure" | "skipped";
154
+ output: string;
155
+ parsedOutput?: unknown;
156
+ error?: string;
157
+ duration: number;
158
+ startedAt?: string;
159
+ finishedAt?: string;
160
+ retries?: number;
161
+ cwd?: string;
162
+ /** HTTP status code on HTTP participant failure */
163
+ httpStatus?: number;
164
+ /** HTTP response body on HTTP participant failure */
165
+ responseBody?: string;
166
+ }
167
+
168
+ export interface WorkflowResult {
169
+ success: boolean;
170
+ output: unknown;
171
+ steps: Record<string, StepResult>;
172
+ duration: number;
173
+ }
174
+
175
+ export interface ValidationResult {
176
+ valid: boolean;
177
+ errors: ValidationError[];
178
+ }
179
+
180
+ export interface ValidationError {
181
+ path: string;
182
+ message: string;
183
+ }
@@ -0,0 +1,4 @@
1
+ export { parseWorkflow, parseWorkflowFile } from "./parser";
2
+ export { validateSchema } from "./schema";
3
+ export { validateSemantic } from "./validate";
4
+ export { validateInputs } from "./validate_inputs";
@@ -0,0 +1,13 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import yaml from "yaml";
3
+ import type { Workflow } from "../model/index";
4
+
5
+ export function parseWorkflow(yamlContent: string): Workflow {
6
+ const parsed = yaml.parse(yamlContent) as Workflow;
7
+ return parsed;
8
+ }
9
+
10
+ export async function parseWorkflowFile(filePath: string): Promise<Workflow> {
11
+ const content = await readFile(filePath, "utf-8");
12
+ return parseWorkflow(content);
13
+ }