Setting the file. One moment.
Compare · N8n:testmax N8n AI · n8n-io/n8n · Skills Docs
ContentsBack to the top of the page
40.5 scripts/ compare.py
Python · 328 lines · 14 KB
shows the runs live (see live_server.py).
15 Settings come from .env.ai-surfaces (template: .env.ai-surfaces.example in the repository root).
16 """
17
18 import argparse
19 import json
20 import os
21 import re
22 import shutil
23 import subprocess
24 import sys
25 import threading
26 import time
27 import webbrowser
28 from datetime import datetime
29 from pathlib import Path
30
31 from live import enabled as live_enabled
32 from live import is_on, recording
33 from n8n_api import setting
34
35 HERE = Path( __file__ ).resolve().parent
36 RUNNERS = { 'iai' , 'claude' , 'codex' }
37 PRINT_LOCK = threading.Lock()
38
39
40 def slug (text):
41 words = re.findall( r ' [ a-z0-9 ] + ' , text.lower())[: 5 ]
42 return '-' .join(words) or 'run'
43
44
45 def step (title):
46 with PRINT_LOCK :
47 print ( f ' \n === { title } ===' , flush = True )
48
49
50 def run (cmd, log = None , prefix = None ):
51 """Run a script, show its output, and keep a copy in log. Returns the exit code."""
52 with subprocess.Popen(cmd, stdout = subprocess. PIPE , stderr = subprocess. STDOUT , text = True , cwd = HERE ) as proc:
53 lines = []
54 for line in proc.stdout:
55 shown = line if not prefix or line.startswith( f '[ { prefix } ]' ) else f '[ { prefix } ] { line } '
56 with PRINT_LOCK :
57 print (shown, end = '' , flush = True )
58 lines.append(line)
59 if log:
60 Path(log).write_text( '' .join(lines))
61 return proc.returncode
62
63
64 class Progress :
65 """Writes status.json, which the live page reads."""
66
67 def __init__ (self, run_dir, mode, builders, skipped, screens):
68 self .path, self .lock = run_dir / 'status.json' , threading.Lock()
69 self .data = {
70 'mode' : mode, 'phase' : 'running' , 'step' : 'Running the builders' , 'startedAt' : time.time(),
71 'screens' : screens,
72 'builders' : { ** {b: { 'status' : 'waiting' } for b in builders},
73 ** {b: { 'status' : 'skipped' , 'reason' : r} for b, r in skipped.items()}},
74 }
75 self .save()
76
77 def save (self):
78 tmp = self .path.with_suffix( '.tmp' )
79 tmp.write_text(json.dumps( self .data, indent = 2 ))
80 os.replace(tmp, self .path)
81
82 def update (self, ** fields):
83 with self .lock:
84 self .data.update(fields)
85 self .save()
86
87 def builder (self, name, ** fields):
88 with self .lock:
89 self .data[ 'builders' ][name].update(fields)
90 self .save()
91
92 def finish (self):
93 with self .lock:
94 for entry in self .data[ 'builders' ].values():
95 if entry[ 'status' ] in ( 'waiting' , 'running' ):
96 entry[ 'status' ] = 'failed'
97 self .data.update( phase = 'done' , step = 'Done' , finishedAt = time.time())
98 self .save()
99
100
101 def start_live_server (run_dir):
102 """Start live_server.py in the background. Returns its URL, or None."""
103 url_file = run_dir / 'live-url.txt'
104 url_file.unlink( missing_ok = True )
105 log = (run_dir / 'live-server.log' ).open( 'w' )
106 # A new session, so the page still serves the final report after this script ends.
107 subprocess.Popen([sys.executable, str ( HERE / 'live_server.py' ), '--run-dir' , str (run_dir),
108 '--parent-pid' , str (os.getpid())],
109 cwd = HERE , stdin = subprocess. DEVNULL , stdout = log, stderr = log, start_new_session = True )
110 for _ in range ( 50 ):
111 if url_file.exists() and url_file.read_text().strip():
112 return url_file.read_text().strip()
113 time.sleep( 0.1 )
114 return None
115
116
117 def main ():
118 ap = argparse.ArgumentParser()
119 ap.add_argument( '--prompt' )
120 ap.add_argument( '--prompt-file' )
121 ap.add_argument( '--expected' )
122 ap.add_argument( '--expected-file' )
123 ap.add_argument( '--title' )
124 ap.add_argument( '--builders' , default = setting( 'COMPARE_BUILDERS' , default = 'iai,claude,codex' ))
125 ap.add_argument( '--run-dir' )
126 ap.add_argument( '--scenarios-file' , help = 'Pinned-data scenarios for run_scenarios.py (see SKILL.md).' )
127 ap.add_argument( '--rerun' , help = 'Copy prompt.txt, case.json and scenarios.json from an earlier run directory.' )
128 ap.add_argument( '--no-live' , action = 'store_true' , help = 'Do not start the live page.' )
129 ap.add_argument( '--sequential' , action = 'store_true' , default =not is_on( 'COMPARE_PARALLEL' ),
130 help = 'Run the builders one at a time. Default: COMPARE_PARALLEL=1 runs them at the same time.' )
131 args = ap.parse_args()
132 if args.no_live:
133 os.environ[ 'COMPARE_LIVE' ] = '0'
134
135 if args.rerun:
136 old = Path(args.rerun)
137 prompt = (old / 'prompt.txt' ).read_text().strip()
138 case = json.loads((old / 'case.json' ).read_text())
139 else :
140 prompt = args.prompt or (Path(args.prompt_file).read_text().strip() if args.prompt_file else None )
141 expected = args.expected or (Path(args.expected_file).read_text().strip() if args.expected_file else None )
142 if not prompt or not expected:
143 sys.exit( 'Give a prompt and an expected outcome (--prompt/--prompt-file and --expected/--expected-file).' )
144 case = { 'title' : args.title or prompt[: 60 ], 'expected' : expected}
145 builders = [b.strip() for b in args.builders.split( ',' ) if b.strip()]
146 unknown = set (builders) - RUNNERS
147 if unknown:
148 sys.exit( f 'Unknown builders: { ", " .join( sorted (unknown)) } . Use iai, claude, codex.' )
149
150 root = Path(setting( 'COMPARE_RUNS_DIR' , default = '/tmp/builder-compare' ))
151 # The child scripts run from HERE, so a relative path must not reach them.
152 run_dir = (Path(args.run_dir) if args.run_dir else root / f ' { datetime.now() : % Y % m % d- % H % M} - { slug(case[ "title" ]) } ' ).resolve()
153 run_dir.mkdir( parents = True , exist_ok = True )
154 (run_dir / 'prompt.txt' ).write_text(prompt + ' \n ' )
155 (run_dir / 'case.json' ).write_text(json.dumps(case, indent = 2 ))
156 scenarios = Path(args.scenarios_file) if args.scenarios_file else (Path(args.rerun) / 'scenarios.json' if args.rerun else None )
157 if scenarios and scenarios.exists():
158 json.loads(scenarios.read_text())
159 shutil.copy(scenarios, run_dir / 'scenarios.json' )
160 elif args.scenarios_file:
161 sys.exit( f 'No scenarios file at { scenarios } .' )
162 print ( f 'run directory: { run_dir } ' , flush = True )
163
164 step( 'Check the setup' )
165 check = subprocess.run([sys.executable, 'prepare.py' ], cwd = HERE , capture_output = True , text = True )
166 print (check.stdout, end = '' )
167 try :
168 status = json.loads(check.stdout)
169 except json.JSONDecodeError:
170 sys.exit(check.stderr or 'prepare.py failed' )
171 if not status.get( 'login' ):
172 sys.exit( 'Cannot log in to n8n. Is it running? Check N8N_BASE_URL, N8N_EVAL_EMAIL and N8N_EVAL_PASSWORD.' )
173 isolation = setting( 'COMPARE_ISOLATION' , default = 'container' )
174 skipped = {}
175 if 'iai' in builders and not status.get( 'instanceAiEnabled' ):
176 skipped[ 'iai' ] = 'Instance AI is not enabled on this instance.'
177 for cli in ( 'claude' , 'codex' ):
178 if cli in builders and not status.get( 'mcpTokenWorks' ):
179 skipped[cli] = status.get( 'mcpTokenHelp' , 'The MCP token does not work.' )
180 elif cli in builders and not status[ 'keys' ].get(cli):
181 skipped[cli] = f 'No API key. Set { cli.upper() } _API_KEY in .env.ai-surfaces.'
182 if isolation == 'container' and not status.get( 'docker' ):
183 skipped.setdefault( 'codex' , 'Docker is not running. Codex runs only in a container.' )
184 if 'claude' in builders and 'claude' not in skipped:
185 print ( 'Docker is not running. Claude falls back to clean-process isolation.' , flush = True )
186 isolation = 'clean'
187 if isolation == 'clean' :
188 skipped.setdefault( 'codex' , 'Codex runs only in a container. Clean isolation is for Claude only.' )
189 if not status.get( 'claudeOnHost' ):
190 skipped.setdefault( 'claude' , 'Clean isolation runs claude on the host, and claude is not on PATH.' )
191 skipped = {name: reason for name, reason in skipped.items() if name in builders}
192 for name, reason in skipped.items():
193 print ( f 'skip { name } : { reason } ' , flush = True )
194 builders = [b for b in builders if b not in skipped]
195 parallel = not args.sequential and len (builders) > 1
196
197 cli_mode = setting( 'COMPARE_CLI_MODE' , default = 'interactive' )
198 screens = {b: ( 'shot' if recording() else None ) if b == 'iai'
199 else ( 'cast' if isolation == 'container' and cli_mode == 'interactive' else None ) for b in builders}
200 progress = Progress(run_dir, 'parallel' if parallel else 'sequential' , builders, skipped, screens)
201 live_url = None
202 if live_enabled() and not args.no_live:
203 live_url = start_live_server(run_dir)
204 if live_url:
205 print ( f 'live page: { live_url } /report/live' , flush = True )
206 webbrowser.open( f ' { live_url } /report/live' )
207 else :
208 print ( f 'the live page did not start, see { run_dir / "live-server.log" } ' , flush = True )
209
210 results = {}
211 try :
212 if parallel and isolation == 'container' and { 'claude' , 'codex' } & set (builders):
213 # Both CLI runners need the image. Build it once before they start.
214 from run_cli_builder import ensure_image
215 ensure_image( False )
216
217 def run_builder (builder):
218 progress.builder(builder, status = 'running' , startedAt = time.time())
219 started = time.time()
220 if builder == 'iai' :
221 cmd = [sys.executable, 'run_instance_ai.py' , '--out' , str (run_dir / 'iai' ),
222 '--prompt-file' , str (run_dir / 'prompt.txt' )]
223 else :
224 cmd = [sys.executable, 'run_cli_builder.py' , '--builder' , builder, '--out' , str (run_dir / builder),
225 '--prompt-file' , str (run_dir / 'prompt.txt' ), '--isolation' , isolation]
226 code = run(cmd, run_dir / f ' { builder } .log' , prefix = builder if parallel else None )
227 # run_cli_builder.py exits 0 when the CLI fails. run.json has the exit code of the CLI.
228 meta_file = run_dir / builder / 'run.json'
229 if code == 0 and meta_file.exists():
230 code = json.loads(meta_file.read_text()).get( 'exitCode' ) or 0
231 results[builder] = { 'exitCode' : code, 'seconds' : round (time.time() - started)}
232 progress.builder(builder, status = 'done' if code == 0 else 'failed' , finishedAt = time.time(), exitCode = code)
233
234 if parallel:
235 step( f 'Run { ", " .join(builders) } at the same time' )
236 threads = [threading.Thread( target = run_builder, args = (b,)) for b in builders]
237 for t in threads:
238 t.start()
239 for t in threads:
240 t.join()
241 else :
242 for builder in builders:
243 step( f 'Run { builder } ' )
244 run_builder(builder)
245
246 if 'iai' in builders and (run_dir / 'iai' / 'run.json' ).exists():
247 step( 'Fetch Instance AI usage from LangSmith' )
248 progress.update( step = 'Fetching Instance AI usage' )
249 if status[ 'keys' ].get( 'langsmith' ):
250 # LangSmith receives traces with a delay.
251 finished = progress.data[ 'builders' ][ 'iai' ].get( 'finishedAt' ) or time.time()
252 time.sleep( max ( 0.0 , 30 - (time.time() - finished)))
253 run([sys.executable, 'langsmith_usage.py' , '--run-dir' , str (run_dir / 'iai' )])
254 else :
255 print ( 'LANGSMITH_API_KEY is not set. The report has no Instance AI cost or tokens.' , flush = True )
256
257 step( 'Capture the canvases' )
258 progress.update( step = 'Capturing the canvases' )
259 node = shutil.which( 'node' )
260 if node:
261 run([node, 'capture_canvas.mjs' , str (run_dir)])
262 else :
263 print ( 'node is not on PATH. No canvases.' , flush = True )
264
265 if not is_on( 'COMPARE_SCENARIOS' ):
266 print ( 'COMPARE_SCENARIOS=0. The workflows do not run with pinned data.' , flush = True )
267 elif not node or not status.get( 'mcpTokenWorks' ):
268 print ( 'Pinned-data runs need node and a working MCP token. Skipped.' , flush = True )
269 else :
270 step( 'Run each workflow with pinned data' )
271 progress.update( step = 'Running the workflows with pinned data' )
272 run([sys.executable, 'run_scenarios.py' , str (run_dir)], run_dir / 'scenarios.log' )
273
274 if node and is_on( 'COMPARE_WIRE' ):
275 step( 'Run each workflow in process with recorded API requests' )
276 progress.update( step = 'Running the workflows with recorded API requests' )
277 run([node, 'run_wire.mjs' , str (run_dir)], run_dir / 'wire.log' )
278 elif node:
279 print ( 'COMPARE_WIRE=0. No in-process runs with recorded API requests.' , flush = True )
280
281 pnpm = shutil.which( 'pnpm' )
282 if pnpm and is_on( 'COMPARE_BINARY_CHECKS' ):
283 step( 'Run the eval binary checks' )
284 progress.update( step = 'Running the eval binary checks' )
285 run([pnpm, '--filter' , '@n8n/instance-ai' , 'exec' , 'tsx' , str ( HERE / 'binary_checks.mts' ), str (run_dir)],
286 run_dir / 'binary-checks.log' )
287
288 step( 'Check the final-message claims' )
289 run([sys.executable, 'claims.py' , str (run_dir)])
290
291 if node:
292 step( 'Derive the deterministic checks' )
293 progress.update( step = 'Deriving the deterministic checks' )
294 run([node, 'derive_checks.mjs' , str (run_dir)])
295
296 step( 'Summarize the runs for the judges' )
297 summary = subprocess.run([sys.executable, 'summarize_runs.py' , str (run_dir)], cwd = HERE ,
298 capture_output = True , text = True )
299 (run_dir / 'summary.txt' ).write_text(summary.stdout)
300 run([sys.executable, 'summarize_runs.py' , str (run_dir), '--blind' ])
301
302 step( 'Build the report' )
303 # The agent does the price and judge steps after this script. The page shows them as queued until then.
304 progress.update( step = 'Building the report' , postRun = { 'prices' : 'queued' , 'judge' : 'queued' })
305 run([sys.executable, 'build_report.py' , str (run_dir)])
306 finally :
307 progress.finish()
308
309 models_file = run_dir / 'models.json'
310 print (json.dumps({
311 'runDir' : str (run_dir),
312 'report' : str (run_dir / 'report' / 'index.html' ),
313 'live' : f ' { live_url } /report/index.html' if live_url else None ,
314 'parallel' : parallel,
315 'summary' : str (run_dir / 'summary.txt' ),
316 'blindSummary' : str (run_dir / 'judge' / 'summary-blind.txt' ),
317 'models' : json.loads(models_file.read_text()) if models_file.exists() else [],
318 'results' : results,
319 'skipped' : skipped,
320 'next' : 'Run build_report.py --step prices=running, add published prices for the CLI models to case.json, '
321 'run build_report.py --step prices=done --step judge=running, give judge/summary-blind.txt to '
322 '3 judges with a fresh context, write judge/verdicts-1.json to verdicts-3.json, run '
323 'merge_verdicts.py, then run build_report.py --step judge=done.' ,
324 }, indent = 2 ))
325
326
327 if __name__ == '__main__' :
328 main()