@duckflux/core 0.6.8 → 0.7.0
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/dist/engine/index.js +448 -36
- package/dist/index.js +450 -36
- package/package.json +1 -1
- package/src/engine/control.ts +60 -23
- package/src/engine/engine.ts +69 -34
- package/src/engine/sequential.ts +11 -1
- package/src/engine/state.ts +8 -1
- package/src/model/index.ts +31 -0
- package/src/parser/schema/duckflux.schema.json +1 -1
- package/src/tracer/index.ts +108 -0
- package/src/tracer/writers/json.ts +68 -0
- package/src/tracer/writers/sqlite.ts +114 -0
- package/src/tracer/writers/txt.ts +82 -0
package/src/engine/control.ts
CHANGED
|
@@ -33,14 +33,25 @@ export async function executeControlStep(
|
|
|
33
33
|
|
|
34
34
|
// Wait step
|
|
35
35
|
if ("wait" in obj && Object.keys(obj).length === 1) {
|
|
36
|
-
const
|
|
37
|
-
|
|
36
|
+
const loopIndex = state.isInsideLoop() ? state.currentLoopIndex() : undefined;
|
|
37
|
+
const traceSeq = state.tracer?.startStep("wait", "wait", undefined, loopIndex);
|
|
38
|
+
try {
|
|
39
|
+
const { executeWait } = await import("./wait");
|
|
40
|
+
const result = await executeWait(state, (obj as { wait: Record<string, unknown> }).wait, chain, hub, signal);
|
|
41
|
+
if (traceSeq !== undefined) state.tracer?.endStep(traceSeq, "success", result);
|
|
42
|
+
return result;
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if (traceSeq !== undefined) state.tracer?.endStep(traceSeq, "failure", undefined, String((err as Error)?.message ?? err));
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
38
47
|
}
|
|
39
48
|
|
|
40
49
|
// Set step — write values to execution.context
|
|
41
50
|
if ("set" in obj && Object.keys(obj).length === 1) {
|
|
42
51
|
const setDef = (obj as { set: Record<string, string> }).set;
|
|
43
52
|
const ctx = state.toCelContext();
|
|
53
|
+
const loopIndex = state.isInsideLoop() ? state.currentLoopIndex() : undefined;
|
|
54
|
+
const traceSeq = state.tracer?.startStep("set", "set", setDef, loopIndex);
|
|
44
55
|
|
|
45
56
|
if (!state.executionMeta.context) {
|
|
46
57
|
state.executionMeta.context = {};
|
|
@@ -48,11 +59,13 @@ export async function executeControlStep(
|
|
|
48
59
|
|
|
49
60
|
for (const [key, expr] of Object.entries(setDef)) {
|
|
50
61
|
if (RESERVED_SET_KEYS.has(key)) {
|
|
62
|
+
if (traceSeq !== undefined) state.tracer?.endStep(traceSeq, "failure", undefined, `set key '${key}' uses a reserved name`);
|
|
51
63
|
throw new Error(`set key '${key}' uses a reserved name`);
|
|
52
64
|
}
|
|
53
65
|
state.executionMeta.context[key] = evaluateCel(expr, ctx);
|
|
54
66
|
}
|
|
55
67
|
|
|
68
|
+
if (traceSeq !== undefined) state.tracer?.endStep(traceSeq, "success", state.executionMeta.context);
|
|
56
69
|
// set does not produce output; chain passes through unchanged
|
|
57
70
|
return chain;
|
|
58
71
|
}
|
|
@@ -75,6 +88,8 @@ export async function executeControlStep(
|
|
|
75
88
|
}
|
|
76
89
|
|
|
77
90
|
const hasMax = loopDef.max !== undefined;
|
|
91
|
+
const outerLoopIndex = state.isInsideLoop() ? state.currentLoopIndex() : undefined;
|
|
92
|
+
const loopTraceSeq = state.tracer?.startStep(loopAs ?? "loop", "loop", undefined, outerLoopIndex);
|
|
78
93
|
|
|
79
94
|
state.pushLoop(loopAs);
|
|
80
95
|
let loopChain = chain;
|
|
@@ -114,6 +129,10 @@ export async function executeControlStep(
|
|
|
114
129
|
iterations += 1;
|
|
115
130
|
state.incrementLoop();
|
|
116
131
|
}
|
|
132
|
+
if (loopTraceSeq !== undefined) state.tracer?.endStep(loopTraceSeq, "success", loopChain);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
if (loopTraceSeq !== undefined) state.tracer?.endStep(loopTraceSeq, "failure", undefined, String((err as Error)?.message ?? err));
|
|
135
|
+
throw err;
|
|
117
136
|
} finally {
|
|
118
137
|
state.popLoop();
|
|
119
138
|
}
|
|
@@ -124,44 +143,62 @@ export async function executeControlStep(
|
|
|
124
143
|
if ("parallel" in obj && Object.keys(obj).length === 1) {
|
|
125
144
|
const parallelSteps = (obj as { parallel: unknown[] }).parallel;
|
|
126
145
|
const controller = new AbortController();
|
|
146
|
+
const loopIndex = state.isInsideLoop() ? state.currentLoopIndex() : undefined;
|
|
147
|
+
const parallelTraceSeq = state.tracer?.startStep("parallel", "parallel", undefined, loopIndex);
|
|
127
148
|
// Combine parent signal with local controller
|
|
128
149
|
const branchSignal = signal
|
|
129
150
|
? AbortSignal.any([signal, controller.signal])
|
|
130
151
|
: controller.signal;
|
|
131
152
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
153
|
+
try {
|
|
154
|
+
const results = await Promise.all(
|
|
155
|
+
parallelSteps.map(async (parallelStep) => {
|
|
156
|
+
try {
|
|
157
|
+
// Each branch starts with the same incoming chain
|
|
158
|
+
return await executeControlStep(workflow, state, parallelStep, basePath, engineExecutor, chain, hub, branchSignal);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
controller.abort();
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
if (parallelTraceSeq !== undefined) state.tracer?.endStep(parallelTraceSeq, "success", results);
|
|
167
|
+
// Chain after parallel is ordered array of branch outputs
|
|
168
|
+
return results;
|
|
169
|
+
} catch (err) {
|
|
170
|
+
if (parallelTraceSeq !== undefined) state.tracer?.endStep(parallelTraceSeq, "failure", undefined, String((err as Error)?.message ?? err));
|
|
171
|
+
throw err;
|
|
172
|
+
}
|
|
146
173
|
}
|
|
147
174
|
|
|
148
175
|
// If step
|
|
149
176
|
if ("if" in obj && Object.keys(obj).length === 1) {
|
|
150
177
|
const ifDef = (obj as { if: { condition: string; then: unknown[]; else?: unknown[] } }).if;
|
|
151
178
|
const condition = evaluateCel(ifDef.condition, state.toCelContext());
|
|
179
|
+
const loopIndex = state.isInsideLoop() ? state.currentLoopIndex() : undefined;
|
|
180
|
+
const ifTraceSeq = state.tracer?.startStep("if", "if", { condition: ifDef.condition }, loopIndex);
|
|
152
181
|
|
|
153
182
|
if (typeof condition !== "boolean") {
|
|
183
|
+
if (ifTraceSeq !== undefined) state.tracer?.endStep(ifTraceSeq, "failure", undefined, `if.condition must evaluate to boolean, got ${typeof condition}`);
|
|
154
184
|
throw new Error(`if.condition must evaluate to boolean, got ${typeof condition}`);
|
|
155
185
|
}
|
|
156
186
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
187
|
+
try {
|
|
188
|
+
let result: unknown;
|
|
189
|
+
if (condition) {
|
|
190
|
+
result = await executeSequential(workflow, state, ifDef.then, basePath, engineExecutor, chain, hub, signal);
|
|
191
|
+
} else if (ifDef.else) {
|
|
192
|
+
result = await executeSequential(workflow, state, ifDef.else, basePath, engineExecutor, chain, hub, signal);
|
|
193
|
+
} else {
|
|
194
|
+
result = chain;
|
|
195
|
+
}
|
|
196
|
+
if (ifTraceSeq !== undefined) state.tracer?.endStep(ifTraceSeq, "success", result);
|
|
197
|
+
return result;
|
|
198
|
+
} catch (err) {
|
|
199
|
+
if (ifTraceSeq !== undefined) state.tracer?.endStep(ifTraceSeq, "failure", undefined, String((err as Error)?.message ?? err));
|
|
200
|
+
throw err;
|
|
161
201
|
}
|
|
162
|
-
|
|
163
|
-
// False without else: chain passes through
|
|
164
|
-
return chain;
|
|
165
202
|
}
|
|
166
203
|
|
|
167
204
|
// Inline participant (has `type` field) or participant override
|
package/src/engine/engine.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { validateSchema } from "../parser/schema";
|
|
|
6
6
|
import { validateSemantic } from "../parser/validate";
|
|
7
7
|
import { validateInputs } from "../parser/validate_inputs";
|
|
8
8
|
import type { WorkflowEngineExecutor } from "../participant/workflow";
|
|
9
|
+
import { TraceCollector, createTraceWriter } from "../tracer/index";
|
|
9
10
|
import { executeControlStep } from "./control";
|
|
10
11
|
import { validateOutputSchema } from "./output";
|
|
11
12
|
import { WorkflowState } from "./state";
|
|
@@ -23,6 +24,10 @@ export interface ExecuteOptions {
|
|
|
23
24
|
executionNumber?: number;
|
|
24
25
|
verbose?: boolean;
|
|
25
26
|
quiet?: boolean;
|
|
27
|
+
/** Directory to write trace files; one file per execution named <executionId>.<ext> */
|
|
28
|
+
traceDir?: string;
|
|
29
|
+
/** Trace output format (default: "json") */
|
|
30
|
+
traceFormat?: "json" | "txt" | "sqlite";
|
|
26
31
|
/** @internal Tracks ancestor workflow paths for circular detection */
|
|
27
32
|
_ancestorPaths?: Set<string>;
|
|
28
33
|
}
|
|
@@ -51,13 +56,30 @@ export async function executeWorkflow(
|
|
|
51
56
|
state.executionMeta.cwd = options.cwd;
|
|
52
57
|
}
|
|
53
58
|
|
|
54
|
-
const
|
|
59
|
+
const startedAtMs = performance.now();
|
|
60
|
+
const startedAtIso = state.executionMeta.startedAt;
|
|
55
61
|
|
|
56
62
|
// Propagate ancestor paths for circular sub-workflow detection
|
|
57
63
|
if (options._ancestorPaths) {
|
|
58
64
|
state.ancestorPaths = options._ancestorPaths;
|
|
59
65
|
}
|
|
60
66
|
|
|
67
|
+
// Set up incremental tracing if requested (only on the root workflow, not sub-workflows)
|
|
68
|
+
if (options.traceDir && !options._ancestorPaths) {
|
|
69
|
+
const collector = new TraceCollector();
|
|
70
|
+
const writer = await createTraceWriter(options.traceDir, options.traceFormat ?? "json");
|
|
71
|
+
collector.writer = writer;
|
|
72
|
+
state.tracer = collector;
|
|
73
|
+
await writer.open({
|
|
74
|
+
id: state.executionMeta.id,
|
|
75
|
+
workflowId: workflow.id,
|
|
76
|
+
workflowName: workflow.name,
|
|
77
|
+
workflowVersion: workflow.version,
|
|
78
|
+
startedAt: startedAtIso,
|
|
79
|
+
inputs: resolved,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
61
83
|
const engineExecutor: WorkflowEngineExecutor = async (subWorkflow, subInputs, subBasePath) => {
|
|
62
84
|
// Sub-workflows share parent hub, propagate ancestor paths for circular detection
|
|
63
85
|
return executeWorkflow(subWorkflow, subInputs, subBasePath, {
|
|
@@ -66,43 +88,56 @@ export async function executeWorkflow(
|
|
|
66
88
|
});
|
|
67
89
|
};
|
|
68
90
|
|
|
69
|
-
// Execute flow with chain threading
|
|
70
|
-
let chain: unknown;
|
|
71
|
-
for (const step of workflow.flow) {
|
|
72
|
-
chain = await executeControlStep(workflow, state, step, basePath, engineExecutor, chain, options.hub);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Output resolution
|
|
76
91
|
let output: unknown;
|
|
77
|
-
|
|
78
|
-
output = state.resolveOutput(workflow.output, evaluateCel);
|
|
79
|
-
|
|
80
|
-
// Validate output against schema if defined
|
|
81
|
-
if (
|
|
82
|
-
typeof workflow.output === "object" &&
|
|
83
|
-
"schema" in workflow.output &&
|
|
84
|
-
"map" in workflow.output &&
|
|
85
|
-
typeof output === "object" &&
|
|
86
|
-
output !== null
|
|
87
|
-
) {
|
|
88
|
-
validateOutputSchema(workflow.output.schema, output as Record<string, unknown>);
|
|
89
|
-
}
|
|
90
|
-
} else {
|
|
91
|
-
// Default: return final chain value
|
|
92
|
-
output = chain;
|
|
93
|
-
}
|
|
92
|
+
let success = false;
|
|
94
93
|
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
try {
|
|
95
|
+
// Execute flow with chain threading
|
|
96
|
+
let chain: unknown;
|
|
97
|
+
for (const step of workflow.flow) {
|
|
98
|
+
chain = await executeControlStep(workflow, state, step, basePath, engineExecutor, chain, options.hub);
|
|
99
|
+
}
|
|
97
100
|
|
|
98
|
-
|
|
101
|
+
// Output resolution
|
|
102
|
+
if (workflow.output !== undefined) {
|
|
103
|
+
output = state.resolveOutput(workflow.output, evaluateCel);
|
|
104
|
+
|
|
105
|
+
// Validate output against schema if defined
|
|
106
|
+
if (
|
|
107
|
+
typeof workflow.output === "object" &&
|
|
108
|
+
"schema" in workflow.output &&
|
|
109
|
+
"map" in workflow.output &&
|
|
110
|
+
typeof output === "object" &&
|
|
111
|
+
output !== null
|
|
112
|
+
) {
|
|
113
|
+
validateOutputSchema(workflow.output.schema, output as Record<string, unknown>);
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
// Default: return final chain value
|
|
117
|
+
output = chain;
|
|
118
|
+
}
|
|
99
119
|
|
|
100
|
-
|
|
101
|
-
success
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
120
|
+
const steps = state.getAllResults();
|
|
121
|
+
success = !Object.values(steps).some((step) => step.status === "failure");
|
|
122
|
+
state.executionMeta.status = success ? "success" : "failure";
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
success,
|
|
126
|
+
output,
|
|
127
|
+
steps,
|
|
128
|
+
duration: Math.max(0, performance.now() - startedAtMs),
|
|
129
|
+
};
|
|
130
|
+
} finally {
|
|
131
|
+
if (state.tracer?.writer) {
|
|
132
|
+
const duration = Math.max(0, performance.now() - startedAtMs);
|
|
133
|
+
await state.tracer.writer.finalize({
|
|
134
|
+
status: success ? "success" : "failure",
|
|
135
|
+
output: output ?? null,
|
|
136
|
+
finishedAt: new Date().toISOString(),
|
|
137
|
+
duration,
|
|
138
|
+
}).catch(() => {});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
106
141
|
}
|
|
107
142
|
|
|
108
143
|
export async function runWorkflowFromFile(
|
package/src/engine/sequential.ts
CHANGED
|
@@ -151,6 +151,9 @@ export async function executeStep(
|
|
|
151
151
|
output: "",
|
|
152
152
|
duration: 0,
|
|
153
153
|
});
|
|
154
|
+
const loopIndex = state.isInsideLoop() ? state.currentLoopIndex() : undefined;
|
|
155
|
+
const skippedSeq = state.tracer?.startStep(stepName, participant.type, undefined, loopIndex);
|
|
156
|
+
if (skippedSeq !== undefined) state.tracer?.endStep(skippedSeq, "skipped");
|
|
154
157
|
}
|
|
155
158
|
return chain; // Skipped steps preserve chain
|
|
156
159
|
}
|
|
@@ -165,6 +168,9 @@ export async function executeStep(
|
|
|
165
168
|
const mergedWithBase = mergeChainedInput(chain, baseInput);
|
|
166
169
|
const mergedInput = mergeChainedInput(mergedWithBase, overrideInput);
|
|
167
170
|
|
|
171
|
+
const loopIndex = state.isInsideLoop() ? state.currentLoopIndex() : undefined;
|
|
172
|
+
const traceSeq = state.tracer?.startStep(stepName ?? "<anonymous>", participant.type, mergedInput, loopIndex);
|
|
173
|
+
|
|
168
174
|
// Set participant-scoped input in state
|
|
169
175
|
state.currentInput = mergedInput;
|
|
170
176
|
|
|
@@ -280,6 +286,7 @@ export async function executeStep(
|
|
|
280
286
|
// Update participant-scoped output and chain
|
|
281
287
|
const outputValue = result.parsedOutput ?? result.output;
|
|
282
288
|
state.currentOutput = outputValue;
|
|
289
|
+
if (traceSeq !== undefined) state.tracer?.endStep(traceSeq, result.status, outputValue, undefined, retries);
|
|
283
290
|
return outputValue;
|
|
284
291
|
} catch (error) {
|
|
285
292
|
const message = String((error as Error)?.message ?? error);
|
|
@@ -305,6 +312,7 @@ export async function executeStep(
|
|
|
305
312
|
if (stepName) {
|
|
306
313
|
state.setResult(stepName, skipResult);
|
|
307
314
|
}
|
|
315
|
+
if (traceSeq !== undefined) state.tracer?.endStep(traceSeq, "skipped", undefined, message);
|
|
308
316
|
return chain; // Skipped steps preserve chain
|
|
309
317
|
}
|
|
310
318
|
|
|
@@ -315,7 +323,7 @@ export async function executeStep(
|
|
|
315
323
|
throw new Error(`fallback cycle detected on participant '${fallbackName}'`);
|
|
316
324
|
}
|
|
317
325
|
|
|
318
|
-
// Keep original step as failure
|
|
326
|
+
// Keep original step as failure
|
|
319
327
|
if (stepName) {
|
|
320
328
|
state.setResult(stepName, {
|
|
321
329
|
status: "failure",
|
|
@@ -327,6 +335,7 @@ export async function executeStep(
|
|
|
327
335
|
...httpMeta,
|
|
328
336
|
});
|
|
329
337
|
}
|
|
338
|
+
if (traceSeq !== undefined) state.tracer?.endStep(traceSeq, "failure", undefined, message);
|
|
330
339
|
|
|
331
340
|
// Execute fallback
|
|
332
341
|
const fallbackResult = await executeStep(
|
|
@@ -354,6 +363,7 @@ export async function executeStep(
|
|
|
354
363
|
...httpMeta,
|
|
355
364
|
});
|
|
356
365
|
}
|
|
366
|
+
if (traceSeq !== undefined) state.tracer?.endStep(traceSeq, "failure", undefined, message);
|
|
357
367
|
|
|
358
368
|
throw error;
|
|
359
369
|
}
|
package/src/engine/state.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { env } from "node:process";
|
|
2
2
|
import type { StepResult } from "../model/index";
|
|
3
|
+
import type { TraceCollector } from "../tracer/index";
|
|
3
4
|
|
|
4
5
|
export type { StepResult };
|
|
5
6
|
|
|
@@ -25,6 +26,8 @@ export class WorkflowState {
|
|
|
25
26
|
eventPayload: unknown;
|
|
26
27
|
/** @internal Tracks ancestor workflow paths for circular sub-workflow detection */
|
|
27
28
|
ancestorPaths: Set<string>;
|
|
29
|
+
/** @internal Optional trace collector; set by engine when --trace-dir is active */
|
|
30
|
+
tracer?: TraceCollector;
|
|
28
31
|
|
|
29
32
|
constructor(inputs: Record<string, unknown> = {}) {
|
|
30
33
|
this.inputs = { ...inputs };
|
|
@@ -82,6 +85,10 @@ export class WorkflowState {
|
|
|
82
85
|
if (top) top.last = last;
|
|
83
86
|
}
|
|
84
87
|
|
|
88
|
+
isInsideLoop(): boolean {
|
|
89
|
+
return this.loopStack.length > 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
85
92
|
currentLoopContext(): { index: number; iteration: number; first: boolean; last: boolean; as?: string } {
|
|
86
93
|
const top = this.loopStack[this.loopStack.length - 1];
|
|
87
94
|
if (!top) return { index: 0, iteration: 1, first: true, last: false };
|
|
@@ -148,7 +155,7 @@ export class WorkflowState {
|
|
|
148
155
|
// Event payload
|
|
149
156
|
ctx["event"] = this.eventPayload ?? {};
|
|
150
157
|
|
|
151
|
-
// Now — epoch seconds to match timestamp()
|
|
158
|
+
// Now — epoch seconds to match timestamp() (Spec §12.9)
|
|
152
159
|
ctx["now"] = Math.floor(Date.now() / 1000);
|
|
153
160
|
|
|
154
161
|
return ctx;
|
package/src/model/index.ts
CHANGED
|
@@ -181,3 +181,34 @@ export interface ValidationError {
|
|
|
181
181
|
path: string;
|
|
182
182
|
message: string;
|
|
183
183
|
}
|
|
184
|
+
|
|
185
|
+
export interface StepTrace {
|
|
186
|
+
seq: number;
|
|
187
|
+
name: string;
|
|
188
|
+
type: string;
|
|
189
|
+
startedAt: string;
|
|
190
|
+
finishedAt?: string;
|
|
191
|
+
duration?: number;
|
|
192
|
+
status: "success" | "failure" | "skipped";
|
|
193
|
+
input?: unknown;
|
|
194
|
+
output?: unknown;
|
|
195
|
+
error?: string;
|
|
196
|
+
retries?: number;
|
|
197
|
+
loopIndex?: number;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface ExecutionTrace {
|
|
201
|
+
execution: {
|
|
202
|
+
id: string;
|
|
203
|
+
workflowId?: string;
|
|
204
|
+
workflowName?: string;
|
|
205
|
+
workflowVersion?: string | number;
|
|
206
|
+
startedAt: string;
|
|
207
|
+
finishedAt: string;
|
|
208
|
+
duration: number;
|
|
209
|
+
status: "success" | "failure" | "running";
|
|
210
|
+
inputs: unknown;
|
|
211
|
+
output: unknown;
|
|
212
|
+
};
|
|
213
|
+
steps: StepTrace[];
|
|
214
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
-
"$id": "https://
|
|
3
|
+
"$id": "https://raw.githubusercontent.com/duckflux/spec/main/duckflux.schema.json",
|
|
4
4
|
"title": "duckflux Workflow",
|
|
5
5
|
"description": "Schema for duckflux — a minimal, deterministic, runtime-agnostic workflow DSL.",
|
|
6
6
|
"type": "object",
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { StepTrace } from "../model/index";
|
|
4
|
+
|
|
5
|
+
export type { StepTrace };
|
|
6
|
+
|
|
7
|
+
export interface TraceOpenMeta {
|
|
8
|
+
id: string;
|
|
9
|
+
workflowId?: string;
|
|
10
|
+
workflowName?: string;
|
|
11
|
+
workflowVersion?: string | number;
|
|
12
|
+
startedAt: string;
|
|
13
|
+
inputs: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface TraceFinalizeMeta {
|
|
17
|
+
status: "success" | "failure";
|
|
18
|
+
output: unknown;
|
|
19
|
+
finishedAt: string;
|
|
20
|
+
duration: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TraceWriter {
|
|
24
|
+
open(meta: TraceOpenMeta): Promise<void>;
|
|
25
|
+
writeStep(step: StepTrace): void | Promise<void>;
|
|
26
|
+
finalize(meta: TraceFinalizeMeta): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class TraceCollector {
|
|
30
|
+
private openSteps: Map<number, { name: string; type: string; startedAt: string; startMs: number; input?: unknown; loopIndex?: number }> = new Map();
|
|
31
|
+
private seq = 0;
|
|
32
|
+
readonly truncateAt: number;
|
|
33
|
+
writer?: TraceWriter;
|
|
34
|
+
|
|
35
|
+
constructor(truncateAt = 1_000_000) {
|
|
36
|
+
this.truncateAt = truncateAt;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
startStep(name: string, type: string, input?: unknown, loopIndex?: number): number {
|
|
40
|
+
this.seq += 1;
|
|
41
|
+
this.openSteps.set(this.seq, {
|
|
42
|
+
name,
|
|
43
|
+
type,
|
|
44
|
+
startedAt: new Date().toISOString(),
|
|
45
|
+
startMs: performance.now(),
|
|
46
|
+
input: input !== undefined ? this.truncate(input) : undefined,
|
|
47
|
+
loopIndex,
|
|
48
|
+
});
|
|
49
|
+
return this.seq;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
endStep(seq: number, status: StepTrace["status"], output?: unknown, error?: string, retries?: number): void {
|
|
53
|
+
const open = this.openSteps.get(seq);
|
|
54
|
+
if (!open) return;
|
|
55
|
+
this.openSteps.delete(seq);
|
|
56
|
+
|
|
57
|
+
const finishedAt = new Date().toISOString();
|
|
58
|
+
const duration = Math.max(0, performance.now() - open.startMs);
|
|
59
|
+
|
|
60
|
+
const step: StepTrace = {
|
|
61
|
+
seq,
|
|
62
|
+
name: open.name,
|
|
63
|
+
type: open.type,
|
|
64
|
+
startedAt: open.startedAt,
|
|
65
|
+
finishedAt,
|
|
66
|
+
duration: Math.round(duration),
|
|
67
|
+
status,
|
|
68
|
+
...(open.input !== undefined ? { input: open.input } : {}),
|
|
69
|
+
...(output !== undefined ? { output: this.truncate(output) } : {}),
|
|
70
|
+
...(error !== undefined ? { error } : {}),
|
|
71
|
+
...(retries !== undefined && retries > 0 ? { retries } : {}),
|
|
72
|
+
...(open.loopIndex !== undefined ? { loopIndex: open.loopIndex } : {}),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
this.writer?.writeStep(step);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
truncate(value: unknown): unknown {
|
|
79
|
+
if (value == null) return value;
|
|
80
|
+
const str = typeof value === "string" ? value : JSON.stringify(value);
|
|
81
|
+
const bytes = new TextEncoder().encode(str);
|
|
82
|
+
if (bytes.length <= this.truncateAt) return value;
|
|
83
|
+
const cut = new TextDecoder().decode(bytes.slice(0, this.truncateAt));
|
|
84
|
+
return cut + "...[truncated]";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function createTraceWriter(
|
|
89
|
+
dir: string,
|
|
90
|
+
format: "json" | "txt" | "sqlite",
|
|
91
|
+
): Promise<TraceWriter> {
|
|
92
|
+
await mkdir(dir, { recursive: true });
|
|
93
|
+
|
|
94
|
+
if (format === "json") {
|
|
95
|
+
const { JsonTraceWriter } = await import("./writers/json");
|
|
96
|
+
return new JsonTraceWriter(dir);
|
|
97
|
+
}
|
|
98
|
+
if (format === "txt") {
|
|
99
|
+
const { TxtTraceWriter } = await import("./writers/txt");
|
|
100
|
+
return new TxtTraceWriter(dir);
|
|
101
|
+
}
|
|
102
|
+
if (format === "sqlite") {
|
|
103
|
+
const { SqliteTraceWriter } = await import("./writers/sqlite");
|
|
104
|
+
return new SqliteTraceWriter(dir);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
throw new Error(`unknown trace format: ${format}`);
|
|
108
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { ExecutionTrace, StepTrace } from "../../model/index";
|
|
4
|
+
import type { TraceFinalizeMeta, TraceOpenMeta, TraceWriter } from "../index";
|
|
5
|
+
|
|
6
|
+
export class JsonTraceWriter implements TraceWriter {
|
|
7
|
+
private dir: string;
|
|
8
|
+
private filePath = "";
|
|
9
|
+
private trace: ExecutionTrace = {
|
|
10
|
+
execution: {
|
|
11
|
+
id: "",
|
|
12
|
+
startedAt: "",
|
|
13
|
+
finishedAt: "",
|
|
14
|
+
duration: 0,
|
|
15
|
+
status: "running",
|
|
16
|
+
inputs: null,
|
|
17
|
+
output: null,
|
|
18
|
+
},
|
|
19
|
+
steps: [],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
constructor(dir: string) {
|
|
23
|
+
this.dir = dir;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async open(meta: TraceOpenMeta): Promise<void> {
|
|
27
|
+
this.filePath = join(this.dir, `${meta.id}.json`);
|
|
28
|
+
this.trace = {
|
|
29
|
+
execution: {
|
|
30
|
+
id: meta.id,
|
|
31
|
+
workflowId: meta.workflowId,
|
|
32
|
+
workflowName: meta.workflowName,
|
|
33
|
+
workflowVersion: meta.workflowVersion,
|
|
34
|
+
startedAt: meta.startedAt,
|
|
35
|
+
finishedAt: "",
|
|
36
|
+
duration: 0,
|
|
37
|
+
status: "running",
|
|
38
|
+
inputs: meta.inputs,
|
|
39
|
+
output: null,
|
|
40
|
+
},
|
|
41
|
+
steps: [],
|
|
42
|
+
};
|
|
43
|
+
await this.flush();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
writeStep(step: StepTrace): void {
|
|
47
|
+
this.trace.steps.push(step);
|
|
48
|
+
this.flushSync();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async finalize(meta: TraceFinalizeMeta): Promise<void> {
|
|
52
|
+
this.trace.execution.status = meta.status;
|
|
53
|
+
this.trace.execution.output = meta.output;
|
|
54
|
+
this.trace.execution.finishedAt = meta.finishedAt;
|
|
55
|
+
this.trace.execution.duration = meta.duration;
|
|
56
|
+
await this.flush();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private flushSync(): void {
|
|
60
|
+
// Fire-and-forget async write; errors are silently ignored to not disrupt execution
|
|
61
|
+
this.flush().catch(() => {});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private async flush(): Promise<void> {
|
|
65
|
+
if (!this.filePath) return;
|
|
66
|
+
await writeFile(this.filePath, JSON.stringify(this.trace, null, 2), "utf-8");
|
|
67
|
+
}
|
|
68
|
+
}
|