@windsland52/maa-log-tools 1.1.0 → 1.2.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/README.md CHANGED
@@ -25,6 +25,7 @@ The concrete adapter is provided by `@windsland52/maa-log-adapter`.
25
25
  - `loadFrameworkLogSources`
26
26
  - `extractFrameworkSessions`
27
27
  - `resolveFrameworkSessionForTimestamp`
28
+ - `buildRuntimeInspection`
28
29
  - `DEFAULT_CORE_PARSE_OPTIONS`
29
30
  - `@windsland52/maa-log-tools/node-input`
30
31
  - Node file/zip/folder extraction helpers
@@ -49,7 +50,7 @@ When `focus` is provided, the helpers scan candidate primary and history log fil
49
50
  ## CLI
50
51
 
51
52
  ```bash
52
- pnpm kernel:cli <path> [--pretty] [--no-events] [--preflight]
53
+ pnpm kernel:cli <path> [--pretty] [--no-events] [--preflight|--runtime-inspection]
53
54
  ```
54
55
 
55
56
  `<path>` can be a log file, a zip file, or a log directory.
@@ -67,3 +68,33 @@ and content before a process-start marker is explicitly marked as a partial file
67
68
  Use the session containing the relevant failure timestamp when selecting version-matched source.
68
69
  `resolveFrameworkSessionForTimestamp` returns a session only when exactly one resolved,
69
70
  process-start-bounded interval contains the timestamp; otherwise it returns `null`.
71
+
72
+ ## Runtime inspection
73
+
74
+ `buildRuntimeInspection(kernelOutput, frameworkExtraction, sourceSegments?)` emits
75
+ `mla-runtime-inspection/v1`. It nests task executions under their runtime session and keeps three
76
+ different semantics separate:
77
+
78
+ - `failures`: direct `next_list_timeout` and `action_failed` facts.
79
+ - `outcomes`: failed or still-running pipeline nodes and tasks, with direct-failure references
80
+ when the propagation can be linked deterministically.
81
+ - `signals`: useful non-failure behavior such as recognition succeeding after earlier misses and
82
+ repeated completed-node sequences.
83
+
84
+ An unsuccessful recognition attempt is retry telemetry, not a failure. A next-list failure is
85
+ reported only when the node finishes without matching a candidate. Repeated recognition attempts
86
+ inside one node are not treated as pipeline loops.
87
+
88
+ Tasks are assigned to a process-start session by timestamp. A file segment without a
89
+ `MAA Process Start` marker can also contain tasks when it is the only matching partial interval;
90
+ ambiguous tasks remain in `unscopedTasks` and produce a warning.
91
+
92
+ Use `--runtime-inspection` to emit this result directly from a file, zip, or directory. It is a
93
+ separate output mode from `--preflight`; the two flags are mutually exclusive.
94
+
95
+ When `sourceSegments` is provided, each evidence position is enriched with `source`, `path`, and
96
+ `localLine` describing which original log file the evidence came from and its 1-based line within
97
+ that file. Segments are built by the Node extraction helpers (`loadNodeLogDirectory`,
98
+ `extractZipContentFromNodeFile`) when merging multiple log files; `parserInputLine` remains the
99
+ line in the merged parser input, while `localLine` is the offset within the individual source
100
+ file. Callers without segments leave these fields as `null`.
package/dist/cli.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import type { KernelOutput } from '@windsland52/maa-log-kernel/protocol';
3
- import { type FrameworkSession, type FrameworkSessionExtraction, type FrameworkVersionSummary } from './frameworkVersion';
3
+ import { type FrameworkSession, type FrameworkSessionExtraction, type FrameworkVersionSummary } from './frameworkVersion.js';
4
4
  export declare const MLA_PREFLIGHT_SCHEMA_VERSION = "mla-preflight/v1";
5
5
  export type PreflightReason = 'notify_events_parsed' | 'empty_log' | 'no_notify_events' | 'no_task_lifecycle' | 'no_analyzable_content';
