@surlymochan/evalid 0.5.19 → 0.5.21

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.
@@ -1,8 +1,16 @@
1
- import { appendFile, mkdir, readFile } from 'node:fs/promises';
2
- import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
1
+ import { appendFile, mkdir, open, readFile, stat } from 'node:fs/promises';
2
+ import { appendFileSync, closeSync, mkdirSync, openSync, readSync, statSync } from 'node:fs';
3
3
  import { dirname, resolve } from 'node:path';
4
4
  import { buildLogEntryHash, validateExecutionLogChain } from './workflow-integrity.js';
5
5
 
6
+ const TAIL_WINDOW_BYTES = 16_384;
7
+
8
+ // Tail anchors are cached per log file: an append only needs the previous entry_hash, and
9
+ // re-reading a multi-megabyte log for every event made appends O(n^2). The cache is keyed by
10
+ // (size, mtimeMs) so an external writer (another xflow process, a rotated or truncated log that
11
+ // lands on the same size) invalidates it instead of serving a stale anchor.
12
+ const tailStateCache = new Map();
13
+
6
14
  function nowIso() {
7
15
  return new Date().toISOString();
8
16
  }
@@ -25,28 +33,34 @@ export async function appendExecutionLog(projectRoot, entry = {}) {
25
33
  async function appendExecutionLogUnlocked(projectRoot, entry = {}) {
26
34
  const file = resolveExecutionLogPath(projectRoot);
27
35
  await mkdir(dirname(file), { recursive: true });
28
- const previousHash = await readLastEntryHash(file);
36
+ const tail = await readTailState(file);
29
37
  const payload = {
30
38
  timestamp: nowIso(),
31
39
  ...entry,
32
- prev_hash: previousHash,
40
+ prev_hash: tail.hash,
33
41
  };
34
42
  payload.entry_hash = buildLogEntryHash(payload);
35
- await appendFile(file, `${JSON.stringify(payload)}\n`, 'utf8');
43
+ // A crash can leave a partial trailing line; start a fresh line so the new entry stays
44
+ // parseable instead of being glued onto the fragment.
45
+ const line = `${tail.size > 0 && !tail.endsWithNewline ? '\n' : ''}${JSON.stringify(payload)}\n`;
46
+ await appendFile(file, line, 'utf8');
47
+ await rememberAppendedTail(file, payload.entry_hash);
36
48
  return file;
37
49
  }
38
50
 
39
51
  export function appendExecutionLogSync(projectRoot, entry = {}) {
40
52
  const file = resolveExecutionLogPath(projectRoot);
41
53
  mkdirSync(dirname(file), { recursive: true });
42
- const previousHash = readLastEntryHashSync(file);
54
+ const tail = readTailStateSync(file);
43
55
  const payload = {
44
56
  timestamp: nowIso(),
45
57
  ...entry,
46
- prev_hash: previousHash,
58
+ prev_hash: tail.hash,
47
59
  };
48
60
  payload.entry_hash = buildLogEntryHash(payload);
49
- appendFileSync(file, `${JSON.stringify(payload)}\n`, 'utf8');
61
+ const line = `${tail.size > 0 && !tail.endsWithNewline ? '\n' : ''}${JSON.stringify(payload)}\n`;
62
+ appendFileSync(file, line, 'utf8');
63
+ rememberAppendedTailSync(file, payload.entry_hash);
50
64
  return file;
51
65
  }
52
66
 
@@ -76,32 +90,178 @@ export async function readExecutionLog(projectRoot, options = {}) {
76
90
  }
77
91
  }
78
92
 
