@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.
@@ -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 { executeWait } = await import("./wait");
37
- return executeWait(state, (obj as { wait: Record<string, unknown> }).wait, chain, hub, signal);
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
- const results = await Promise.all(
133
- parallelSteps.map(async (parallelStep) => {
134
- try {
135
- // Each branch starts with the same incoming chain
136
- return await executeControlStep(workflow, state, parallelStep, basePath, engineExecutor, chain, hub, branchSignal);
137
- } catch (error) {
138
- controller.abort();
139
- throw error;
140
- }
141
- }),
142
- );
143
-
144
- // Chain after parallel is ordered array of branch outputs
145
- return results;
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
- if (condition) {
158
- return executeSequential(workflow, state, ifDef.then, basePath, engineExecutor, chain, hub, signal);
159
- } else if (ifDef.else) {
160
- return executeSequential(workflow, state, ifDef.else, basePath, engineExecutor, chain, hub, signal);
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
@@ -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 startedAt = performance.now();
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
- if (workflow.output !== undefined) {
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
- const steps = state.getAllResults();
96
- const success = !Object.values(steps).some((step) => step.status === "failure");
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
- state.executionMeta.status = success ? "success" : "failure";
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
- return {
101
- success,
102
- output,
103
- steps,
104
- duration: Math.max(0, performance.now() - startedAt),
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(
@@ -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 (Go runner behavior)
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
  }
@@ -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() and Go runner behavior (Spec §12.9)
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;
@@ -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://duckflux.dev/schema/v0.4/duckflux.schema.json",
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
+ }