@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,187 @@
1
+ import type { InputDefinition, ValidationError, ValidationResult } from "../model/index";
2
+
3
+ function coerceValue(value: unknown, typeName: string): unknown {
4
+ if (typeof value !== "string") return value;
5
+
6
+ switch (typeName) {
7
+ case "integer": {
8
+ const n = Number(value);
9
+ if (Number.isInteger(n)) return n;
10
+ return value;
11
+ }
12
+ case "number": {
13
+ const n = Number(value);
14
+ if (Number.isFinite(n)) return n;
15
+ return value;
16
+ }
17
+ case "boolean": {
18
+ if (value === "true") return true;
19
+ if (value === "false") return false;
20
+ return value;
21
+ }
22
+ default:
23
+ return value;
24
+ }
25
+ }
26
+
27
+ function matchesType(typeName: string, value: unknown): boolean {
28
+ switch (typeName) {
29
+ case "string":
30
+ return typeof value === "string";
31
+ case "integer":
32
+ return typeof value === "number" && Number.isInteger(value);
33
+ case "number":
34
+ return typeof value === "number";
35
+ case "boolean":
36
+ return typeof value === "boolean";
37
+ case "array":
38
+ return Array.isArray(value);
39
+ case "object":
40
+ return typeof value === "object" && value !== null && !Array.isArray(value);
41
+ default:
42
+ return true;
43
+ }
44
+ }
45
+
46
+ function validateConstraints(
47
+ name: string,
48
+ value: unknown,
49
+ definition: InputDefinition,
50
+ errors: ValidationError[],
51
+ ): void {
52
+ // Enum
53
+ if (definition.enum && !definition.enum.includes(value)) {
54
+ errors.push({
55
+ path: `inputs.${name}`,
56
+ message: `input '${name}' must be one of: ${definition.enum.join(", ")}`,
57
+ });
58
+ }
59
+
60
+ // Numeric constraints
61
+ if (typeof value === "number") {
62
+ if (definition.minimum !== undefined && value < definition.minimum) {
63
+ errors.push({
64
+ path: `inputs.${name}`,
65
+ message: `input '${name}' must be >= ${definition.minimum}`,
66
+ });
67
+ }
68
+ if (definition.maximum !== undefined && value > definition.maximum) {
69
+ errors.push({
70
+ path: `inputs.${name}`,
71
+ message: `input '${name}' must be <= ${definition.maximum}`,
72
+ });
73
+ }
74
+ }
75
+
76
+ // String constraints
77
+ if (typeof value === "string") {
78
+ if (definition.minLength !== undefined && value.length < definition.minLength) {
79
+ errors.push({
80
+ path: `inputs.${name}`,
81
+ message: `input '${name}' must have at least ${definition.minLength} characters`,
82
+ });
83
+ }
84
+ if (definition.maxLength !== undefined && value.length > definition.maxLength) {
85
+ errors.push({
86
+ path: `inputs.${name}`,
87
+ message: `input '${name}' must have at most ${definition.maxLength} characters`,
88
+ });
89
+ }
90
+ if (definition.pattern) {
91
+ const regex = new RegExp(definition.pattern);
92
+ if (!regex.test(value)) {
93
+ errors.push({
94
+ path: `inputs.${name}`,
95
+ message: `input '${name}' must match pattern: ${definition.pattern}`,
96
+ });
97
+ }
98
+ }
99
+ }
100
+
101
+ // Format validation (best-effort)
102
+ if (typeof value === "string" && definition.format) {
103
+ let valid = true;
104
+ switch (definition.format) {
105
+ case "date":
106
+ valid = !Number.isNaN(Date.parse(value)) && /^\d{4}-\d{2}-\d{2}$/.test(value);
107
+ break;
108
+ case "date-time":
109
+ valid = !Number.isNaN(Date.parse(value));
110
+ break;
111
+ case "uri":
112
+ try {
113
+ new URL(value);
114
+ } catch {
115
+ valid = false;
116
+ }
117
+ break;
118
+ case "email":
119
+ valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
120
+ break;
121
+ }
122
+ if (!valid) {
123
+ errors.push({
124
+ path: `inputs.${name}`,
125
+ message: `input '${name}' must be a valid ${definition.format}`,
126
+ });
127
+ }
128
+ }
129
+ }
130
+
131
+ export function validateInputs(
132
+ inputDefs: Record<string, InputDefinition | null> | undefined,
133
+ provided: Record<string, unknown>,
134
+ ): { result: ValidationResult; resolved: Record<string, unknown> } {
135
+ if (!inputDefs) {
136
+ return {
137
+ result: { valid: true, errors: [] },
138
+ resolved: { ...provided },
139
+ };
140
+ }
141
+
142
+ const errors: ValidationError[] = [];
143
+ const resolved: Record<string, unknown> = { ...provided };
144
+
145
+ for (const [name, definition] of Object.entries(inputDefs)) {
146
+ // null definition means string type with no schema
147
+ if (definition === null) {
148
+ continue;
149
+ }
150
+
151
+ const hasProvided = name in provided;
152
+
153
+ if (!hasProvided) {
154
+ if (definition.default !== undefined) {
155
+ resolved[name] = definition.default;
156
+ } else if (definition.required) {
157
+ errors.push({
158
+ path: `inputs.${name}`,
159
+ message: `required input '${name}' is missing`,
160
+ });
161
+ }
162
+ continue;
163
+ }
164
+
165
+ // Coerce from CLI string values
166
+ if (definition.type) {
167
+ resolved[name] = coerceValue(provided[name], definition.type);
168
+ }
169
+
170
+ const value = resolved[name];
171
+
172
+ if (definition.type && !matchesType(definition.type, value)) {
173
+ errors.push({
174
+ path: `inputs.${name}`,
175
+ message: `input '${name}' must be of type '${definition.type}'`,
176
+ });
177
+ continue;
178
+ }
179
+
180
+ validateConstraints(name, value, definition, errors);
181
+ }
182
+
183
+ return {
184
+ result: { valid: errors.length === 0, errors },
185
+ resolved,
186
+ };
187
+ }
@@ -0,0 +1,63 @@
1
+ import { evaluateCel } from "../cel/index";
2
+ import type { EventHub } from "../eventhub/types";
3
+ import type { EmitParticipant, StepResult } from "../model/index";
4
+ import { parseDuration } from "../engine/errors";
5
+
6
+ export async function executeEmit(
7
+ participant: EmitParticipant,
8
+ context: Record<string, unknown>,
9
+ hub: EventHub,
10
+ ): Promise<StepResult> {
11
+ const startedAt = new Date().toISOString();
12
+ const start = Date.now();
13
+
14
+ // Resolve payload
15
+ let resolvedPayload: unknown;
16
+ if (typeof participant.payload === "string") {
17
+ resolvedPayload = evaluateCel(participant.payload, context);
18
+ } else if (participant.payload && typeof participant.payload === "object") {
19
+ const resolved: Record<string, unknown> = {};
20
+ for (const [key, value] of Object.entries(participant.payload)) {
21
+ resolved[key] = typeof value === "string" ? evaluateCel(value, context) : value;
22
+ }
23
+ resolvedPayload = resolved;
24
+ } else {
25
+ resolvedPayload = {};
26
+ }
27
+
28
+ try {
29
+ if (participant.ack) {
30
+ const timeoutMs = participant.timeout ? parseDuration(participant.timeout) : 30000;
31
+ await hub.publishAndWaitAck(participant.event, resolvedPayload, timeoutMs);
32
+ } else {
33
+ await hub.publish(participant.event, resolvedPayload);
34
+ }
35
+
36
+ return {
37
+ status: "success",
38
+ output: JSON.stringify({ ack: true }),
39
+ parsedOutput: { ack: true },
40
+ duration: Date.now() - start,
41
+ startedAt,
42
+ finishedAt: new Date().toISOString(),
43
+ };
44
+ } catch (err) {
45
+ const isTimeout =
46
+ err instanceof Error &&
47
+ (err.name === "AbortError" ||
48
+ err.name === "TimeoutError" ||
49
+ /timed?\s*out/i.test(err.message));
50
+
51
+ if (participant.onTimeout === "skip" && isTimeout) {
52
+ return {
53
+ status: "success",
54
+ output: JSON.stringify({ ack: false }),
55
+ parsedOutput: { ack: false },
56
+ duration: Date.now() - start,
57
+ startedAt,
58
+ finishedAt: new Date().toISOString(),
59
+ };
60
+ }
61
+ throw err;
62
+ }
63
+ }
@@ -0,0 +1,158 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { StepResult } from "../model/index";
3
+
4
+ function isMap(value: unknown): value is Record<string, unknown> {
5
+ return typeof value === "object" && value !== null && !Array.isArray(value);
6
+ }
7
+
8
+ function mapToEnvVars(input: Record<string, unknown>): Record<string, string> {
9
+ const env: Record<string, string> = {};
10
+ for (const [key, value] of Object.entries(input)) {
11
+ env[key] = typeof value === "string" ? value : String(value);
12
+ }
13
+ return env;
14
+ }
15
+
16
+ export async function executeExec(
17
+ participant: { run?: string; env?: Record<string, string>; cwd?: string },
18
+ input?: unknown,
19
+ env: Record<string, string> = {},
20
+ signal?: AbortSignal,
21
+ ): Promise<StepResult> {
22
+ const command = participant.run ?? "";
23
+ const participantEnv = participant.env ?? {};
24
+ const cwd = participant.cwd ?? process.cwd();
25
+
26
+ // Spec v0.6: map input → env vars, string input → stdin, no input → nothing
27
+ const inputEnvVars = isMap(input) ? mapToEnvVars(input) : {};
28
+ const stdinData = typeof input === "string" ? input : undefined;
29
+
30
+ const startedAt = new Date().toISOString();
31
+ const start = Date.now();
32
+
33
+ return new Promise<StepResult>((resolve) => {
34
+ try {
35
+ const proc = spawn("sh", ["-c", command], {
36
+ env: { ...process.env, ...env, ...participantEnv, ...inputEnvVars },
37
+ cwd,
38
+ stdio: ["pipe", "pipe", "pipe"],
39
+ });
40
+
41
+ let stdout = "";
42
+ let stderr = "";
43
+
44
+ proc.stdout.on("data", (d: Buffer) => {
45
+ stdout += d.toString();
46
+ });
47
+ proc.stderr.on("data", (d: Buffer) => {
48
+ stderr += d.toString();
49
+ });
50
+
51
+ let aborted = false;
52
+ const onAbort = () => {
53
+ aborted = true;
54
+ try {
55
+ proc.kill("SIGKILL");
56
+ } catch (_) {}
57
+ };
58
+
59
+ if (signal) {
60
+ if (signal.aborted) onAbort();
61
+ else signal.addEventListener("abort", onAbort);
62
+ }
63
+
64
+ proc.on("error", (err) => {
65
+ const duration = Date.now() - start;
66
+ if (signal) signal.removeEventListener("abort", onAbort);
67
+ resolve({
68
+ status: "failure",
69
+ output: "",
70
+ parsedOutput: undefined,
71
+ error: String(err),
72
+ duration,
73
+ startedAt,
74
+ finishedAt: new Date().toISOString(),
75
+ cwd,
76
+ });
77
+ });
78
+
79
+ proc.on("close", (code) => {
80
+ const duration = Date.now() - start;
81
+ if (signal) signal.removeEventListener("abort", onAbort);
82
+
83
+ if (aborted) {
84
+ resolve({
85
+ status: "failure",
86
+ output: stdout,
87
+ parsedOutput: undefined,
88
+ error: "aborted",
89
+ duration,
90
+ startedAt,
91
+ finishedAt: new Date().toISOString(),
92
+ cwd,
93
+ });
94
+ return;
95
+ }
96
+
97
+ const exitCode = code ?? 1;
98
+ if (exitCode !== 0) {
99
+ const errMsg = stderr.trim() || `exit code ${exitCode}`;
100
+ resolve({
101
+ status: "failure",
102
+ output: stdout,
103
+ parsedOutput: undefined,
104
+ error: errMsg,
105
+ duration,
106
+ startedAt,
107
+ finishedAt: new Date().toISOString(),
108
+ cwd,
109
+ });
110
+ return;
111
+ }
112
+
113
+ let parsed: unknown | undefined = undefined;
114
+ try {
115
+ parsed = JSON.parse(stdout);
116
+ } catch (_) {
117
+ // ignore parse errors
118
+ }
119
+
120
+ resolve({
121
+ status: "success",
122
+ output: stdout,
123
+ parsedOutput: parsed,
124
+ duration,
125
+ startedAt,
126
+ finishedAt: new Date().toISOString(),
127
+ cwd,
128
+ });
129
+ });
130
+
131
+ // Spec v0.6: only write to stdin when input is a string
132
+ if (stdinData !== undefined) {
133
+ try {
134
+ proc.stdin.write(stdinData);
135
+ proc.stdin.end();
136
+ } catch (_) {
137
+ // ignore
138
+ }
139
+ } else {
140
+ try { proc.stdin.end(); } catch (_) { /* ignore */ }
141
+ }
142
+ } catch (err) {
143
+ const duration = Date.now() - start;
144
+ resolve({
145
+ status: "failure",
146
+ output: "",
147
+ parsedOutput: undefined,
148
+ error: String(err),
149
+ duration,
150
+ startedAt,
151
+ finishedAt: new Date().toISOString(),
152
+ cwd,
153
+ });
154
+ }
155
+ });
156
+ }
157
+
158
+ export default executeExec;
@@ -0,0 +1,45 @@
1
+ import type { StepResult } from "../model/index";
2
+
3
+ export default async function executeHttp(participant: { url: string; method?: string; headers?: Record<string, string>; body?: unknown }, input?: string): Promise<StepResult> {
4
+ const startedAt = new Date().toISOString();
5
+ const start = Date.now();
6
+ const method = (participant.method ?? "GET").toUpperCase();
7
+ const url = participant.url;
8
+ const headers = participant.headers ?? {};
9
+ let body: unknown = participant.body;
10
+ if ((body === undefined || body === null) && input !== undefined) {
11
+ body = input;
12
+ }
13
+
14
+ const fetchOptions: RequestInit = { method, headers: headers as HeadersInit };
15
+ if (body !== undefined) fetchOptions.body = typeof body === "string" ? body : JSON.stringify(body);
16
+
17
+ const res = await fetch(url, fetchOptions);
18
+ const text = await res.text();
19
+ const duration = Date.now() - start;
20
+
21
+ if (!res.ok) {
22
+ const err: Error & { httpStatus?: number; responseBody?: string } = new Error(
23
+ `http participant request failed: ${res.status} ${res.statusText}`,
24
+ );
25
+ err.httpStatus = res.status;
26
+ err.responseBody = text;
27
+ throw err;
28
+ }
29
+
30
+ let parsedOutput: unknown;
31
+ try {
32
+ parsedOutput = JSON.parse(text);
33
+ } catch (_) {
34
+ // ignore parse errors
35
+ }
36
+
37
+ return {
38
+ status: "success",
39
+ output: text,
40
+ parsedOutput,
41
+ duration,
42
+ startedAt,
43
+ finishedAt: new Date().toISOString(),
44
+ };
45
+ }
@@ -0,0 +1,61 @@
1
+ import type { EventHub } from "../eventhub/types";
2
+ import type { EmitParticipant, McpParticipant, Participant, StepResult, WorkflowParticipant } from "../model/index";
3
+ import { executeEmit } from "./emit";
4
+ import { executeExec } from "./exec";
5
+ import executeHttp from "./http";
6
+ import { executeMcp } from "./mcp";
7
+ import { executeSubWorkflow } from "./workflow";
8
+ import type { AncestorPaths, WorkflowEngineExecutor } from "./workflow";
9
+
10
+ export type ExecutorFunction = (
11
+ participant: Participant,
12
+ input: unknown,
13
+ env: Record<string, string>,
14
+ basePath?: string,
15
+ engineExecutor?: WorkflowEngineExecutor,
16
+ hub?: EventHub,
17
+ celContext?: Record<string, unknown>,
18
+ ancestorPaths?: AncestorPaths,
19
+ ) => Promise<StepResult>;
20
+
21
+ const executors: Record<string, ExecutorFunction> = {
22
+ exec: async (participant, input, env) => executeExec(participant as Parameters<typeof executeExec>[0], input, env),
23
+ http: async (participant, input) => executeHttp(participant as Parameters<typeof executeHttp>[0], input as string | undefined),
24
+ workflow: async (participant, input, _env, basePath, engineExecutor, _hub, _celContext, ancestorPaths) => {
25
+ if (!basePath) {
26
+ throw new Error("workflow participant execution requires basePath");
27
+ }
28
+ if (!engineExecutor) {
29
+ throw new Error("workflow participant execution requires engineExecutor");
30
+ }
31
+ return executeSubWorkflow(participant as WorkflowParticipant, input, basePath, engineExecutor, ancestorPaths);
32
+ },
33
+ mcp: async (participant, input) => executeMcp(participant as McpParticipant, input),
34
+ emit: async (participant, _input, _env, _basePath, _engineExecutor, hub, celContext) => {
35
+ if (!hub) {
36
+ throw new Error("emit participant requires an event hub but none was provided");
37
+ }
38
+ if (!celContext) {
39
+ throw new Error("emit participant requires CEL context");
40
+ }
41
+ return executeEmit(participant as EmitParticipant, celContext, hub);
42
+ },
43
+ };
44
+
45
+ export async function executeParticipant(
46
+ participant: Participant,
47
+ input: unknown,
48
+ env: Record<string, string> = {},
49
+ basePath?: string,
50
+ engineExecutor?: WorkflowEngineExecutor,
51
+ hub?: EventHub,
52
+ celContext?: Record<string, unknown>,
53
+ ancestorPaths?: AncestorPaths,
54
+ ): Promise<StepResult> {
55
+ const executor = executors[participant.type];
56
+ if (!executor) {
57
+ throw new Error(`participant type '${participant.type}' is not yet implemented`);
58
+ }
59
+
60
+ return executor(participant, input, env, basePath, engineExecutor, hub, celContext, ancestorPaths);
61
+ }
@@ -0,0 +1,8 @@
1
+ import type { McpParticipant, StepResult } from "../model/index";
2
+
3
+ export async function executeMcp(participant: McpParticipant, _input: unknown): Promise<StepResult> {
4
+ throw new Error(
5
+ `mcp participant is not yet implemented (server: ${participant.server ?? "unspecified"}, tool: ${participant.tool ?? "unspecified"}). ` +
6
+ "Use onError to handle this gracefully.",
7
+ );
8
+ }
@@ -0,0 +1,73 @@
1
+ import { dirname, resolve } from "node:path";
2
+ import type { StepResult, Workflow, WorkflowParticipant, WorkflowResult } from "../model/index";
3
+ import { parseWorkflowFile } from "../parser/parser";
4
+ import { validateSchema } from "../parser/schema";
5
+ import { validateSemantic } from "../parser/validate";
6
+
7
+ export type WorkflowEngineExecutor = (
8
+ workflow: Workflow,
9
+ inputs: Record<string, unknown>,
10
+ basePath: string,
11
+ ) => Promise<WorkflowResult>;
12
+
13
+ /** Set of ancestor workflow file paths, injected by engine for circular detection */
14
+ export type AncestorPaths = Set<string>;
15
+
16
+ function toWorkflowInputs(input: unknown): Record<string, unknown> {
17
+ if (input && typeof input === "object" && !Array.isArray(input)) {
18
+ return { ...(input as Record<string, unknown>) };
19
+ }
20
+
21
+ if (input === undefined) {
22
+ return {};
23
+ }
24
+
25
+ return { input };
26
+ }
27
+
28
+ export async function executeSubWorkflow(
29
+ participant: WorkflowParticipant,
30
+ input: unknown,
31
+ basePath: string,
32
+ engineExecutor: WorkflowEngineExecutor,
33
+ ancestorPaths?: AncestorPaths,
34
+ ): Promise<StepResult> {
35
+ const start = Date.now();
36
+
37
+ const resolvedPath = resolve(basePath, participant.path);
38
+
39
+ // Circular sub-workflow detection (§13.2)
40
+ if (ancestorPaths?.has(resolvedPath)) {
41
+ throw new Error(
42
+ `circular sub-workflow detected: '${resolvedPath}' is already on the call stack`,
43
+ );
44
+ }
45
+ ancestorPaths?.add(resolvedPath);
46
+
47
+ const subWorkflow = await parseWorkflowFile(resolvedPath);
48
+
49
+ const schemaResult = validateSchema(subWorkflow);
50
+ if (!schemaResult.valid) {
51
+ throw new Error(`schema validation failed: ${JSON.stringify(schemaResult.errors)}`);
52
+ }
53
+
54
+ const semanticResult = await validateSemantic(subWorkflow, dirname(resolvedPath));
55
+ if (!semanticResult.valid) {
56
+ throw new Error(`semantic validation failed: ${JSON.stringify(semanticResult.errors)}`);
57
+ }
58
+
59
+ try {
60
+ const result = await engineExecutor(subWorkflow, toWorkflowInputs(input), dirname(resolvedPath));
61
+ const output =
62
+ typeof result.output === "string" ? result.output : JSON.stringify(result.output ?? null);
63
+
64
+ return {
65
+ status: result.success ? "success" : "failure",
66
+ output,
67
+ parsedOutput: result.output,
68
+ duration: Date.now() - start,
69
+ };
70
+ } finally {
71
+ ancestorPaths?.delete(resolvedPath);
72
+ }
73
+ }