79
- async function readLastEntryHash(file) {
93
+ export async function readLastEntryHash(file) {
94
+ const tail = await readTailState(file);
95
+ return tail.hash;
96
+ }
97
+
98
+ async function readTailState(file) {
80
99
  try {
81
- const raw = await readFile(file, 'utf8');
82
- return lastEntryHashFromRaw(raw);
100
+ const info = await stat(file);
101
+ const cached = tailStateCache.get(file);
102
+ if (cached && cached.size === info.size && cached.mtimeMs === info.mtimeMs) {
103
+ return cached;
104
+ }
105
+ const state = {
106
+ size: info.size,
107
+ mtimeMs: info.mtimeMs,
108
+ hash: await readTailHash(file, info.size),
109
+ endsWithNewline: await tailEndsWithNewline(file, info.size),
110
+ };
111
+ tailStateCache.set(file, state);
112
+ return state;
113
+ } catch {
114
+ return { size: 0, mtimeMs: null, hash: null, endsWithNewline: true };
115
+ }
116
+ }
117
+
118
+ function readTailStateSync(file) {
119
+ try {
120
+ const info = statSync(file);
121
+ const cached = tailStateCache.get(file);
122
+ if (cached && cached.size === info.size && cached.mtimeMs === info.mtimeMs) {
123
+ return cached;
124
+ }
125
+ const state = {
126
+ size: info.size,
127
+ mtimeMs: info.mtimeMs,
128
+ hash: readTailHashSync(file, info.size),
129
+ endsWithNewline: tailEndsWithNewlineSync(file, info.size),
130
+ };
131
+ tailStateCache.set(file, state);
132
+ return state;
133
+ } catch {
134
+ return { size: 0, mtimeMs: null, hash: null, endsWithNewline: true };
135
+ }
136
+ }
137
+
138
+ async function readTailHash(file, size) {
139
+ if (!size) return null;
140
+ // The window must contain at least one complete line to anchor the chain. A single entry
141
+ // larger than the window (an error or detail field is unbounded) leaves no complete line, so
142
+ // the window grows until the previous entry comes into view. Returning null instead made the
143
+ // next append pick up prev_hash: null, which permanently breaks the chain after a restart or
144
+ // from a second process.
145
+ let windowBytes = TAIL_WINDOW_BYTES;
146
+ for (;;) {
147
+ const start = Math.max(0, size - windowBytes);
148
+ const hash = await readHashWindow(file, start, size);
149
+ if (hash !== null || start === 0) return hash;
150
+ windowBytes *= 4;
151
+ }
152
+ }
153
+
154
+ function readTailHashSync(file, size) {
155
+ if (!size) return null;
156
+ let windowBytes = TAIL_WINDOW_BYTES;
157
+ for (;;) {
158
+ const start = Math.max(0, size - windowBytes);
159
+ const hash = readHashWindowSync(file, start, size);
160
+ if (hash !== null || start === 0) return hash;
161
+ windowBytes *= 4;
162
+ }
163
+ }
164
+
165
+ async function readHashWindow(file, start, size) {
166
+ const handle = await open(file, 'r');
167
+ try {
168
+ const buffer = Buffer.alloc(size - start);
169
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, start);
170
+ return lastEntryHashFromRaw(buffer.subarray(0, bytesRead).toString('utf8'), start > 0);
83
171
  } catch {
84
172
  return null;
173
+ } finally {
174
+ await handle.close();
85
175
  }
86
176
  }
87
177
 