6
6
  export interface PreflightOutput {
package/dist/cli.js CHANGED
@@ -4,10 +4,10 @@ import path from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import { loadFrameworkLogSources } from './frameworkInput.js';
6
6
  import { extractFrameworkSessions, } from './frameworkVersion.js';
7
- import { readNodeTextFileContent } from './nodeInput.js';
8
- import { analyzeLogContent, analyzeDirectory, analyzeZipFile, } from './index.js';
7
+ import { loadNodeLogDirectory, extractZipContentFromNodeFile, readNodeTextFileContent, } from './nodeInput.js';
8
+ import { analyzeLogContent, buildRuntimeInspection, } from './index.js';
9
9
  const printUsage = () => {
10
- console.error('Usage: mla-log-tools <path> [--pretty] [--no-events] [--preflight]');
10
+ console.error('Usage: mla-log-tools <path> [--pretty] [--no-events] [--preflight|--runtime-inspection]');
11
11
  console.error(' <path>: log file path, zip path, or log directory path');
12
12
  };
13
13
  const parseArgs = (argv) => {
@@ -15,6 +15,7 @@ const parseArgs = (argv) => {
15
15
  let pretty = false;
16
16
  let noEvents = false;
17
17
  let preflight = false;
18
+ let runtimeInspection = false;
18
19
  for (const arg of argv) {
19
20
  if (arg === '--pretty') {
20
21
  pretty = true;
@@ -28,6 +29,10 @@ const parseArgs = (argv) => {
28
29
  preflight = true;
29
30
  continue;
30
31
  }
32
+ if (arg === '--runtime-inspection') {
33
+ runtimeInspection = true;
34
+ continue;
35
+ }
31
36
  if (arg === '--help' || arg === '-h') {
32
37
  printUsage();
33
38
  process.exit(0);
@@ -36,7 +41,7 @@ const parseArgs = (argv) => {
36
41
  targetPath = arg;
37
42
  }
38
43
  }
39
- return { targetPath, pretty, noEvents, preflight };
44
+ return { targetPath, pretty, noEvents, preflight, runtimeInspection };
40
45
  };
41
46
  const renderOutput = (output, pretty, noEvents) => {
42
47
  const payload = noEvents
@@ -88,25 +93,55 @@ export const buildPreflightOutput = (output, framework = EMPTY_FRAMEWORK_EXTRACT
88
93
  };
89
94
  };
90
95
  export const main = async () => {
91
- const { targetPath, pretty, noEvents, preflight, } = parseArgs(process.argv.slice(2));
96
+ const { targetPath, pretty, noEvents, preflight, runtimeInspection, } = parseArgs(process.argv.slice(2));
97
+ if (preflight && runtimeInspection) {
98
+ console.error('--preflight and --runtime-inspection are mutually exclusive.');
99
+ process.exit(1);
100
+ }
92
101
  if (!targetPath) {
93
102
  printUsage();
94
103
  process.exit(1);
95
104
  }
96
105
  const resolvedPath = path.resolve(targetPath);
97
106
  const targetStat = await stat(resolvedPath);
98
- const framework = preflight
107
+ const framework = preflight || runtimeInspection
99
108
  ? extractFrameworkSessions(await loadFrameworkLogSources(resolvedPath))
100
109
  : EMPTY_FRAMEWORK_EXTRACTION;
101
110
  let result = null;
111
+ let sourceSegments;
102
112
  if (targetStat.isDirectory()) {
103
- result = await analyzeDirectory({ directoryPath: resolvedPath });
113
+ const extracted = await loadNodeLogDirectory(resolvedPath);
114
+ if (extracted) {
115
+ sourceSegments = extracted.sourceSegments;
116
+ result = await analyzeLogContent({
117
+ content: extracted.content,
118
+ errorImages: extracted.errorImages,
119
+ visionImages: extracted.visionImages,
120
+ waitFreezesImages: extracted.waitFreezesImages,
121
+ });
122
+ }
104
123
  }
105
124
  else if (resolvedPath.toLowerCase().endsWith('.zip')) {
106
- result = await analyzeZipFile({ zipFilePath: resolvedPath });
125
+ const extracted = await extractZipContentFromNodeFile(resolvedPath);
126
+ if (extracted) {
127
+ sourceSegments = extracted.sourceSegments;
128
+ result = await analyzeLogContent({
129
+ content: extracted.content,
130
+ errorImages: extracted.errorImages,
131
+ visionImages: extracted.visionImages,
132
+ waitFreezesImages: extracted.waitFreezesImages,
133
+ });
134
+ }
107
135
  }
108
136
  else {
109
137
  const content = await readNodeTextFileContent(resolvedPath);
138
+ const lineCount = (content.match(/\n/g) ?? []).length + 1;
139
+ sourceSegments = [{
140
+ source: `file:${resolvedPath.replace(/\\/g, '/')}`,
141
+ path: path.basename(resolvedPath),
142
+ startLine: 1,
143
+ lineCount,
144
+ }];
110
145
  result = await analyzeLogContent({ content });
111
146
  }
112
147
  if (!result) {
@@ -126,6 +161,11 @@ export const main = async () => {
126
161
  }
127
162
  return;
128
163
  }
164
+ if (runtimeInspection) {
165
+ process.stdout.write(JSON.stringify(buildRuntimeInspection(result, framework, sourceSegments), null, pretty ? 2 : 0));
166
+ process.stdout.write('\n');
167
+ return;
168
+ }
129
169
  process.stdout.write(renderOutput(result, pretty, noEvents));
130
170
  process.stdout.write('\n');
131
171
  };
@@ -1,2 +1,2 @@
1
- import type { FrameworkLogSource } from './frameworkVersion';
1
+ import type { FrameworkLogSource } from './frameworkVersion.js';
2
2
  export declare const loadFrameworkLogSources: (targetPath: string) => Promise<FrameworkLogSource[]>;
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { KernelOutput } from '@windsland52/maa-log-kernel';
2
2
  import type { AnalyzeLogContentInput, ParseFileOptions } from '@windsland52/maa-log-runtime';
3
- import { type LogBundleFocus } from './nodeInput';
3
+ import { type LogBundleFocus } from './nodeInput.js';
4
4
  type ParseOptions = ParseFileOptions;
5
5
  export interface AnalyzeZipBufferInput {
6
6
  zipData: Uint8Array;
@@ -27,6 +27,7 @@ export declare const analyzeZipFile: (input: AnalyzeZipFileInput) => Promise<Ker
27
27
  export declare const analyzeDirectory: (input: AnalyzeDirectoryInput) => Promise<KernelOutput | null>;
28
28
  export { DEFAULT_CORE_PARSE_OPTIONS, } from '@windsland52/maa-log-runtime';
29
29
  export type { AnalyzeLogContentInput, ParseFileOptions } from '@windsland52/maa-log-runtime';
30
- export * from './nodeInput';
31
- export * from './frameworkInput';
32
- export * from './frameworkVersion';
30
+ export * from './nodeInput.js';
31
+ export * from './frameworkInput.js';
32
+ export * from './frameworkVersion.js';
33
+ export * from './runtimeInspection.js';
package/dist/index.js CHANGED
@@ -56,3 +56,4 @@ export { DEFAULT_CORE_PARSE_OPTIONS, } from '@windsland52/maa-log-runtime';
56
56
  export * from './nodeInput.js';
57
57
  export * from './frameworkInput.js';
58
58
  export * from './frameworkVersion.js';
59
+ export * from './runtimeInspection.js';
@@ -1,3 +1,4 @@
1
+ import type { SourceSegment } from './runtimeInspection.js';
1
2
  export interface KernelTextFile {
2
3
  path: string;
3
4
  name: string;
@@ -10,6 +11,7 @@ export interface NodeExtractedLogContent {
10
11
  visionImages: Map<string, string>;
11
12
  waitFreezesImages: Map<string, string>;
12
13
  textFiles: KernelTextFile[];
14
+ sourceSegments: SourceSegment[];
13
15
  }
14
16
  export interface LogBundleFocus {
15
17
  keywords?: string[];
package/dist/nodeInput.js CHANGED
@@ -146,14 +146,49 @@ const contentMatchesFocus = (content, focus) => {
146
146
  return true;
147
147
  });
148
148
  };
149
- const joinMergedContent = (chunks) => {
150
- return chunks.reduce((result, chunk) => {
151
- if (chunk.length === 0)
152
- return result;
153
- if (result.length === 0)
154
- return chunk;
155
- return result.endsWith('\n') ? `${result}${chunk}` : `${result}\n${chunk}`;
156
- }, '');
149
+ const countNewlines = (content) => {
150
+ let count = 0;
151
+ let pos = 0;
152
+ while ((pos = content.indexOf(String.fromCharCode(10), pos)) >= 0) {
153
+ count += 1;
154
+ pos += 1;
155
+ }
156
+ return count;
157
+ };
158
+ const joinMergedWithSources = (chunks) => {
159
+ let content = "";
160
+ let runningNewlines = 0;
161
+ const chunkStarts = [];
162
+ for (const chunk of chunks) {
163
+ if (chunk.content.length === 0)
164
+ continue;
165
+ if (content.length === 0) {
166
+ content = chunk.content;
167
+ }
168
+ else if (content.endsWith(String.fromCharCode(10))) {
169
+ content += chunk.content;
170
+ }
171
+ else {
172
+ content += String.fromCharCode(10) + chunk.content;
173
+ runningNewlines += 1;
174
+ }
175
+ const startLine = runningNewlines + 1;
176
+ chunkStarts.push({ startLine, source: chunk.source, path: chunk.path });
177
+ runningNewlines += countNewlines(chunk.content);
178
+ }
179
+ if (chunkStarts.length === 0) {
180
+ return { content: "", segments: [] };
181
+ }
182
+ const totalLines = runningNewlines + 1;
183
+ const segments = chunkStarts.map((info, i) => ({
184
+ source: info.source,
185
+ path: info.path,
186
+ startLine: info.startLine,
187
+ lineCount: i < chunkStarts.length - 1
188
+ ? chunkStarts[i + 1].startLine - info.startLine
189
+ : totalLines - info.startLine + 1,
190
+ }));
191
+ return { content, segments };
157
192
  };
158
193
  const rankLogPath = (filePath) => {
159
194
  const baseName = path.basename(filePath).toLowerCase();
@@ -185,11 +220,15 @@ const collectFocusedFileContents = async (logPaths, focus) => {
185
220
  const content = await readNodeTextFileContent(logPath);
186
221
  if (!contentMatchesFocus(content, focus))
187
222
  continue;
188
- chunks.push(content);
223
+ chunks.push({
224
+ content,
225
+ source: toFileReference(logPath),
226
+ path: toPosixPath(path.basename(logPath)),
227
+ });
189
228
  }
190
- return joinMergedContent(chunks);
229
+ return joinMergedWithSources(chunks);
191
230
  };
192
- const collectFocusedZipContents = (entries, paths, basePath, focus) => {
231
+ const collectFocusedZipContents = (entries, paths, basePath, focus, sourceRef) => {
193
232
  const normalizedBasePath = normalizeLowerPath(basePath);
194
233
  const candidatePaths = sortLogPaths(paths.filter((entryPath) => {
195
234
  const normalizedPath = toPosixPath(entryPath);
@@ -209,23 +248,39 @@ const collectFocusedZipContents = (entries, paths, basePath, focus) => {
209
248
  const content = decodeNodeBytes(bytes);
210
249
  if (!contentMatchesFocus(content, focus))
211
250
  continue;
212
- chunks.push(content);
251
+ chunks.push({
252
+ content,
253
+ source: toZipReference(sourceRef, toPosixPath(entryPath)),
254
+ path: toPosixPath(entryPath),
255
+ });
213
256
  }
214
- return joinMergedContent(chunks);
257
+ return joinMergedWithSources(chunks);
215
258
  };
216
- const buildDefaultZipContent = (entries, paths, basePath) => {
259
+ const buildDefaultZipContent = (entries, paths, basePath, sourceRef) => {
217
260
  const bakLogName = BAK_LOG_NAMES.find((name) => findZipEntry(entries, paths, joinPath(basePath, name)));
218
261
  const mainLogName = MAIN_LOG_NAMES.find((name) => findZipEntry(entries, paths, joinPath(basePath, name)));
219
- const bakData = bakLogName ? findZipEntry(entries, paths, joinPath(basePath, bakLogName)) : null;
220
- const mainData = mainLogName ? findZipEntry(entries, paths, joinPath(basePath, mainLogName)) : null;
221
262
  const chunks = [];
222
- if (bakData) {
223
- chunks.push(decodeNodeBytes(bakData));
263
+ if (bakLogName) {
264
+ const data = findZipEntry(entries, paths, joinPath(basePath, bakLogName));
265
+ if (data) {
266
+ chunks.push({
267
+ content: decodeNodeBytes(data),
268
+ source: toZipReference(sourceRef, joinPath(basePath, bakLogName)),
269
+ path: toPosixPath(joinPath(basePath, bakLogName)),
270
+ });
271
+ }
224
272
  }
225
- if (mainData) {
226
- chunks.push(decodeNodeBytes(mainData));
273
+ if (mainLogName) {
274
+ const data = findZipEntry(entries, paths, joinPath(basePath, mainLogName));
275
+ if (data) {
276
+ chunks.push({
277
+ content: decodeNodeBytes(data),
278
+ source: toZipReference(sourceRef, joinPath(basePath, mainLogName)),
279
+ path: toPosixPath(joinPath(basePath, mainLogName)),
280
+ });
281
+ }
227
282
  }
228
- return joinMergedContent(chunks);
283
+ return joinMergedWithSources(chunks);
229
284
  };
230
285
  export const readNodeTextFileContent = async (filePath) => {
231
286
  const bytes = await readFile(filePath);
@@ -239,10 +294,10 @@ export const extractZipContentFromNodeBuffer = (zipData, sourceRef = 'memory.zip
239
294
  const basePath = findBaseDirectory(paths);
240
295
  if (basePath == null)
241
296
  return null;
242
- const content = options.focus
243
- ? collectFocusedZipContents(files, paths, basePath, options.focus)
244
- : buildDefaultZipContent(files, paths, basePath);
245
- if (!content)
297
+ const merged = options.focus
298
+ ? collectFocusedZipContents(files, paths, basePath, options.focus, sourceRef)
299
+ : buildDefaultZipContent(files, paths, basePath, sourceRef);
300
+ if (!merged.content)
246
301
  return null;
247
302
  const errorImages = new Map();
248
303
  const visionImages = new Map();
@@ -285,7 +340,7 @@ export const extractZipContentFromNodeBuffer = (zipData, sourceRef = 'memory.zip
285
340
  });
286
341
  }
287
342
  textFiles.sort((a, b) => a.path.localeCompare(b.path));
288
- return { content, errorImages, visionImages, waitFreezesImages, textFiles };
343
+ return { content: merged.content, sourceSegments: merged.segments, errorImages, visionImages, waitFreezesImages, textFiles };
289
344
  };
290
345
  export const extractZipContentFromNodeFile = async (zipFilePath, options = {}) => {
291
346
  const bytes = await readFile(zipFilePath);
@@ -368,22 +423,30 @@ const buildDefaultDirectoryContent = async (debugPath, allFiles) => {
368
423
  const mainLogPath = await pickPrimaryLogPath(debugPath, allFiles, MAIN_LOG_NAMES);
369
424
  const chunks = [];
370
425
  if (bakLogPath) {
371
- chunks.push(await readNodeTextFileContent(bakLogPath));
426
+ chunks.push({
427
+ content: await readNodeTextFileContent(bakLogPath),
428
+ source: toFileReference(bakLogPath),
429
+ path: toPosixPath(path.relative(debugPath, bakLogPath)),
430
+ });
372
431
  }
373
432
  if (mainLogPath) {
374
- chunks.push(await readNodeTextFileContent(mainLogPath));
433
+ chunks.push({
434
+ content: await readNodeTextFileContent(mainLogPath),
435
+ source: toFileReference(mainLogPath),
436
+ path: toPosixPath(path.relative(debugPath, mainLogPath)),
437
+ });
375
438
  }
376
- return joinMergedContent(chunks);
439
+ return joinMergedWithSources(chunks);
377
440
  };
378
441
  export const loadNodeLogDirectory = async (inputDirectoryPath, options = {}) => {
379
442
  const debugPath = await resolveDebugDirectory(inputDirectoryPath);
380
443
  if (!debugPath)
381
444
  return null;
382
445
  const allFiles = await collectFilesRecursively(debugPath);
383
- const content = options.focus
446
+ const merged = options.focus
384
447
  ? await collectFocusedFileContents(allFiles.filter((filePath) => isCoreLogName(path.basename(filePath))), options.focus)
385
448
  : await buildDefaultDirectoryContent(debugPath, allFiles);
386
- if (!content)
449
+ if (!merged.content)
387
450
  return null;
388
451
  const errorImages = new Map();
389
452
  const visionImages = new Map();
@@ -422,7 +485,8 @@ export const loadNodeLogDirectory = async (inputDirectoryPath, options = {}) =>
422
485
  }
423
486
  textFiles.sort((a, b) => a.path.localeCompare(b.path));
424
487
  return {
425
- content,
488
+ content: merged.content,
489
+ sourceSegments: merged.segments,
426
490
  errorImages,
427
491
  visionImages,
428
492
  waitFreezesImages,
@@ -0,0 +1,225 @@
1
+ import type { KernelOutput, TaskInfo } from '@windsland52/maa-log-kernel';
2
+ import type { FrameworkSession, FrameworkSessionExtraction } from './frameworkVersion.js';
3
+ export declare const MLA_RUNTIME_INSPECTION_SCHEMA_VERSION = "mla-runtime-inspection/v1";
4
+ export interface RuntimeEvidencePosition {
5
+ timestamp: string | null;
6
+ parserInputLine: number | null;
7
+ source: string | null;
8
+ path: string | null;
9
+ localLine: number | null;
10
+ }
11
+ export interface SourceSegment {
12
+ source: string;
13
+ path: string;
14
+ startLine: number;
15
+ lineCount: number;
16
+ }
17
+ interface RuntimeScope {
18
+ sessionId: string | null;
19
+ executionId: string;
20
+ taskId: number;
21
+ taskName: string;
22
+ }
23
+ export interface RuntimeFailure extends RuntimeScope {
24
+ failureId: string;
25
+ kind: 'next_list_timeout' | 'action_failed';
26
+ nodeId: number;
27
+ nodeName: string;
28
+ startedAt: string;
29
+ endedAt: string | null;
30
+ errorImages: string[];
31
+ visionImages: string[];
32
+ evidence: RuntimeEvidencePosition;
33
+ }
34
+ export interface RuntimeOutcome extends RuntimeScope {
35
+ outcomeId: string;
36
+ kind: 'pipeline_node' | 'task';
37
+ status: 'failed' | 'running';
38
+ nodeId: number | null;
39
+ nodeName: string | null;
40
+ directFailureIds: string[];
41
+ evidence: RuntimeEvidencePosition;
42
+ }
43
+ export interface RuntimeMetricDistribution {
44
+ count: number;
45
+ minimum: number;
46
+ p50: number;
47
+ p95: number;
48
+ maximum: number;
49
+ average: number;
50
+ }
51
+ export type RuntimeSignalPriority = 'high' | 'normal' | 'low';
52
+ export type RuntimeSignalPriorityReason = 'timeout' | 'unmatched_terminal' | 'high_mixed_results' | 'high_unsuccessful_attempts' | 'high_occurrence_count' | 'related_to_direct_failure' | 'still_repeating_at_log_end' | 'high_repeat_count' | 'long_duration' | 'incomplete_repetition';
53
+ export interface RecognitionOccurrenceSample {
54
+ nodeId: number;
55
+ startedAt: string;
56
+ endedAt: string | null;
57
+ attemptCount: number;
58
+ unsuccessfulAttempts: number;
59
+ terminalMatch: string | null;
60
+ evidence: {
61
+ start: RuntimeEvidencePosition;
62
+ end: RuntimeEvidencePosition;
63
+ };
64
+ }
65
+ export interface RecognitionActivitySignal extends RuntimeScope {
66
+ signalId: string;
67
+ kind: 'recognition_activity';
68
+ pipelineNodeName: string;
69
+ nextList: {
70
+ name: string;
71
+ anchor: boolean;
72
+ jumpBack: boolean;
73
+ }[];
74
+ occurrenceCount: number;
75
+ occurrencesWithMixedResults: number;
76
+ terminalOutcomes: {
77
+ matched: number;
78
+ timeout: number;
79
+ running: number;
80
+ unmatched: number;
81
+ };
82
+ terminalMatches: {
83
+ name: string;
84
+ count: number;
85
+ }[];
86
+ candidateStatistics: {
87
+ name: string;
88
+ evaluationCount: number;
89
+ matchedAttemptCount: number;
90
+ unsuccessfulAttemptCount: number;
91
+ runningAttemptCount: number;
92
+ terminalMatchCount: number;
93
+ }[];
94
+ unmappedAttemptCount: number;
95
+ attempts: RuntimeMetricDistribution;
96
+ unsuccessfulAttempts: RuntimeMetricDistribution;
97
+ durationMs: RuntimeMetricDistribution;
98
+ representatives: {
99
+ first: RecognitionOccurrenceSample;
100
+ worst: RecognitionOccurrenceSample;
101
+ last: RecognitionOccurrenceSample;
102
+ };
103
+ priority: RuntimeSignalPriority;
104
+ priorityReasons: RuntimeSignalPriorityReason[];
105
+ }
106
+ export interface RepeatedNodeSequenceSignal extends RuntimeScope {
107
+ signalId: string;
108
+ kind: 'repeated_node' | 'repeated_node_cycle';
109
+ pattern: string[];
110
+ segmentCount: number;
111
+ totalRepeatCount: number;
112
+ maximumRepeatCount: number;
113
+ durationMs: RuntimeMetricDistribution;
114
+ terminations: {
115
+ leftPattern: number;
116
+ taskEnded: number;
117
+ stillRepeatingAtLogEnd: number;
118
+ };
119
+ representatives: {
120
+ first: {
121
+ firstSeenAt: string;
122
+ lastSeenAt: string;
123
+ repeatCount: number;
124
+ evidence: RuntimeEvidencePosition;
125
+ };
126
+ longest: {
127
+ firstSeenAt: string;
128
+ lastSeenAt: string;
129
+ repeatCount: number;
130
+ evidence: RuntimeEvidencePosition;
131
+ };
132
+ last: {
133
+ firstSeenAt: string;
134
+ lastSeenAt: string;
135
+ repeatCount: number;
136
+ evidence: RuntimeEvidencePosition;
137
+ };
138
+ };
139
+ detector: {
140
+ name: 'repeated-completed-node-sequence';
141
+ version: 1;
142
+ minimumRepeats: number;
143
+ maximumPatternLength: 8;
144
+ };
145
+ priority: RuntimeSignalPriority;
146
+ priorityReasons: RuntimeSignalPriorityReason[];
147
+ }
148
+ export type RuntimeSignal = RecognitionActivitySignal | RepeatedNodeSequenceSignal;
149
+ export interface RuntimeTaskStatistics {
150
+ nodeExecutions: number;
151
+ succeededNodes: number;
152
+ failedNodes: number;
153
+ runningNodes: number;
154
+ recognitionAttempts: number;
155
+ unsuccessfulRecognitionAttempts: number;
156
+ nodeExecutionsWithRecognition: number;
157
+ nodeExecutionsWithMixedRecognitionResults: number;
158
+ recognitionActivityGroups: number;
159
+ maximumRecognitionAttemptsPerNode: number;
160
+ maximumUnsuccessfulRecognitionAttemptsPerNode: number;
161
+ actionAttempts: number;
162
+ actionFailures: number;
163
+ nextListTimeouts: number;
164
+ errorImageReferences: number;
165
+ uniqueErrorImages: number;
166
+ visionImageReferences: number;
167
+ uniqueVisionImages: number;
168
+ }
169
+ export interface RuntimeTaskExecution {
170
+ executionId: string;
171
+ taskId: number;
172
+ name: string;
173
+ hash: string;
174
+ uuid: string;
175
+ status: TaskInfo['status'];
176
+ completeness: 'complete' | 'open_at_log_end';
177
+ startedAt: string;
178
+ endedAt: string | null;
179
+ observedDurationMs: number | null;
180
+ firstNode: string | null;
181
+ lastNode: string | null;
182
+ statistics: RuntimeTaskStatistics;
183
+ directFailureIds: string[];
184
+ outcomeIds: string[];
185
+ signalIds: string[];
186
+ signalHighlights: {
187
+ recognitionActivity: string[];
188
+ repetitions: string[];
189
+ };
190
+ evidence: {
191
+ start: RuntimeEvidencePosition;
192
+ end: RuntimeEvidencePosition;
193
+ };
194
+ }
195
+ export interface RuntimeSession {
196
+ sessionId: string;
197
+ startKind: FrameworkSession['startKind'];
198
+ frameworkStatus: FrameworkSession['status'];
199
+ frameworkVersion: string | null;
200
+ versions: string[];
201
+ start: FrameworkSession['start'];
202
+ end: FrameworkSession['end'];
203
+ tasks: RuntimeTaskExecution[];
204
+ summary: {
205
+ taskExecutions: number;
206
+ succeededTasks: number;
207
+ failedTasks: number;
208
+ runningTasks: number;
209
+ directFailures: number;
210
+ nextListTimeouts: number;
211
+ actionFailures: number;
212
+ signals: number;
213
+ };
214
+ }
215
+ export interface RuntimeInspection {
216
+ schemaVersion: typeof MLA_RUNTIME_INSPECTION_SCHEMA_VERSION;
217
+ sessions: RuntimeSession[];
218
+ unscopedTasks: RuntimeTaskExecution[];
219
+ failures: RuntimeFailure[];
220
+ outcomes: RuntimeOutcome[];
221
+ signals: RuntimeSignal[];
222
+ warnings: string[];
223
+ }
224
+ export declare const buildRuntimeInspection: (output: KernelOutput, framework: FrameworkSessionExtraction, sourceSegments?: readonly SourceSegment[]) => RuntimeInspection;
225
+ export {};
@@ -0,0 +1,582 @@
1
+ import { buildNodeExecutionTimeline } from './nodeExecutionTimeline.js';
2
+ export const MLA_RUNTIME_INSPECTION_SCHEMA_VERSION = 'mla-runtime-inspection/v1';
3
+ const timestampMs = (value) => {
4
+ if (!value)
5
+ return null;
6
+ const parsed = Date.parse(value.includes('T') ? value : value.replace(' ', 'T'));
7
+ return Number.isFinite(parsed) ? parsed : null;
8
+ };
9
+ const elapsed = (start, end) => {
10
+ const startMs = timestampMs(start);
11
+ const endMs = timestampMs(end);
12
+ return startMs == null || endMs == null ? null : Math.max(0, endMs - startMs);
13
+ };
14
+ const buildEvidenceIndex = (task) => {
15
+ const exactLines = new Map();
16
+ const ordered = [];
17
+ for (const event of task.events) {
18
+ if (event._lineNumber == null)
19
+ continue;
20
+ if (!exactLines.has(event.timestamp))
21
+ exactLines.set(event.timestamp, event._lineNumber);
22
+ const timestamp = timestampMs(event.timestamp);
23
+ if (timestamp != null)
24
+ ordered.push({ timestamp, line: event._lineNumber });
25
+ }
26
+ ordered.sort((left, right) => left.timestamp - right.timestamp);
27
+ return { exactLines, ordered };
28
+ };
29
+ const evidence = (index, timestamp) => {
30
+ if (timestamp) {
31
+ const exactLine = index.exactLines.get(timestamp);
32
+ if (exactLine != null)
33
+ return { timestamp, parserInputLine: exactLine, source: null, path: null, localLine: null };
34
+ }
35
+ const target = timestampMs(timestamp);
36
+ if (target == null || index.ordered.length === 0) {
37
+ return { timestamp: timestamp ?? null, parserInputLine: null, source: null, path: null, localLine: null };
38
+ }
39
+ let low = 0;
40
+ let high = index.ordered.length;
41
+ while (low < high) {
42
+ const middle = Math.floor((low + high) / 2);
43
+ if ((index.ordered[middle]?.timestamp ?? target) < target)
44
+ low = middle + 1;
45
+ else
46
+ high = middle;
47
+ }
48
+ const before = index.ordered[low - 1];
49
+ const after = index.ordered[low];
50
+ const nearest = before == null
51
+ ? after
52
+ : after == null || target - before.timestamp <= after.timestamp - target ? before : after;
53
+ return { timestamp: timestamp ?? null, parserInputLine: nearest?.line ?? null, source: null, path: null, localLine: null };
54
+ };
55
+ const recognitionItems = (items) => ((items ?? []).flatMap(item => [
56
+ ...(item.type === 'recognition' || item.type === 'recognition_node' ? [item] : []),
57
+ ...recognitionItems(item.children),
58
+ ]));
59
+ const imagesFor = (node) => {
60
+ const attempts = recognitionItems(node.node_flow);
61
+ return {
62
+ error: [node.error_image, ...attempts.map(item => item.error_image)]
63
+ .filter((item) => Boolean(item)),
64
+ vision: attempts.map(item => item.vision_image).filter((item) => Boolean(item)),
65
+ };
66
+ };
67
+ const scopeFor = (task, sessionId, executionId) => ({
68
+ sessionId,
69
+ executionId,
70
+ taskId: task.task_id,
71
+ taskName: task.entry,
72
+ });
73
+ const sessionFor = (task, sessions) => {
74
+ const candidates = sessions.filter(session => session.start.timestamp != null
75
+ && session.end.timestamp != null
76
+ && task.start_time >= session.start.timestamp
77
+ && task.start_time <= session.end.timestamp);
78
+ const complete = candidates.filter(session => session.startKind === 'process_start');
79
+ if (complete.length === 1)
80
+ return complete[0] ?? null;
81
+ const partial = candidates.filter(session => session.startKind === 'partial_file');
82
+ return complete.length === 0 && partial.length === 1 ? (partial[0] ?? null) : null;
83
+ };
84
+ const metricDistribution = (values) => {
85
+ if (values.length === 0) {
86
+ return { count: 0, minimum: 0, p50: 0, p95: 0, maximum: 0, average: 0 };
87
+ }
88
+ const sorted = [...values].sort((left, right) => left - right);
89
+ const percentile = (ratio) => (sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)] ?? 0);
90
+ const total = sorted.reduce((sum, value) => sum + value, 0);
91
+ return {
92
+ count: sorted.length,
93
+ minimum: sorted[0] ?? 0,
94
+ p50: percentile(0.5),
95
+ p95: percentile(0.95),
96
+ maximum: sorted[sorted.length - 1] ?? 0,
97
+ average: total / sorted.length,
98
+ };
99
+ };
100
+ const recognitionCandidateName = (attempt, nextNames) => {
101
+ if (attempt.anchor_name && nextNames.has(attempt.anchor_name))
102
+ return attempt.anchor_name;
103
+ if (nextNames.has(attempt.name))
104
+ return attempt.name;
105
+ const detailName = attempt.reco_details?.name;
106
+ return detailName && nextNames.has(detailName) ? detailName : null;
107
+ };
108
+ const increment = (map, key) => {
109
+ map.set(key, (map.get(key) ?? 0) + 1);
110
+ };
111
+ const prioritize = (reasons) => {
112
+ if (reasons.length === 0)
113
+ return { priority: 'low', priorityReasons: [] };
114
+ if (reasons.includes('timeout')
115
+ || reasons.includes('unmatched_terminal')
116
+ || reasons.includes('still_repeating_at_log_end')
117
+ || reasons.includes('related_to_direct_failure')
118
+ || reasons.includes('incomplete_repetition')) {
119
+ return { priority: 'high', priorityReasons: reasons };
120
+ }
121
+ if (reasons.includes('high_mixed_results')
122
+ || reasons.includes('high_unsuccessful_attempts')
123
+ || reasons.includes('high_occurrence_count')
124
+ || reasons.includes('high_repeat_count')
125
+ || reasons.includes('long_duration')) {
126
+ return { priority: 'normal', priorityReasons: reasons };
127
+ }
128
+ return { priority: 'low', priorityReasons: reasons };
129
+ };
130
+ const repetitions = (nodes) => {
131
+ const result = [];
132
+ let start = 0;
133
+ while (start < nodes.length) {
134
+ let best = null;
135
+ for (let length = 1; length <= Math.min(8, Math.floor((nodes.length - start) / 2)); length += 1) {
136
+ let count = 1;
137
+ while (start + (count + 1) * length <= nodes.length) {
138
+ const same = nodes.slice(start, start + length).every((node, offset) => node.name === nodes[start + count * length + offset]?.name);
139
+ if (!same)
140
+ break;
141
+ count += 1;
142
+ }
143
+ if (count < (length === 1 ? 5 : 3))
144
+ continue;
145
+ if (best == null || length * count > best.length * best.count)
146
+ best = { start, length, count };
147
+ }
148
+ if (best == null)
149
+ start += 1;
150
+ else {
151
+ result.push(best);
152
+ start += best.length * best.count;
153
+ }
154
+ }
155
+ return result;
156
+ };
157
+ const canonicalCycle = (pattern) => {
158
+ if (pattern.length < 2)
159
+ return [...pattern];
160
+ const rotations = pattern.map((_, index) => [...pattern.slice(index), ...pattern.slice(0, index)]);
161
+ rotations.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
162
+ return rotations[0] ?? [...pattern];
163
+ };
164
+ const findSourceSegment = (segments, parserInputLine) => {
165
+ for (const segment of segments) {
166
+ if (parserInputLine >= segment.startLine && parserInputLine < segment.startLine + segment.lineCount) {
167
+ return segment;
168
+ }
169
+ }
170
+ return null;
171
+ };
172
+ const enrichWithSource = (position, segments) => {
173
+ if (!segments || position.parserInputLine == null)
174
+ return position;
175
+ const segment = findSourceSegment(segments, position.parserInputLine);
176
+ if (!segment)
177
+ return position;
178
+ return {
179
+ ...position,
180
+ source: segment.source,
181
+ path: segment.path,
182
+ localLine: position.parserInputLine - segment.startLine + 1,
183
+ };
184
+ };
185
+ const createEvidenceFn = (segments) => (index, timestamp) => enrichWithSource(evidence(index, timestamp), segments);
186
+ export const buildRuntimeInspection = (output, framework, sourceSegments) => {
187
+ const failures = [];
188
+ const outcomes = [];
189
+ const signals = [];
190
+ const taskOccurrences = new Map();
191
+ const executionIds = new Map();
192
+ const sessionIds = new Map();
193
+ const executionSessionIds = new Map();
194
+ const evidenceAt = createEvidenceFn(sourceSegments);
195
+ for (const task of output.tasks) {
196
+ const occurrence = (taskOccurrences.get(task.task_id) ?? 0) + 1;
197
+ taskOccurrences.set(task.task_id, occurrence);
198
+ const executionId = `task-execution-${task.task_id}-${occurrence}`;
199
+ const sessionId = sessionFor(task, framework.sessions)?.sessionId ?? null;
200
+ executionIds.set(task, executionId);
201
+ sessionIds.set(task, sessionId);
202
+ executionSessionIds.set(executionId, sessionId);
203
+ }
204
+ const buildTask = (task) => {
205
+ const sessionId = sessionIds.get(task) ?? null;
206
+ const executionId = executionIds.get(task);
207
+ if (!executionId)
208
+ throw new Error('Task execution identity was not initialized.');
209
+ const scope = scopeFor(task, sessionId, executionId);
210
+ const directFailureIds = [];
211
+ const outcomeIds = [];
212
+ const signalIds = [];
213
+ const recognitionOccurrences = [];
214
+ const evidenceIndex = buildEvidenceIndex(task);
215
+ const timeline = buildNodeExecutionTimeline(task.nodes, { rootTaskId: task.task_id });
216
+ for (const item of timeline) {
217
+ const failureKind = item.navStatus === 'action-failed'
218
+ ? 'action_failed'
219
+ : item.navStatus === 'timeout' && item.nodeInfo.next_list.length > 0
220
+ ? 'next_list_timeout'
221
+ : null;
222
+ let nodeFailureId = null;
223
+ if (failureKind) {
224
+ nodeFailureId = `failure-${failures.length + 1}`;
225
+ const images = imagesFor(item.nodeInfo);
226
+ failures.push({
227
+ ...scope,
228
+ failureId: nodeFailureId,
229
+ kind: failureKind,
230
+ nodeId: item.nodeInfo.node_id,
231
+ nodeName: item.executionName,
232
+ startedAt: item.nodeInfo.ts,
233
+ endedAt: item.nodeInfo.end_ts ?? null,
234
+ errorImages: [...new Set(images.error)],
235
+ visionImages: [...new Set(images.vision)],
236
+ evidence: evidenceAt(evidenceIndex, item.nodeInfo.end_ts ?? item.nodeInfo.ts),
237
+ });
238
+ directFailureIds.push(nodeFailureId);
239
+ }
240
+ if (item.nodeInfo.status !== 'success') {
241
+ const outcomeId = `outcome-${outcomes.length + 1}`;
242
+ outcomes.push({
243
+ ...scope,
244
+ outcomeId,
245
+ kind: 'pipeline_node',
246
+ status: item.nodeInfo.status,
247
+ nodeId: item.nodeInfo.node_id,
248
+ nodeName: item.executionName,
249
+ directFailureIds: nodeFailureId ? [nodeFailureId] : [],
250
+ evidence: evidenceAt(evidenceIndex, item.nodeInfo.end_ts ?? item.nodeInfo.ts),
251
+ });
252
+ outcomeIds.push(outcomeId);
253
+ }
254
+ const attempts = recognitionItems(item.nodeInfo.node_flow);
255
+ const missed = attempts.filter(attempt => attempt.status === 'failed');
256
+ if (item.nodeInfo.next_list.length > 0) {
257
+ const terminalOutcome = item.navStatus === 'timeout'
258
+ ? 'timeout'
259
+ : item.nodeInfo.status === 'running'
260
+ ? 'running'
261
+ : item.matchedRecognitionName ? 'matched' : 'unmatched';
262
+ recognitionOccurrences.push({
263
+ nodeId: item.nodeInfo.node_id,
264
+ pipelineNodeName: item.nodeInfo.name,
265
+ nextList: item.nodeInfo.next_list.map(next => ({
266
+ name: next.name,
267
+ anchor: next.anchor,
268
+ jumpBack: next.jump_back,
269
+ })),
270
+ attempts,
271
+ terminalMatch: item.matchedRecognitionName ?? null,
272
+ terminalOutcome,
273
+ durationMs: elapsed(item.nodeInfo.ts, item.nodeInfo.end_ts),
274
+ sample: {
275
+ nodeId: item.nodeInfo.node_id,
276
+ startedAt: item.nodeInfo.ts,
277
+ endedAt: item.nodeInfo.end_ts ?? null,
278
+ attemptCount: attempts.length,
279
+ unsuccessfulAttempts: missed.length,
280
+ terminalMatch: item.matchedRecognitionName ?? null,
281
+ evidence: {
282
+ start: evidenceAt(evidenceIndex, attempts[0]?.ts ?? item.nodeInfo.ts),
283
+ end: evidenceAt(evidenceIndex, item.nodeInfo.end_ts ?? item.nodeInfo.ts),
284
+ },
285
+ },
286
+ });
287
+ }
288
+ }
289
+ const recognitionGroups = new Map();
290
+ for (const occurrence of recognitionOccurrences) {
291
+ const key = JSON.stringify([occurrence.pipelineNodeName, occurrence.nextList]);
292
+ const group = recognitionGroups.get(key) ?? [];
293
+ group.push(occurrence);
294
+ recognitionGroups.set(key, group);
295
+ }
296
+ for (const group of recognitionGroups.values()) {
297
+ const firstOccurrence = group[0];
298
+ const lastOccurrence = group[group.length - 1];
299
+ if (!firstOccurrence || !lastOccurrence)
300
+ continue;
301
+ const signalId = `signal-${signals.length + 1}`;
302
+ const terminalMatches = new Map();
303
+ const terminalOutcomes = { matched: 0, timeout: 0, running: 0, unmatched: 0 };
304
+ const candidates = new Map(firstOccurrence.nextList.map(next => [next.name, {
305
+ name: next.name,
306
+ evaluationCount: 0,
307
+ matchedAttemptCount: 0,
308
+ unsuccessfulAttemptCount: 0,
309
+ runningAttemptCount: 0,
310
+ terminalMatchCount: 0,
311
+ }]));
312
+ let unmappedAttemptCount = 0;
313
+ let occurrencesWithMixedResults = 0;
314
+ for (const occurrence of group) {
315
+ terminalOutcomes[occurrence.terminalOutcome] += 1;
316
+ if (occurrence.terminalMatch) {
317
+ increment(terminalMatches, occurrence.terminalMatch);
318
+ const candidate = candidates.get(occurrence.terminalMatch);
319
+ if (candidate)
320
+ candidate.terminalMatchCount += 1;
321
+ }
322
+ const statuses = new Set(occurrence.attempts.map(attempt => attempt.status));
323
+ if (statuses.has('failed') && statuses.has('success'))
324
+ occurrencesWithMixedResults += 1;
325
+ const nextNames = new Set(candidates.keys());
326
+ for (const attempt of occurrence.attempts) {
327
+ const candidateName = recognitionCandidateName(attempt, nextNames);
328
+ const candidate = candidateName ? candidates.get(candidateName) : null;
329
+ if (!candidate) {
330
+ unmappedAttemptCount += 1;
331
+ continue;
332
+ }
333
+ candidate.evaluationCount += 1;
334
+ if (attempt.status === 'success')
335
+ candidate.matchedAttemptCount += 1;
336
+ else if (attempt.status === 'failed')
337
+ candidate.unsuccessfulAttemptCount += 1;
338
+ else
339
+ candidate.runningAttemptCount += 1;
340
+ }
341
+ }
342
+ const worstOccurrence = [...group].sort((left, right) => (right.sample.unsuccessfulAttempts - left.sample.unsuccessfulAttempts
343
+ || right.sample.attemptCount - left.sample.attemptCount))[0] ?? firstOccurrence;
344
+ const attemptsDist = metricDistribution(group.map(item => item.sample.attemptCount));
345
+ const unsuccessfulDist = metricDistribution(group.map(item => item.sample.unsuccessfulAttempts));
346
+ const durationDist = metricDistribution(group.flatMap(item => item.durationMs == null ? [] : [item.durationMs]));
347
+ const reasons = [];
348
+ if (terminalOutcomes.timeout > 0)
349
+ reasons.push('timeout');
350
+ if (terminalOutcomes.unmatched > 0)
351
+ reasons.push('unmatched_terminal');
352
+ if (group.length > 0 && occurrencesWithMixedResults / group.length >= 0.3)
353
+ reasons.push('high_mixed_results');
354
+ if (unsuccessfulDist.maximum >= 5 || unsuccessfulDist.p95 >= 3)
355
+ reasons.push('high_unsuccessful_attempts');
356
+ if (group.length >= 20)
357
+ reasons.push('high_occurrence_count');
358
+ if (group.some(item => item.sample.nodeId && failures.some(failure => failure.nodeId === item.sample.nodeId && failure.executionId === scope.executionId))) {
359
+ reasons.push('related_to_direct_failure');
360
+ }
361
+ const ranking = prioritize(reasons);
362
+ signals.push({
363
+ ...scope,
364
+ signalId,
365
+ kind: 'recognition_activity',
366
+ pipelineNodeName: firstOccurrence.pipelineNodeName,
367
+ nextList: firstOccurrence.nextList,
368
+ occurrenceCount: group.length,
369
+ occurrencesWithMixedResults,
370
+ terminalOutcomes,
371
+ terminalMatches: [...terminalMatches].map(([name, count]) => ({ name, count }))
372
+ .sort((left, right) => right.count - left.count),
373
+ candidateStatistics: [...candidates.values()],
374
+ unmappedAttemptCount,
375
+ attempts: attemptsDist,
376
+ unsuccessfulAttempts: unsuccessfulDist,
377
+ durationMs: durationDist,
378
+ representatives: {
379
+ first: firstOccurrence.sample,
380
+ worst: worstOccurrence.sample,
381
+ last: lastOccurrence.sample,
382
+ },
383
+ priority: ranking.priority,
384
+ priorityReasons: ranking.priorityReasons,
385
+ });
386
+ signalIds.push(signalId);
387
+ }
388
+ const completed = timeline.map(item => item.nodeInfo).filter(node => node.status !== 'running');
389
+ const repetitionGroups = new Map();
390
+ for (const repeated of repetitions(completed)) {
391
+ const first = completed[repeated.start];
392
+ const lastIndex = repeated.start + repeated.length * repeated.count - 1;
393
+ const last = completed[lastIndex];
394
+ if (!first || !last)
395
+ continue;
396
+ const reachesEnd = lastIndex === completed.length - 1;
397
+ const rawPattern = completed.slice(repeated.start, repeated.start + repeated.length).map(node => node.name);
398
+ const pattern = repeated.length === 1 ? rawPattern : canonicalCycle(rawPattern);
399
+ const kind = repeated.length === 1 ? 'repeated_node' : 'repeated_node_cycle';
400
+ const key = JSON.stringify([kind, pattern]);
401
+ const group = repetitionGroups.get(key) ?? [];
402
+ group.push({
403
+ pattern,
404
+ repeatCount: repeated.count,
405
+ durationMs: elapsed(first.ts, last.end_ts ?? last.ts) ?? 0,
406
+ firstSeenAt: first.ts,
407
+ lastSeenAt: last.end_ts ?? last.ts,
408
+ termination: reachesEnd
409
+ ? task.status === 'running' ? 'still_repeating_at_log_end' : 'task_ended'
410
+ : 'left_pattern',
411
+ evidence: evidenceAt(evidenceIndex, first.ts),
412
+ });
413
+ repetitionGroups.set(key, group);
414
+ }
415
+ for (const group of repetitionGroups.values()) {
416
+ const first = group[0];
417
+ const last = group[group.length - 1];
418
+ if (!first || !last)
419
+ continue;
420
+ const longest = [...group].sort((left, right) => right.durationMs - left.durationMs)[0] ?? first;
421
+ const signalId = `signal-${signals.length + 1}`;
422
+ const terminations = {
423
+ leftPattern: group.filter(item => item.termination === 'left_pattern').length,
424
+ taskEnded: group.filter(item => item.termination === 'task_ended').length,
425
+ stillRepeatingAtLogEnd: group.filter(item => item.termination === 'still_repeating_at_log_end').length,
426
+ };
427
+ const totalRepeatCount = group.reduce((sum, item) => sum + item.repeatCount, 0);
428
+ const maximumRepeatCount = Math.max(...group.map(item => item.repeatCount));
429
+ const repetitionReasons = [];
430
+ if (terminations.leftPattern > 0)
431
+ repetitionReasons.push('incomplete_repetition');
432
+ if (terminations.stillRepeatingAtLogEnd > 0)
433
+ repetitionReasons.push('still_repeating_at_log_end');
434
+ if (maximumRepeatCount >= 10 || totalRepeatCount >= 20)
435
+ repetitionReasons.push('high_repeat_count');
436
+ const repetitionRanking = prioritize(repetitionReasons);
437
+ signals.push({
438
+ ...scope,
439
+ signalId,
440
+ kind: first.pattern.length === 1 ? 'repeated_node' : 'repeated_node_cycle',
441
+ pattern: first.pattern,
442
+ segmentCount: group.length,
443
+ totalRepeatCount,
444
+ maximumRepeatCount,
445
+ durationMs: metricDistribution(group.map(item => item.durationMs)),
446
+ terminations,
447
+ representatives: {
448
+ first: first,
449
+ longest,
450
+ last,
451
+ },
452
+ detector: {
453
+ name: 'repeated-completed-node-sequence',
454
+ version: 1,
455
+ minimumRepeats: first.pattern.length === 1 ? 5 : 3,
456
+ maximumPatternLength: 8,
457
+ },
458
+ priority: repetitionRanking.priority,
459
+ priorityReasons: repetitionRanking.priorityReasons,
460
+ });
461
+ signalIds.push(signalId);
462
+ }
463
+ if (task.status !== 'succeeded') {
464
+ const outcomeId = `outcome-${outcomes.length + 1}`;
465
+ outcomes.push({
466
+ ...scope,
467
+ outcomeId,
468
+ kind: 'task',
469
+ status: task.status,
470
+ nodeId: null,
471
+ nodeName: null,
472
+ directFailureIds: [...directFailureIds],
473
+ evidence: evidenceAt(evidenceIndex, task.end_time ?? task.events[task.events.length - 1]?.timestamp),
474
+ });
475
+ outcomeIds.push(outcomeId);
476
+ }
477
+ const attemptsByNode = timeline.map(item => recognitionItems(item.nodeInfo.node_flow));
478
+ const allAttempts = attemptsByNode.flat();
479
+ const imageSets = timeline.map(item => imagesFor(item.nodeInfo));
480
+ const errorImages = imageSets.flatMap(set => set.error);
481
+ const visionImages = imageSets.flatMap(set => set.vision);
482
+ const ownFailures = failures.filter(failure => directFailureIds.includes(failure.failureId));
483
+ const ownSignals = signals.filter(signal => signalIds.includes(signal.signalId));
484
+ const recognitionActivity = ownSignals
485
+ .filter((signal) => (signal.kind === 'recognition_activity'))
486
+ .sort((left, right) => (right.unsuccessfulAttempts.maximum - left.unsuccessfulAttempts.maximum
487
+ || right.occurrenceCount - left.occurrenceCount))
488
+ .slice(0, 5)
489
+ .map(signal => signal.signalId);
490
+ const repetitionSignals = ownSignals
491
+ .filter((signal) => signal.kind !== 'recognition_activity')
492
+ .sort((left, right) => (right.totalRepeatCount * right.pattern.length - left.totalRepeatCount * left.pattern.length))
493
+ .slice(0, 5)
494
+ .map(signal => signal.signalId);
495
+ return {
496
+ executionId: scope.executionId,
497
+ taskId: task.task_id,
498
+ name: task.entry,
499
+ hash: task.hash,
500
+ uuid: task.uuid,
501
+ status: task.status,
502
+ completeness: task.status === 'running' ? 'open_at_log_end' : 'complete',
503
+ startedAt: task.start_time,
504
+ endedAt: task.end_time ?? null,
505
+ observedDurationMs: task.duration ?? elapsed(task.start_time, task.end_time),
506
+ firstNode: timeline[0]?.executionName ?? null,
507
+ lastNode: timeline[timeline.length - 1]?.executionName ?? null,
508
+ statistics: {
509
+ nodeExecutions: timeline.length,
510
+ succeededNodes: timeline.filter(item => item.nodeInfo.status === 'success').length,
511
+ failedNodes: timeline.filter(item => item.nodeInfo.status === 'failed').length,
512
+ runningNodes: timeline.filter(item => item.nodeInfo.status === 'running').length,
513
+ recognitionAttempts: allAttempts.length,
514
+ unsuccessfulRecognitionAttempts: allAttempts.filter(attempt => attempt.status === 'failed').length,
515
+ nodeExecutionsWithRecognition: attemptsByNode.filter(attempts => attempts.length > 0).length,
516
+ nodeExecutionsWithMixedRecognitionResults: attemptsByNode.filter(attempts => {
517
+ const statuses = new Set(attempts.map(attempt => attempt.status));
518
+ return statuses.has('failed') && statuses.has('success');
519
+ }).length,
520
+ recognitionActivityGroups: recognitionActivity.length,
521
+ maximumRecognitionAttemptsPerNode: Math.max(0, ...attemptsByNode.map(attempts => attempts.length)),
522
+ maximumUnsuccessfulRecognitionAttemptsPerNode: Math.max(0, ...attemptsByNode.map(attempts => attempts.filter(attempt => attempt.status === 'failed').length)),
523
+ actionAttempts: timeline.filter(item => item.nodeInfo.action_details != null).length,
524
+ actionFailures: ownFailures.filter(failure => failure.kind === 'action_failed').length,
525
+ nextListTimeouts: ownFailures.filter(failure => failure.kind === 'next_list_timeout').length,
526
+ errorImageReferences: errorImages.length,
527
+ uniqueErrorImages: new Set(errorImages).size,
528
+ visionImageReferences: visionImages.length,
529
+ uniqueVisionImages: new Set(visionImages).size,
530
+ },
531
+ directFailureIds,
532
+ outcomeIds,
533
+ signalIds,
534
+ signalHighlights: { recognitionActivity, repetitions: repetitionSignals },
535
+ evidence: {
536
+ start: evidenceAt(evidenceIndex, task.start_time),
537
+ end: evidenceAt(evidenceIndex, task.end_time ?? task.events[task.events.length - 1]?.timestamp),
538
+ },
539
+ };
540
+ };
541
+ const tasks = output.tasks.map(buildTask);
542
+ const sessions = framework.sessions.map((session) => {
543
+ const scoped = tasks.filter(task => executionSessionIds.get(task.executionId) === session.sessionId);
544
+ const ids = new Set(scoped.flatMap(task => task.directFailureIds));
545
+ const scopedFailures = failures.filter(failure => ids.has(failure.failureId));
546
+ return {
547
+ sessionId: session.sessionId,
548
+ startKind: session.startKind,
549
+ frameworkStatus: session.status,
550
+ frameworkVersion: session.version,
551
+ versions: [...session.versions],
552
+ start: session.start,
553
+ end: session.end,
554
+ tasks: scoped,
555
+ summary: {
556
+ taskExecutions: scoped.length,
557
+ succeededTasks: scoped.filter(task => task.status === 'succeeded').length,
558
+ failedTasks: scoped.filter(task => task.status === 'failed').length,
559
+ runningTasks: scoped.filter(task => task.status === 'running').length,
560
+ directFailures: scopedFailures.length,
561
+ nextListTimeouts: scopedFailures.filter(failure => failure.kind === 'next_list_timeout').length,
562
+ actionFailures: scopedFailures.filter(failure => failure.kind === 'action_failed').length,
563
+ signals: scoped.reduce((count, task) => count + task.signalIds.length, 0),
564
+ },
565
+ };
566
+ });
567
+ const unscopedTasks = tasks.filter(task => executionSessionIds.get(task.executionId) == null);
568
+ return {
569
+ schemaVersion: MLA_RUNTIME_INSPECTION_SCHEMA_VERSION,
570
+ sessions,
571
+ unscopedTasks,
572
+ failures,
573
+ outcomes,
574
+ signals,
575
+ warnings: [
576
+ ...framework.warnings,
577
+ ...(unscopedTasks.length
578
+ ? [`${unscopedTasks.length} task execution(s) could not be assigned to one runtime session.`]
579
+ : []),
580
+ ],
581
+ };
582
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windsland52/maa-log-tools",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "bin": {
@@ -42,10 +42,10 @@
42
42
  },
43
43
  "dependencies": {
44
44
  "fflate": "^0.8.2",
45
- "@windsland52/maa-log-kernel": "1.0.0",
46
- "@windsland52/maa-log-parser": "1.0.0",
47
- "@windsland52/maa-log-runtime": "1.0.0",
48
- "@windsland52/maa-log-adapter": "1.0.0"
45
+ "@windsland52/maa-log-adapter": "1.0.1",
46
+ "@windsland52/maa-log-parser": "1.0.1",
47
+ "@windsland52/maa-log-runtime": "1.0.1",
48
+ "@windsland52/maa-log-kernel": "1.0.1"
49
49
  },
50
50
  "engines": {
51
51
  "node": ">=24.0.0"