Setting the file. One moment.
Live Server · N8n:testmax N8n AI · n8n-io/n8n · Skills Docs
ContentsBack to the top of the page — line 203
This file
Number 40.8
Position 8 of 24
Type Python
Size 26 KB
Lines 635 scripts/ live_server.py
Python · 635 lines · 26 KB
16
compare.py writes the status of each builder to <run-dir>/status.json. The server stops
17 30 minutes after the last request, when the run is done or compare.py has stopped.
18
19 Other skills reuse the server: they call serve() with their own app (see BuilderApp).
20 """
21
22 import argparse
23 import json
24 import mimetypes
25 import os
26 import re
27 import threading
28 import time
29 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
30 from pathlib import Path
31 from urllib.parse import unquote, urlparse
32
33 import build_report as br
34 from run_cli_builder import find_session_file
35
36 IDLE_STOP_S = 1800
37 KINDS = { 'iai' : 'instance-ai' , 'claude' : 'claude' , 'codex' : 'codex' }
38 STATUS_LABELS = { 'waiting' : 'Waiting' , 'running' : 'Running' , 'done' : 'Done' , 'failed' : 'Failed' , 'skipped' : 'Skipped' }
39 mimetypes.add_type( 'font/woff2' , '.woff2' )
40 mimetypes.add_type( 'text/javascript' , '.js' )
41 mimetypes.add_type( 'video/webm' , '.webm' )
42
43
44 def read_status (run_dir):
45 try :
46 return json.loads((run_dir / 'status.json' ).read_text())
47 except ( OSError , ValueError ):
48 return { 'phase' : 'running' , 'builders' : {}}
49
50
51 def empty_data (model = None ):
52 return { 'events' : [], 'calls' : [], 'model' : model, 'duration' : '-' , 'cost' : '-' , 'costSource' : None }
53
54
55 def load_instance_ai_live (sub):
56 """Trace events from the event stream that run_instance_ai.py writes while the thread runs."""
57 path = sub / 'events.jsonl'
58 rows = br.read_jsonl(path) if path.exists() else []
59 if not rows:
60 return empty_data()
61 first = next ((r for r in rows if r.get( 'type' ) == 'run-start' ), rows[ 0 ])
62 start, root = first[ 'ts' ] / 1000 , first.get( 'agentId' )
63 events, texts, tools = [], {}, {}
64 for r in rows:
65 kind, p, agent = r.get( 'type' ), r.get( 'payload' ) or {}, r.get( 'agentId' )
66 t = (r.get( 'ts' ) or first[ 'ts' ]) / 1000 - start
67 label = '' if agent in (root, None ) else f ' { agent.split( "-run_" )[ 0 ] } › '
68 if kind in ( 'text-delta' , 'reasoning-delta' ):
69 event_kind = 'text' if kind == 'text-delta' else 'reasoning'
70 key = (agent, r.get( 'responseId' ), event_kind)
71 if key not in texts:
72 texts[key] = { 'kind' : event_kind, 't' : t, 'text' : '' }
73 events.append(texts[key])
74 texts[key][ 'text' ] += p.get( 'text' ) or ''
75 elif kind == 'tool-call' :
76 tools[p.get( 'toolCallId' )] = { 'kind' : 'tool' , 't' : t, 'name' : label + (p.get( 'toolName' ) or '' ),
77 'args' : p.get( 'args' ), 'result' : 'Waiting for the result…' ,
78 'error' : False , 'duration' : None }
79 events.append(tools[p.get( 'toolCallId' )])
80 elif kind in ( 'tool-result' , 'tool-error' ) and p.get( 'toolCallId' ) in tools:
81 result = p.get( 'result' ) if kind == 'tool-result' else p.get( 'error' )
82 tools[p[ 'toolCallId' ]].update(
83 result = result, duration = t - tools[p[ 'toolCallId' ]][ 't' ],
84 error = kind == 'tool-error' or ( isinstance (result, dict ) and (result.get( 'success' ) is False or 'error' in result)))
85 elif kind == 'confirmation-request' and p.get( 'toolCallId' ) in tools:
86 tools[p[ 'toolCallId' ]][ 'note' ] = 'Confirmation card answered by the auto-approve policy.'
87 data = empty_data()
88 data[ 'events' ] = [e for e in events if e[ 'kind' ] == 'tool' or e[ 'text' ].strip()]
89 return data
90
91
92 def live_run (run_dir, name, case):
93 """Meta and trace data of one builder. A finished builder has run.json. A running one does not."""
94 sub = run_dir / name
95 meta_file = sub / 'run.json'
96 if meta_file.exists():
97 meta = json.loads(meta_file.read_text())
98 if meta.get( 'kind' ) in br. LOADERS :
99 return meta, br.load_run(sub, meta, case.get( 'prices' ))
100 meta = { 'kind' : KINDS .get(name, name)}
101 if meta[ 'kind' ] == 'instance-ai' :
102 return meta, load_instance_ai_live(sub)
103 session = find_session_file(meta[ 'kind' ], sub / 'home' ) if (sub / 'home' ).exists() else None
104 if not session:
105 return meta, empty_data()
106 meta[ 'sessionFile' ] = str (session.relative_to(sub))
107 return meta, br.load_run(sub, meta, case.get( 'prices' ))
108
109
110 def seconds (value):
111 return f ' { value :.0f} s'
112
113
114 def gather (run_dir):
115 case, status, verdicts = br.load_case(run_dir), read_status(run_dir), br.load_verdicts(run_dir)
116 builders = status.get( 'builders' , {})
117 names = sorted ((n for n, b in builders.items() if b.get( 'status' ) != 'skipped' ),
118 key =lambda n: (br. RUN_ORDER .get( KINDS .get(n, n), 9 ), n))
119 now, runs = time.time(), []
120 for name in names:
121 entry = builders[name]
122 state = entry.get( 'status' , 'waiting' )
123 meta, data = live_run(run_dir, name, case)
124 running = state in ( 'waiting' , 'running' )
125 if running and data.get( 'loadError' ):
126 # A running builder can leave a file in a state that the loader cannot read yet.
127 data = empty_data(data.get( 'model' ))
128 started, finished = entry.get( 'startedAt' ), entry.get( 'finishedAt' )
129 if started and (running or data[ 'duration' ] == '-' ):
130 data[ 'duration' ] = seconds((finished or now) - started)
131 if running:
132 data[ 'cost' ] = '-'
133 runs.append((name, meta, data, entry))
134 return case, status, verdicts, runs
135
136
137 def status_badge (state):
138 return f '<span class="badge st- { br.esc(state) } "> { STATUS_LABELS .get(state, br.esc(state)) } </span>'
139
140
141 def clock (status):
142 started = status.get( 'startedAt' )
143 elapsed = seconds((status.get( 'finishedAt' ) or time.time()) - started) if started else ''
144 mode = 'Parallel' if status.get( 'mode' ) == 'parallel' else 'One at a time'
145 return ' · ' .join(x for x in (mode, elapsed, status.get( 'step' )) if x)
146
147
148 def render_run (run_dir, case, name, meta, data, entry, verdict, scale):
149 row = br.render_row_cells(name, meta, data, status_badge(entry.get( 'status' , 'waiting' )))
150 canvas = br.render_canvas_box(name, meta, run_dir / 'report' , case.get( 'baseUrl' , 'http://localhost:5678' ),
151 empty = 'The canvas appears when all builders finish.' )
152 view, auto = br.verdict_view(verdict, run_dir / name, case.get( 'expected' ) or '' )
153 return row, br.render_panel_body(data, view, canvas, * scale, case[ 'prompt' ], case.get( 'expected' ) or '' , auto = auto,
154 pending = True )
155
156
157 def render_live_box (status, runs):
158 cards = []
159 for name, meta, _, entry in runs:
160 title = br.esc(br.run_title(name, meta))
161 screen = (status.get( 'screens' ) or {}).get(name)
162 if screen == 'cast' :
163 inner = ( f '<div data-cast=" { name } "></div><button type="button" class="live-zoom overlay" '
164 f 'data-zoom=" { name } " aria-label="Show { title } full screen"></button>' )
165 elif screen == 'shot' :
166 inner = ( f '<button type="button" class="live-zoom" data-zoom=" { name } " aria-label="Show { title } full screen">'
167 f '<img data-shot=" { name } " alt=" { title } in the n8n UI" hidden></button>'
168 f '<div class="live-empty" data-shot-empty=" { name } ">Waiting for the first screenshot</div>' )
169 else :
170 inner = '<div class="live-empty">No live screen for this builder. The LLM steps below update live.</div>'
171 cards.append(
172 f '<div class="live-card"><div class="live-screen"> { inner } </div>'
173 f '<div class="reel-meta"><span class="run-name"> { title } </span>'
174 f '<span data-live-badge=" { name } "> { status_badge(entry.get( "status" , "waiting" )) } </span></div></div>'
175 )
176 return ( f '<div class="box"><div class="head"><h2>Live</h2><span class="mono-label" id="live-clock">'
177 f ' { br.esc(clock(status)) } </span></div><div class="live-grid"> { "" .join(cards) } </div></div>' )
178
179
180 def render_page (run_dir):
181 case, status, verdicts, runs = gather(run_dir)
182 scale = br.token_scale([r[: 3 ] for r in runs])
183 rows, panels = [], []
184 for i, (name, meta, data, entry) in enumerate (runs):
185 active = ' active' if i == 0 else ''
186 row, panel = render_run(run_dir, case, name, meta, data, entry, verdicts.get(name), scale)
187 rows.append( f '<tr class="row { active } " data-run=" { name } " onclick="show( \'{ name }\' )"> { row } </tr>' )
188 panels.append( f '<section class="run { active } " id=" { name } "> { panel } </section>' )
189 try :
190 br.ensure_player_cache()
191 player_js, player_css = ( f 'vendor/ { n } ' for n in br. PLAYER_FILES )
192 except OSError :
193 player_js, player_css = (br. PLAYER_CDN + n for n in br. PLAYER_FILES )
194 notes = ( 'This page updates while the builders run, and changes to the full report when the run is done. '
195 'Costs, canvases, checks and verdicts come after the run. Times are seconds from the start of each builder.' )
196 return br.fill_template(
197 br.esc(case.get( 'title' ) or 'Builder comparison' ), '' .join(rows), br.esc(notes), '' .join(panels),
198 replay = render_live_box(status, runs), head = f '<link rel="stylesheet" href=" { player_css } ">' ,
199 scripts = f '<script src=" { player_js } "></script><script>const SCREENS = [];</script><script> { LIVE_JS } </script>' ,
200 prompt = br.esc(case[ 'prompt' ]), expected = br.esc(case.get( 'expected' ) or '-' ))
201
202
203 def render_state (run_dir):
204 case, status, verdicts, runs = gather(run_dir)
205 scale = br.token_scale([r[: 3 ] for r in runs])
206 state = { 'phase' : status.get( 'phase' , 'running' ), 'clock' : clock(status), 'runs' : {}}
207 if state[ 'phase' ] == 'done' and (run_dir / 'report' / 'index.html' ).exists():
208 state[ 'report' ] = '/report/index.html'
209 for name, meta, data, entry in runs:
210 row, panel = render_run(run_dir, case, name, meta, data, entry, verdicts.get(name), scale)
211 shot = run_dir / name / 'live.png'
212 state[ 'runs' ][name] = { 'row' : row, 'panel' : panel, 'badge' : status_badge(entry.get( 'status' , 'waiting' )),
213 'shot' : shot.stat().st_mtime if shot.exists() else None }
214 return state
215
216
217 LIVE_JS = """
218 window.addEventListener('DOMContentLoaded', ()=>{
219 const seen = {} , shots = {} , userFolds = {} ;
220 // A box that the user opened or closed stays that way when its panel updates. Other boxes take the new default.
221 document.addEventListener('click', e=>{
222 const summary = e.target.closest && e.target.closest('details.fold > summary');
223 if (!summary || e.target.closest('.run-picker')) return;
224 const section = summary.closest('section.run');
225 if (section) (userFolds[section.id] ??= {} )[summary.querySelector('h2').textContent] = !summary.parentElement.open;
226 });
227 document.addEventListener('change', e=>{
228 if (!e.target.matches || !e.target.matches('.run-picker')) return;
229 const steps = document.getElementById(e.target.value)?.querySelector('.steps');
230 if (steps) (userFolds[e.target.value] ??= {} )['LLM steps'] = steps.open;
231 });
232 document.querySelectorAll('[data-cast]').forEach(el=>{
233 AsciinemaPlayer.create({driver: 'eventsource', url: `/api/cast/$ {el.dataset.cast} `, bufferTime: 0}, el,
234 {fit: 'both', autoPlay: true, controls: false});
235 });
236 // A live screen opens larger in a full-screen viewer. The viewer follows the newest screenshot, and a
237 // terminal gets its own player, because the stream starts with the whole recording so far.
238 const zoomNames = [...document.querySelectorAll('[data-zoom]')].map(b=>b.dataset.zoom);
239 const viewer = document.createElement('div');
240 let zoomed = null, opener = null, castPlayer = null, castName = null;
241 viewer.className = 'theater live-theater';
242 viewer.hidden = true;
243 viewer.setAttribute('role', 'dialog');
244 viewer.setAttribute('aria-modal', 'true');
245 viewer.setAttribute('aria-label', 'Live screen');
246 viewer.innerHTML = '<div class="theater-bar"><h2>Live</h2><span data-zoom-badge></span>'
247 + '<button type="button" class="btn" data-zoom-close>Close</button></div><div class="tabs"></div>'
248 + '<div class="live-zoomed"><img alt=""><div class="live-empty">Waiting for the first screenshot</div>'
249 + '<div class="live-zoomed-cast" hidden></div></div>'
250 + '<p class="fineprint">The screen updates every few seconds. Press the left or right arrow key to change the '
251 + 'scenario. Press Esc to close.</p>';
252 for (const name of zoomNames) {
253 const tab = Object.assign(document.createElement('button'), {type: 'button', className: 'tab'});
254 tab.dataset.zoomTab = name;
255 tab.textContent = document.querySelector(`[data-zoom="$ {name} "]`).closest('.live-card').querySelector('.run-name').textContent;
256 tab.addEventListener('click', ()=>showZoom(name));
257 viewer.querySelector('.tabs').append(tab);
258 }
259 document.body.append(viewer);
260 function stopCast(){
261 castPlayer?.dispose();
262 castPlayer = castName = null;
263 viewer.querySelector('.live-zoomed-cast').replaceChildren();
264 }
265 function showZoom(name){
266 zoomed = name;
267 viewer.querySelectorAll('[data-zoom-tab]').forEach(t=>t.classList.toggle('active', t.dataset.zoomTab === name));
268 const shot = document.querySelector(`img[data-shot="$ {name} "]`);
269 const isCast = !!document.querySelector(`[data-cast="$ {name} "]`);
270 const img = viewer.querySelector('.live-zoomed img'), castBox = viewer.querySelector('.live-zoomed-cast');
271 if (castName !== name) stopCast();
272 castBox.hidden = !isCast;
273 if (isCast && !castPlayer) {
274 castName = name;
275 castPlayer = AsciinemaPlayer.create({driver: 'eventsource', url: `/api/cast/$ {name} `, bufferTime: 0}, castBox,
276 {fit: 'both', autoPlay: true, controls: false});
277 }
278 const shotHidden = !shot || shot.hidden;
279 img.hidden = isCast || shotHidden;
280 if (shot && !shot.hidden && img.getAttribute('src') !== shot.getAttribute('src')) { img.src = shot.src; img.alt = shot.alt; }
281 viewer.querySelector('.live-zoomed .live-empty').hidden = isCast || !shotHidden;
282 viewer.querySelector('[data-zoom-badge]').innerHTML = document.querySelector(`[data-live-badge="${name}"]`)?.innerHTML || '';
283 }
284 function closeZoom(){
285 stopCast();
286 viewer.hidden = true;
287 zoomed = null;
288 document.body.classList.remove('theater-open');
289 opener?.focus();
290 }
291 document.querySelectorAll('[data-zoom]').forEach(b=>b.addEventListener('click', ()=>{
292 opener = b;
293 viewer.hidden = false;
294 document.body.classList.add('theater-open');
295 showZoom(b.dataset.zoom);
296 viewer.querySelector('[data-zoom-close]').focus();
297 }));
298 viewer.querySelector('[data-zoom-close]').addEventListener('click', closeZoom);
299 document.addEventListener('keydown', e=>{
300 if (zoomed === null) return;
301 if (e.key === 'Escape') closeZoom();
302 else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
303 const step = e.key === 'ArrowRight' ? 1 : -1;
304 showZoom(zoomNames[(zoomNames.indexOf(zoomed) + step + zoomNames.length) % zoomNames.length]);
305 }
306 });
307 function selectQuietly(root, step){
308 root.querySelectorAll('.step').forEach(b=>b.classList.toggle('active', b.dataset.step === step));
309 root.querySelectorAll('.step-detail').forEach(d=>{ d.hidden = d.dataset.step !== step; });
310 }
311 // Keeps the selected step, the scroll positions and the open sections. When the last step is
312 // selected, the newest step is selected after the update.
313 function swapPanel(section, html, first){
314 const old = section.querySelector('.steps');
315 const items = old ? [...old.querySelectorAll('.step')] : [];
316 const active = items.find(b=>b.classList.contains('active'));
317 const follow = first || !active || active === items[items.length - 1];
318 // A run with no trace yet has a .steps section without the list and the pane.
319 const listTop = old?.querySelector('.step-list')?.scrollTop ?? 0;
320 const paneTop = old?.querySelector('.step-pane')?.scrollTop ?? 0;
321 const openState = {};
322 section.querySelectorAll('.step-detail').forEach(d=>d.querySelectorAll('details').forEach((x, i)=>{ openState[`${d.dataset.step}:${i}`] = x.open; }));
323 section.innerHTML = html;
324 section.querySelectorAll('details.fold').forEach(d=>{
325 const open = (userFolds[section.id] || {})[d.querySelector('summary h2')?.textContent];
326 if (open !== undefined) d.open = open;
327 });
328 addRunPickers();
329 section.querySelectorAll('.step-detail').forEach(d=>d.querySelectorAll('details').forEach((x, i)=>{
330 const key = `${d.dataset.step}:${i}`;
331 if (key in openState) x.open = openState[key];
332 }));
333 const root = section.querySelector('.steps');
334 const next = root ? [...root.querySelectorAll('.step')] : [];
335 if (!next.length) return;
336 const list = root.querySelector('.step-list'), pane = root.querySelector('.step-pane');
337 if (!list || !pane) return;
338 if (follow) {
339 selectQuietly(root, next[next.length - 1].dataset.step);
340 list.scrollTop = list.scrollHeight;
341 } else {
342 selectQuietly(root, active.dataset.step);
343 list.scrollTop = listTop;
344 pane.scrollTop = paneTop;
345 }
346 }
347 async function poll(){
348 let state;
349 try {
350 state = await (await fetch('/api/state', {cache: 'no-store'})).json();
351 } catch {
352 $('live-clock').textContent = 'Lost the connection to the live server. Trying again…';
353 setTimeout(poll, 3000);
354 return;
355 }
356 if (state.report) { location.replace(state.report); return; }
357 $('live-clock').textContent = state.clock;
358 for (const [name, run] of Object.entries(state.runs)) {
359 const row = document.querySelector(`.row[data-run="${name}"]`);
360 if (row && seen[`row:${name}`] !== run.row) { row.innerHTML = run.row; seen[`row:${name}`] = run.row; }
361 const section = document.getElementById(name);
362 if (section && seen[`panel:${name}`] !== run.panel) {
363 swapPanel(section, run.panel, seen[`panel:${name}`] === undefined);
364 seen[`panel:${name}`] = run.panel;
365 }
366 const badge = document.querySelector(`[data-live-badge="${name}"]`);
367 if (badge) badge.innerHTML = run.badge;
368 const img = document.querySelector(`img[data-shot="${name}"]`);
369 if (img && run.shot && shots[name] !== run.shot) {
370 shots[name] = run.shot;
371 img.src = `/api/shot/${name}?v=${run.shot}`;
372 img.hidden = false;
373 document.querySelector(`[data-shot-empty="${name}"]`)?.remove();
374 }
375 }
376 if (zoomed !== null) showZoom(zoomed);
377 setTimeout(poll, 2000);
378 }
379 poll();
380 });
381 """
382
383
384 class BuilderApp :
385 """What the server shows for compare.py: one live card, table row and panel for each builder.
386
387 Another skill can serve its own page with the same server: give LiveServer an object with
388 these methods."""
389
390 def __init__ (self, run_dir):
391 self .run_dir = run_dir
392
393 def entry (self, name):
394 """The status.json entry of a live card, or None for an unknown name."""
395 return read_status( self .run_dir).get( 'builders' , {}).get(name)
396
397 def finished (self, name):
398 state = ( self .entry(name) or {}).get( 'status' )
399 return state in ( 'done' , 'failed' , 'skipped' ) or ( self .run_dir / name / 'run.json' ).exists()
400
401 def done (self):
402 return read_status( self .run_dir).get( 'phase' ) == 'done' and ( self .run_dir / 'report' / 'index.html' ).exists()
403
404 def page (self):
405 return render_page( self .run_dir)
406
407 def state (self):
408 return render_state( self .run_dir)
409
410 def shot (self, name):
411 return self .run_dir / name / 'live.png'
412
413 def casts (self, name):
414 """The live recording, then the place where the runner moves it when the builder stops."""
415 sub = self .run_dir / name
416 return sub / '.screen' / 'screen.cast' , sub / 'screen.cast'
417
418 def file (self, path):
419 """A file for a route that the server does not know, or None."""
420 return None
421
422
423 class LiveServer ( ThreadingHTTPServer ):
424 daemon_threads = True
425
426 def __init__ (self, address, app):
427 super (). __init__ (address, Handler)
428 port = self .server_address[ 1 ]
429 self .app, self .run_dir = app, app.run_dir
430 self .allowed_hosts = { f '127.0.0.1: { port } ' , f 'localhost: { port } ' }
431 self .last_request = time.time()
432
433
434 class Handler ( BaseHTTPRequestHandler ):
435 server_version = 'cwb-live'
436
437 def log_message (self, * args):
438 pass
439
440 def do_GET (self):
441 srv = self .server
442 srv.last_request = time.time()
443 # Only same-origin requests. A page that resolves its own host name to 127.0.0.1 gets a 403.
444 if self .headers.get( 'Host' , '' ) not in srv.allowed_hosts:
445 return self .send_error( 403 )
446 path = unquote(urlparse( self .path).path)
447 app = srv.app
448 try :
449 if path in ( '/' , '/report' , '/report/' ):
450 return self .redirect( '/report/index.html' if app.done() else '/report/live' )
451 if path == '/report/live' :
452 return self .send_body(app.page().encode(), 'text/html; charset=utf-8' )
453 if path == '/api/state' :
454 return self .send_body(json.dumps(app.state()).encode(), 'application/json' )
455 name = path.rsplit( '/' , 1 )[ - 1 ]
456 if path.startswith(( '/api/cast/' , '/api/shot/' )) and not app.entry(name):
457 return self .send_error( 404 )
458 if path.startswith( '/api/cast/' ):
459 return self .stream_cast(name)
460 if path.startswith( '/api/shot/' ):
461 return self .send_file(app.shot(name))
462 if path.startswith( '/report/' ):
463 return self .send_file( self .static_file(path.removeprefix( '/report/' )))
464 self .send_file(app.file(path))
465 except ( BrokenPipeError , ConnectionResetError ):
466 pass
467
468 def redirect (self, location):
469 self .send_response( 302 )
470 self .send_header( 'Location' , location)
471 self .send_header( 'Content-Length' , '0' )
472 self .end_headers()
473
474 def send_body (self, body, content_type):
475 self .send_response( 200 )
476 self .send_header( 'Content-Type' , content_type)
477 self .send_header( 'Content-Length' , str ( len (body)))
478 self .send_header( 'Cache-Control' , 'no-store' )
479 self .end_headers()
480 self .wfile.write(body)
481
482 def static_file (self, rel):
483 """A file of the report. Fonts, logos and the player come from the asset cache until the report exists."""
484 base = ( self .server.run_dir / 'report' ).resolve()
485 candidate = (base / rel).resolve()
486 if candidate.is_relative_to(base) and candidate.is_file():
487 return candidate
488 parts = Path(rel).parts
489 if parts[: 1 ] in (( 'fonts' ,), ( 'brand' ,)):
490 cache = br. ASSET_CACHE .resolve()
491 candidate = (cache / rel).resolve()
492 return candidate if candidate.is_relative_to(cache) and candidate.is_file() else None
493 if len (parts) == 2 and parts[ 0 ] == 'vendor' and parts[ 1 ] in br. PLAYER_FILES :
494 return br. PLAYER_CACHE / parts[ 1 ]
495 return None
496
497 def send_file (self, file):
498 if not file or not file .is_file():
499 return self .send_error( 404 )
500 size = file .stat().st_size
501 start, end = 0 , size - 1
502 match = re.fullmatch( r 'bytes= (\d * ) - (\d * ) ' , self .headers.get( 'Range' ) or '' )
503 if match and size and (match[ 1 ] or match[ 2 ]):
504 # Videos need byte ranges to seek.
505 if match[ 1 ]:
506 start, end = int (match[ 1 ]), int (match[ 2 ]) if match[ 2 ] else size - 1
507 else :
508 start = max (size - int (match[ 2 ]), 0 )
509 end = min (end, size - 1 )
510 if start > end:
511 self .send_response( 416 )
512 self .send_header( 'Content-Range' , f 'bytes */ { size } ' )
513 self .end_headers()
514 return
515 self .send_response( 206 )
516 self .send_header( 'Content-Range' , f 'bytes { start } - { end } / { size } ' )
517 else :
518 self .send_response( 200 )
519 self .send_header( 'Content-Type' , mimetypes.guess_type( file .name)[ 0 ] or 'application/octet-stream' )
520 self .send_header( 'Content-Length' , str (end - start + 1 ))
521 self .send_header( 'Accept-Ranges' , 'bytes' )
522 self .send_header( 'Cache-Control' , 'no-cache' )
523 self .end_headers()
524 with file .open( 'rb' ) as f:
525 f.seek(start)
526 left = end - start + 1
527 while left > 0 :
528 chunk = f.read( min (left, 1 << 16 ))
529 if not chunk:
530 break
531 self .wfile.write(chunk)
532 left -= len (chunk)
533
534 def sse (self, data, event = None ):
535 self .wfile.write((( f 'event: { event }\n ' if event else '' ) + f 'data: { data }\n\n ' ).encode())
536 self .wfile.flush()
537
538 def stream_cast (self, name):
539 """Send the screen recording so far as one reset frame, then each new frame as it arrives."""
540 srv = self .server
541 self .send_response( 200 )
542 self .send_header( 'Content-Type' , 'text/event-stream' )
543 self .send_header( 'Cache-Control' , 'no-store' )
544 self .end_headers()
545 path = None
546 while path is None :
547 path = next ((p for p in srv.app.casts(name) if p.exists()), None )
548 if path is None :
549 if srv.app.finished(name):
550 return self .sse( ' {} ' , 'done' )
551 self .wfile.write( b ': waiting \n\n ' )
552 self .wfile.flush()
553 srv.last_request = time.time()
554 time.sleep( 1 )
555 # The runner moves the recording when the builder stops. The open file stays readable.
556 with path.open( 'rb' ) as f:
557 buffer = b ''
558
559 def complete_lines ():
560 nonlocal buffer
561 buffer += f.read()
562 * lines, buffer = buffer.split( b ' \n ' )
563 return [line for line in lines if line.strip()]
564
565 lines = complete_lines()
566 while not lines:
567 if srv.app.finished(name):
568 return self .sse( ' {} ' , 'done' )
569 time.sleep( 0.2 )
570 lines = complete_lines()
571 header, frames = json.loads(lines[ 0 ]), [json.loads(line) for line in lines[ 1 :]]
572 output = [f for f in frames if f[ 1 ] == 'o' ]
573 self .sse(json.dumps({ 'cols' : header.get( 'width' ), 'rows' : header.get( 'height' ),
574 'time' : output[ - 1 ][ 0 ] if output else 0 , 'init' : '' .join(f[ 2 ] for f in output)}))
575 last_data = last_ping = time.time()
576 while True :
577 lines = complete_lines()
578 for line in lines:
579 frame = json.loads(line)
580 if frame[ 1 ] == 'o' :
581 self .sse(json.dumps([frame[ 0 ], 'o' , frame[ 2 ]]))
582 now = time.time()
583 srv.last_request = now
584 if lines:
585 last_data = now
586 continue
587 if srv.app.finished(name) and now - last_data > 3 :
588 return self .sse( ' {} ' , 'done' )
589 if now - last_ping > 15 :
590 self .wfile.write( b ': ping \n\n ' )
591 self .wfile.flush()
592 last_ping = now
593 time.sleep( 0.2 )
594
595
596 def pid_alive (pid):
597 try :
598 os.kill(pid, 0 )
599 except ProcessLookupError :
600 return False
601 except PermissionError :
602 return True
603 return True
604
605
606 def stop_when_idle (server, parent_pid):
607 while True :
608 time.sleep( 30 )
609 if time.time() - server.last_request < IDLE_STOP_S :
610 continue
611 done = read_status(server.run_dir).get( 'phase' ) == 'done'
612 if done or (parent_pid and not pid_alive(parent_pid)):
613 server.shutdown()
614 return
615
616
617 def serve (app, parent_pid = None , port = 0 ):
618 """Serve app until it is idle. Writes the address to <run-dir>/live-url.txt."""
619 server = LiveServer(( '127.0.0.1' , port), app)
620 (app.run_dir / 'live-url.txt' ).write_text( f 'http://127.0.0.1: { server.server_address[ 1 ] }\n ' )
621 threading.Thread( target = stop_when_idle, args = (server, parent_pid), daemon = True ).start()
622 server.serve_forever()
623
624
625 def main ():
626 ap = argparse.ArgumentParser()
627 ap.add_argument( '--run-dir' , required = True )
628 ap.add_argument( '--parent-pid' , type = int )
629 ap.add_argument( '--port' , type = int , default = 0 )
630 args = ap.parse_args()
631 serve(BuilderApp(Path(args.run_dir).resolve()), args.parent_pid, args.port)
632
633
634 if __name__ == '__main__' :
635 main()