88
- function readLastEntryHashSync(file) {
178
+ function readHashWindowSync(file, start, size) {
179
+ const fd = openSync(file, 'r');
89
180
  try {
90
- const raw = readFileSync(file, 'utf8');
91
- return lastEntryHashFromRaw(raw);
181
+ const buffer = Buffer.alloc(size - start);
182
+ const bytesRead = readSync(fd, buffer, 0, buffer.length, start);
183
+ return lastEntryHashFromRaw(buffer.subarray(0, bytesRead).toString('utf8'), start > 0);
92
184
  } catch {
93
185
  return null;
186
+ } finally {
187
+ closeSync(fd);
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Record the anchor that was just written. One cheap stat keeps the cache key (size, mtimeMs)
193
+ * honest, so a later writer that rewrites the file at the same size invalidates the entry.
194
+ */
195
+ async function rememberAppendedTail(file, entryHash) {
196
+ try {
197
+ const info = await stat(file);
198
+ tailStateCache.set(file, {
199
+ size: info.size,
200
+ mtimeMs: info.mtimeMs,
201
+ hash: entryHash,
202
+ endsWithNewline: true,
203
+ });
204
+ } catch {
205
+ tailStateCache.delete(file);
206
+ }
207
+ }
208
+
209
+ function rememberAppendedTailSync(file, entryHash) {
210
+ try {
211
+ const info = statSync(file);
212
+ tailStateCache.set(file, {
213
+ size: info.size,
214
+ mtimeMs: info.mtimeMs,
215
+ hash: entryHash,
216
+ endsWithNewline: true,
217
+ });
218
+ } catch {
219
+ tailStateCache.delete(file);
94
220
  }
95
221
  }
96
222
 
97
- function lastEntryHashFromRaw(raw) {
98
- const lines = raw.split('\n').map((line) => line.trim()).filter(Boolean);
223
+ async function tailEndsWithNewline(file, size) {
224
+ if (!size) return true;
225
+ const handle = await open(file, 'r');
226
+ try {
227
+ const buffer = Buffer.alloc(1);
228
+ const { bytesRead } = await handle.read(buffer, 0, 1, size - 1);
229
+ return bytesRead === 0 || buffer[0] === 0x0a;
230
+ } catch {
231
+ return true;
232
+ } finally {
233
+ await handle.close();
234
+ }
235
+ }
236
+
237
+ function tailEndsWithNewlineSync(file, size) {
238
+ if (!size) return true;
239
+ const fd = openSync(file, 'r');
240
+ try {
241
+ const buffer = Buffer.alloc(1);
242
+ const bytesRead = readSync(fd, buffer, 0, 1, size - 1);
243
+ return bytesRead === 0 || buffer[0] === 0x0a;
244
+ } catch {
245
+ return true;
246
+ } finally {
247
+ closeSync(fd);
248
+ }
249
+ }
250
+
251
+ function lastEntryHashFromRaw(raw, startedMidLine = false) {
252
+ const lines = raw.split('\n');
253
+ if (startedMidLine) {
254
+ // The first line of a tail window is usually a fragment; it cannot anchor the chain.
255
+ lines.shift();
256
+ }
99
257
  for (let index = lines.length - 1; index >= 0; index -= 1) {
258
+ const line = lines[index].trim();
259
+ if (!line) continue;
100
260
  try {
101
- const entry = JSON.parse(lines[index]);
261
+ const entry = JSON.parse(line);
102
262
  if (entry.entry_hash) return entry.entry_hash;
103
263
  } catch {
104
- return null;
264
+ // A corrupt or truncated trailing line must not reset the chain; keep scanning upwards.
105
265
  }
106
266
  }
107
267
  return null;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * M4 lite fast track, slice 3 (evalid #101, parent #97): auto-select the
3
+ * track for `xflow workflow run`.
4
+ *
5
+ * With no explicit flag the run classifies the change first (slice 1's
6
+ * `xflow/atoms/lite_track.py`, working-tree diff against the merge-base,
7
+ * `--uncommitted`): a small diff walks the lite track, a big diff stays
8
+ * full. Explicit `--lite` / `--full` always wins; both flags together
9
+ * resolve to full (conservative). Any classifier failure falls back to
10
+ * full and records the failure -- a run must never silently go lite.
11
+ *
12
+ * The returned decision object is JSON-serialisable on purpose: the CLI
13
+ * attaches it to the workflow input as `lite_track_decision` and the
14
+ * executor records it in the `workflow_started` / `workflow_completed`
15
+ * events, next to slice 1's `lite_fast_track` field.
16
+ */
17
+ import { spawnSync } from 'node:child_process';
18
+ import { dirname, resolve } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const __dirname = dirname(fileURLToPath(import.meta.url));
22
+ export const DEFAULT_CLASSIFIER_PATH = resolve(__dirname, '..', '..', 'xflow', 'atoms', 'lite_track.py');
23
+ const DEFAULT_TIMEOUT_MS = 60_000;
24
+
25
+ /**
26
+ * Run the classifier against `projectRoot` and return the auto decision.
27
+ *
28
+ * `runClassifier` is injectable (defaults to `spawnSync`) so tests can
29
+ * stub the classifier invocation without touching git or python.
30
+ */
31
+ export function resolveLiteTrackAuto({
32
+ projectRoot,
33
+ classifierPath = DEFAULT_CLASSIFIER_PATH,
34
+ python = process.env.PYTHON || 'python3',
35
+ runClassifier = spawnSync,
36
+ timeoutMs = DEFAULT_TIMEOUT_MS,
37
+ } = {}) {
38
+ const fallback = (detail) => ({
39
+ source: 'auto-fallback-full',
40
+ track: 'full',
41
+ reason: `classifier failed (${detail}); falling back to full track`,
42
+ files: null,
43
+ added: null,
44
+ removed: null,
45
+ total: null,
46
+ base: null,
47
+ force_full: [],
48
+ });
49
+
50
+ let result;
51
+ try {
52
+ result = runClassifier(
53
+ python,
54
+ [classifierPath, '--repo', projectRoot, '--uncommitted', '--json'],
55
+ { encoding: 'utf8', timeout: timeoutMs },
56
+ );
57
+ } catch (err) {
58
+ return fallback(`spawn error: ${err?.message || String(err)}`);
59
+ }
60
+ if (!result || result.status !== 0) {
61
+ const detail = String(
62
+ result?.stderr?.trim() || result?.error?.message || `exit ${result?.status ?? 'unknown'}`,
63
+ ).slice(0, 200);
64
+ return fallback(detail);
65
+ }
66
+ let verdict;
67
+ try {
68
+ verdict = JSON.parse(result.stdout);
69
+ } catch {
70
+ return fallback('unparseable classifier output');
71
+ }
72
+ if (verdict?.track !== 'lite' && verdict?.track !== 'full') {
73
+ return fallback(`unexpected verdict: ${JSON.stringify(verdict).slice(0, 200)}`);
74
+ }
75
+ return {
76
+ source: 'auto',
77
+ track: verdict.track,
78
+ reason: verdict.reason || '',
79
+ files: verdict.files ?? null,
80
+ added: verdict.added ?? null,
81
+ removed: verdict.removed ?? null,
82
+ total: verdict.total ?? null,
83
+ base: verdict.base ?? null,
84
+ force_full: verdict.force_full || [],
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Resolve the effective track from explicit flags and (when neither flag
90
+ * is given) the auto decision. Returns `{ lite, decision }`.
91
+ */
92
+ export function decideLiteTrack({ lite, full, autoDecision }) {
93
+ if (lite === true && full === true) {
94
+ return {
95
+ lite: false,
96
+ decision: {
97
+ source: 'explicit-full',
98
+ track: 'full',
99
+ reason: 'both --lite and --full given; --full wins (conservative)',
100
+ },
101
+ };
102
+ }
103
+ if (lite === true) {
104
+ return {
105
+ lite: true,
106
+ decision: { source: 'explicit-lite', track: 'lite', reason: 'explicit --lite flag' },
107
+ };
108
+ }
109
+ if (full === true) {
110
+ return {
111
+ lite: false,
112
+ decision: { source: 'explicit-full', track: 'full', reason: 'explicit --full flag' },
113
+ };
114
+ }
115
+ if (!autoDecision) {
116
+ return {
117
+ lite: false,
118
+ decision: {
119
+ source: 'auto-fallback-full',
120
+ track: 'full',
121
+ reason: 'no classifier verdict available; falling back to full track',
122
+ },
123
+ };
124
+ }
125
+ return { lite: autoDecision.track === 'lite', decision: autoDecision };
126
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * log-tail — byte-offset reader for the hash-chained execution log.
3
+ *
4
+ * The execution log (.as-xflow/logs/execution.ndjson) is the single source of truth for
5
+ * workflow progress: the CLI writes it, `xflow serve` reads it. Live consumers therefore tail
6
+ * it by byte offset instead of re-reading it, and resume from the `entry_hash` they last saw
7
+ * (SSE Last-Event-ID). Every offset returned here sits on a line boundary, so a consumer never
8
+ * observes half an entry.
9
+ */
10
+ import { open, stat } from 'node:fs/promises';
11
+ import { resolveExecutionLogPath } from './execution-log.js';
12
+
13
+ export const DEFAULT_TAIL_LIMIT = 200;
14
+ export const MAX_TAIL_READ_BYTES = 4 * 1024 * 1024;
15
+ const OVERSCAN_BYTES = 64 * 1024;
16
+ const MAX_OVERSCAN_BYTES = 64 * 1024 * 1024;
17
+
18
+ export async function readExecutionLogSize(projectRoot = process.cwd()) {
19
+ try {
20
+ return (await stat(resolveExecutionLogPath(projectRoot))).size;
21
+ } catch {
22
+ return 0;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Read the entries written after `offset`.
28
+ *
29
+ * Returns { entries, nextOffset, size, hasMore, partial, eof }. `nextOffset` only ever advances
30
+ * past a complete line, so an entry that is still being written is re-read (not skipped) on the
31
+ * next call. `limit` caps one call so a large backlog is drained over several calls instead of
32
+ * one unbounded read.
33
+ */
34
+ export async function readEntriesFromOffset(projectRoot = process.cwd(), offset = 0, options = {}) {
35
+ const file = resolveExecutionLogPath(projectRoot);
36
+ const limit = Math.max(1, Math.floor(Number(options.limit) || DEFAULT_TAIL_LIMIT));
37
+ const size = await readExecutionLogSize(projectRoot);
38
+ const requested = Math.floor(Number(offset) || 0);
39
+ const start = Math.min(Math.max(0, requested), size);
40
+ if (start >= size) {
41
+ return { file, entries: [], nextOffset: size, size, hasMore: false, partial: false, eof: true };
42
+ }
43
+
44
+ const end = Math.min(size, start + MAX_TAIL_READ_BYTES);
45
+ const window = await readRange(file, start, end);
46
+ const entries = [];
47
+ let cursor = 0;
48
+ let nextOffset = start;
49
+ let sawNewline = false;
50
+
51
+ while (entries.length < limit) {
52
+ const newlineAt = window.indexOf(0x0a, cursor);
53
+ if (newlineAt === -1) break;
54
+ sawNewline = true;
55
+ const line = window.subarray(cursor, newlineAt).toString('utf8').trim();
56
+ cursor = newlineAt + 1;
57
+ nextOffset = start + cursor;
58
+ if (!line) continue;
59
+ entries.push(parseEntryLine(line));
60
+ }
61
+
62
+ if (!sawNewline && end < size) {
63
+ // One entry larger than the read window would stall the tail forever (the same offset
64
+ // would be returned on every poll). Skip to the next line boundary and tell the caller.
65
+ const skippedTo = await skipOversizedLine(file, end, size);
66
+ entries.push({
67
+ kind: 'invalid_log_line',
68
+ raw: 'line_exceeds_read_window',
69
+ reason: 'entry_larger_than_read_window',
70
+ truncated: true,
71
+ });
72
+ return {
73
+ file,
74
+ entries,
75
+ nextOffset: skippedTo,
76
+ size,
77
+ hasMore: skippedTo < size,
78
+ partial: false,
79
+ eof: skippedTo >= size,
80
+ };
81
+ }
82
+
83
+ return {
84
+ file,
85
+ entries,
86
+ nextOffset,
87
+ size,
88
+ hasMore: nextOffset < size,
89
+ // Anything left in the window sits after the last newline, i.e. an entry still being written.
90
+ partial: cursor < window.length,
91
+ eof: nextOffset >= size,
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Map an entry_hash to the byte offset just past that entry.
97
+ *
98
+ * Returns null when the hash is unknown (rotated or deleted log), which callers treat as
99
+ * "replay is impossible, fall back to a snapshot" rather than as an error.
100
+ */
101
+ export async function resolveEntryOffset(projectRoot = process.cwd(), entryHash) {
102
+ const wanted = String(entryHash || '').trim();
103
+ if (!wanted) return null;
104
+ const file = resolveExecutionLogPath(projectRoot);
105
+ let size;
106
+ try {
107
+ size = (await stat(file)).size;
108
+ } catch {
109
+ return null;
110
+ }
111
+ if (!size) return null;
112
+
113
+ // A reconnect resolves a single hash, and the entry the client last saw is almost always
114
+ // among the most recent ones, so scan the tail first and widen the window until the log is
115
+ // covered. Every window ends at EOF on purpose: a window that stopped at an arbitrary offset
116
+ // would split an entry across two reads and never see it whole.
117
+ let windowBytes = OVERSCAN_BYTES;
118
+ for (;;) {
119
+ const start = Math.max(0, size - windowBytes);
120
+ const window = await readRange(file, start, size);
121
+ const offset = matchEntryOffset(window, start, wanted);
122
+ if (offset !== null) return offset;
123
+ if (start === 0) return null;
124
+ windowBytes *= 4;
125
+ }
126
+ }
127
+
128
+ function matchEntryOffset(window, base, wanted) {
129
+ let cursor = 0;
130
+ while (cursor < window.length) {
131
+ const newlineAt = window.indexOf(0x0a, cursor);
132
+ if (newlineAt === -1) break;
133
+ const line = window.subarray(cursor, newlineAt).toString('utf8').trim();
134
+ cursor = newlineAt + 1;
135
+ if (!line) continue;
136
+ try {
137
+ if (JSON.parse(line).entry_hash === wanted) return base + cursor;
138
+ } catch {
139
+ // Corrupt lines cannot match a hash; keep scanning.
140
+ }
141
+ }
142
+ return null;
143
+ }
144
+
145
+ function parseEntryLine(line) {
146
+ try {
147
+ const entry = JSON.parse(line);
148
+ return entry && typeof entry === 'object' ? entry : { kind: 'invalid_log_line', raw: line.slice(0, 512) };
149
+ } catch {
150
+ return { kind: 'invalid_log_line', raw: line.slice(0, 512), reason: 'invalid_json' };
151
+ }
152
+ }
153
+
154
+ async function readRange(file, start, end) {
155
+ const handle = await open(file, 'r');
156
+ try {
157
+ const buffer = Buffer.alloc(end - start);
158
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, start);
159
+ return buffer.subarray(0, bytesRead);
160
+ } finally {
161
+ await handle.close();
162
+ }
163
+ }
164
+
165
+ async function skipOversizedLine(file, from, size) {
166
+ let position = from;
167
+ while (position < size) {
168
+ const chunkEnd = Math.min(size, position + OVERSCAN_BYTES);
169
+ const chunk = await readRange(file, position, chunkEnd);
170
+ const newlineAt = chunk.indexOf(0x0a);
171
+ if (newlineAt !== -1) return position + newlineAt + 1;
172
+ position = chunkEnd;
173
+ if (position - from > MAX_OVERSCAN_BYTES) return position;
174
+ }
175
+ return size;
176
+ }
@@ -75,9 +75,13 @@ export class WorkflowExecutor {
75
75
 
76
76
  // ─── Main execution loop ─────────────────────────────────────────────────
77
77
 
78
- // M4 lite fast track: explicit `--lite` input only (the classifier in
79
- // xflow/atoms/lite_track.py is the decision procedure; this flag is the
80
- // enforcement switch). No silent auto-switching.
78
+ // M4 lite fast track: `input.lite === true` engages it. Slice 1 set this
79
+ // only from the explicit `--lite` flag; slice 3 (evalid #101) lets the
80
+ // CLI resolve it first -- `decideLiteTrack` in lite-track-auto.js runs
81
+ // the classifier when no explicit flag is given and sets `input.lite`
82
+ // when the verdict is lite, so the executor itself never decides. The
83
+ // decision itself arrives as `input.lite_track_decision` and is recorded
84
+ // in the workflow_started / workflow_completed events.
81
85
  isLiteFastTrack() {
82
86
  return this.input?.lite === true;
83
87
  }
@@ -137,6 +141,10 @@ export class WorkflowExecutor {
137
141
  workflow: this.workflow.name,
138
142
  track: this.workflow.track,
139
143
  lite_fast_track: this.isLiteFastTrack(),
144
+ // M4 slice 3: how the track was chosen (auto / explicit-lite /
145
+ // explicit-full / auto-fallback-full); null when the run was started
146
+ // without the CLI decision step (e.g. direct executor use).
147
+ lite_track_decision: this.input?.lite_track_decision ?? null,
140
148
  workflow_integrity: this.workflowManifest,
141
149
  max_parallel_agents: this.maxParallelAgents,
142
150
  parallel_policy: this.concurrencyPolicy.mode,
@@ -351,6 +359,7 @@ export class WorkflowExecutor {
351
359
  workflow: this.workflow.name,
352
360
  track: this.workflow.track,
353
361
  lite_fast_track: this.isLiteFastTrack(),
362
+ lite_track_decision: this.input?.lite_track_decision ?? null,
354
363
  workflow_integrity: this.workflowManifest,
355
364
  change_id: this.changeId || null,
356
365
  mission_id: this.runtime.missionId || null,