File size: 24,711 Bytes
24cc077
2031f77
0e86155
2031f77
95a1fbc
0e86155
1096ae8
2031f77
 
e49e31a
2031f77
6c60ea0
2031f77
ed1475b
a6d3251
e49e31a
76e0bfa
a6e1920
6c60ea0
 
 
 
 
95a1fbc
2031f77
6c60ea0
95a1fbc
6c60ea0
2031f77
 
a6e1920
3315dab
76e0bfa
3315dab
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0e86155
 
 
3315dab
 
 
 
0e86155
3315dab
0e86155
 
6c60ea0
 
 
 
 
 
 
 
 
 
3315dab
 
 
 
6c60ea0
 
 
 
3315dab
 
 
 
 
 
0e86155
 
 
 
6c60ea0
 
 
3315dab
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0e86155
 
 
 
 
 
 
6c60ea0
 
 
be1eee5
 
6c60ea0
 
 
 
 
 
 
 
 
 
0e86155
 
 
 
 
 
 
6c60ea0
0e86155
 
6c60ea0
0e86155
 
 
 
6c60ea0
0e86155
 
 
6c60ea0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0e86155
3315dab
 
 
 
 
6c60ea0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76e0bfa
 
 
a6e1920
 
 
 
 
 
 
 
 
 
 
5a037eb
a6e1920
 
 
 
 
 
 
 
 
 
 
5a037eb
 
 
54a5f89
 
 
 
 
 
 
 
a6e1920
 
76e0bfa
 
 
 
 
 
 
 
623f6f8
857589b
623f6f8
 
e49e31a
 
 
 
 
 
 
2031f77
 
ed1475b
2031f77
1d0ef9e
 
 
 
857589b
1d0ef9e
857589b
b4691b7
 
2031f77
 
ed1475b
 
2031f77
 
2a138ca
 
2031f77
 
 
 
 
 
 
 
 
 
 
 
 
623f6f8
2031f77
 
 
 
 
a6d3251
 
2031f77
 
 
a6d3251
2031f77
6c60ea0
 
 
2031f77
6c60ea0
2031f77
 
1096ae8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2031f77
 
 
95a1fbc
 
2031f77
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
"""Shared CPU routes and delivery API for hosted ALD training."""
import base64
import hashlib
import json
from pathlib import Path
import re
import subprocess
import threading
from fastapi import HTTPException, Request
from fastapi.responses import FileResponse, Response, HTMLResponse, JSONResponse
from starlette.concurrency import run_in_threadpool
from delivery import create_api, CapacityExceeded, InvalidAnalysisOutput, publish_worker_result
from worker import run_reported
from build_info import build_identity
from training_config import validate_config
from hf_identity import oauth_config
from builtin_tracks import TrackLibrary
from steam_leaderboard import compare_public_best_lap, milliseconds, SteamUnavailable
from bucket_storage import (BucketStorageError, MAX_REQUEST as BUCKET_MAX_REQUEST,
    MAX_RECIPE_LIBRARY, run_bucket_operation, valid_bucket_id, valid_bundle_key,
    valid_recipe_key, validate_recipe_library, AUTOSAVE_APP_KEY, winner_sidecar_filename)
from portable_bundle import (BundleError, load_bundle, MAX_BUNDLE, MAX_NETWORK,
    MAX_ANALYSIS_ARCHIVE, MAX_ANALYSIS_FILE_BYTES)

ROOT=Path(__file__).resolve().parent
EVOLUTION_SIDECAR=re.compile(r'^[a-z0-9][a-z0-9-]{0,63}-(formula|rally|snowmobile|truck)-[0-9]{8}T[0-9]{6}Z-[0-9a-f]{8}(?:-(fitness-winner|fastest-lap-winner|clean-laps-winner))?\.evolution$')

def create_app(worker=run_reported,store=None,gpu_worker=None,track_library=None,require_analysis=False):
    app=create_api(store)
    admission=threading.BoundedSemaphore(1)
    leaderboard_admission=threading.BoundedSemaphore(1)
    bucket_admission=threading.BoundedSemaphore(1)
    library=track_library if track_library is not None else TrackLibrary(ROOT/'builtin_tracks')

    @app.middleware('http')
    async def bucket_no_store(request:Request, call_next):
        response=await call_next(request)
        if request.url.path.startswith('/api/bucket/'):
            response.headers['Cache-Control']='no-store'
        return response

    def bucket_token(request:Request):
        authorization=request.headers.get('authorization','')
        if not authorization.startswith('Bearer '):
            raise HTTPException(401,'Storage credential required')
        token=authorization[7:]
        if not token or len(token)>4096 or any(ch.isspace() or ord(ch)<33 for ch in token):
            raise HTTPException(401,'Storage credential required')
        return token

    async def bucket_body(request:Request, token:str, *, limit:int, fields:set[str]):
        chunks=bytearray()
        try:
            async for chunk in request.stream():
                if len(chunks)+len(chunk)>limit: raise HTTPException(413,'Storage request too large')
                chunks.extend(chunk)
            def unique_object(pairs):
                result={}
                for key,value in pairs:
                    if key in result: raise ValueError('duplicate key')
                    result[key]=value
                return result
            value=json.loads(chunks.decode('utf-8'),object_pairs_hook=unique_object)
            if not isinstance(value,dict) or set(value)!=fields:
                raise ValueError('invalid body')
        except HTTPException:
            raise
        except Exception:
            raise HTTPException(400,'Invalid storage request') from None
        finally:
            chunks.clear()
        if not valid_bucket_id(value.get('bucket_id')):
            raise HTTPException(400,'Invalid storage request')
        return value

    def bucket_http_error(exc:BucketStorageError):
        status={'unauthorized':401,'forbidden':403,'not_found':404,'invalid_request':400,
                'invalid_bundle':400,'storage_unavailable':502,'timeout':504}.get(exc.category,502)
        detail=('Storage credential is invalid' if status==401 else
                'Storage access is unavailable' if status==403 else
                'Invalid portable bundle' if exc.category=='invalid_bundle' else
                'Storage operation timed out' if status==504 else
                'Storage operation failed')
        return HTTPException(status,detail)

    async def bucket_operation(request:Request, operation:str, *, fields:set[str], limit:int=4096):
        token=bucket_token(request)
        if not bucket_admission.acquire(blocking=False):
            raise HTTPException(429,'Storage operation busy')
        try:
            body=await bucket_body(request,token,limit=limit,fields=fields)
            bundle_raw=None
            bundle_obj=None
            bundle_files=None
            if operation=='save':
                bundle=body.get('bundle')
                if not isinstance(bundle,str) or len(bundle.encode('utf-8'))>MAX_BUNDLE:
                    raise HTTPException(400,'Invalid portable bundle')
                try: bundle_obj,bundle_files=load_bundle(bundle.encode('utf-8'))
                except BundleError: raise HTTPException(400,'Invalid portable bundle') from None
                bundle_raw=bundle.encode('utf-8')
                call_args={'bundle_base64':base64.b64encode(bundle_raw).decode('ascii')}
            elif operation=='recipes_save':
                library=body.get('library')
                if not isinstance(library,str): raise HTTPException(400,'Invalid recipe library')
                try: library_raw=library.encode('utf-8')
                except UnicodeEncodeError: raise HTTPException(400,'Invalid recipe library') from None
                if len(library_raw)>MAX_RECIPE_LIBRARY:
                    raise HTTPException(413,'Recipe library too large')
                try: validate_recipe_library(library_raw)
                except Exception: raise HTTPException(400,'Invalid recipe library') from None
                call_args={'library_base64':base64.b64encode(library_raw).decode('ascii')}
            elif operation=='restore':
                if not valid_bundle_key(body.get('key')):
                    raise HTTPException(400,'Invalid storage request')
                call_args={'key':body['key']}
            elif operation=='recipes_restore':
                if not valid_recipe_key(body.get('key')):
                    raise HTTPException(400,'Invalid storage request')
                call_args={'key':body['key']}
            else:
                call_args={}
            try:
                result=await run_in_threadpool(run_bucket_operation,operation,token,body['bucket_id'],**call_args)
            except BucketStorageError as exc:
                raise bucket_http_error(exc) from None
            if operation=='save':
                result['_validated_bundle_bytes']=bundle_raw
                result['_validated_bundle_files']=bundle_files
                result['_validated_bundle_has_receipt']='run_receipt' in bundle_obj['artifacts']
            if operation=='recipes_save':
                result['_validated_library_bytes']=library_raw
                result['_validated_bucket_id']=body['bucket_id']
            return result
        finally:
            bucket_admission.release()

    @app.post('/api/bucket/connect')
    async def bucket_connect(request:Request):
        result=await bucket_operation(request,'connect',fields={'bucket_id'})
        return JSONResponse(result,headers={'Cache-Control':'no-store'})

    @app.post('/api/bucket/list')
    async def bucket_list(request:Request):
        result=await bucket_operation(request,'list',fields={'bucket_id'})
        return JSONResponse(result,headers={'Cache-Control':'no-store'})

    @app.post('/api/bucket/save')
    async def bucket_save(request:Request):
        result=await bucket_operation(request,'save',fields={'bucket_id','bundle'},limit=BUCKET_MAX_REQUEST)
        raw=result.pop('_validated_bundle_bytes',None)
        bundle_files=result.pop('_validated_bundle_files',None)
        has_receipt=result.pop('_validated_bundle_has_receipt',False)
        verified=result.get('verified_files')
        required={'bundle.json'}
        if has_receipt:
            required|={'report.md','generation-history.csv'}
        if isinstance(bundle_files,dict) and 'analysis_archive.gz' in bundle_files:
            required.add('analysis-archive.gz')
            required.update(name for name in bundle_files if name.startswith('analysis/'))
            if 'analysis/stars.json' in bundle_files:
                required.add('star-check.txt')
            key_match=AUTOSAVE_APP_KEY.fullmatch(result.get('key',''))
            if key_match is None:
                raise HTTPException(502,'Storage operation failed')
            for role in ('fitness','fastest_lap','clean_laps'):
                inner=f'analysis/winners/{role}.evolution'
                if inner in bundle_files:
                    required.add(winner_sidecar_filename(key_match.group('track'),key_match.group('vehicle'),
                        key_match.group('timestamp'),key_match.group('uuid')[:8],role))
        evolution=[name for name in verified if isinstance(name,str)
                   and (match:=EVOLUTION_SIDECAR.fullmatch(name)) and match.group(2) is None] if isinstance(verified,dict) else []
        if len(evolution)!=1 or not isinstance(verified,dict) or set(verified)!=(required|{evolution[0]}):
            raise HTTPException(502,'Storage operation failed')
        if not isinstance(raw,bytes) or not isinstance(bundle_files,dict):
            raise HTTPException(502,'Storage operation failed')
        network=bundle_files['selected_network.evolution']
        safe_verified={}
        total=0
        per_file_limit=MAX_ANALYSIS_FILE_BYTES
        for name,proof in verified.items():
            if (not isinstance(proof,dict) or set(proof)!={'bytes','sha256'}
                    or type(proof['bytes']) is not int or not 0<=proof['bytes']<=per_file_limit
                    or not isinstance(proof['sha256'],str) or not re.fullmatch(r'[0-9a-f]{64}',proof['sha256'])):
                raise HTTPException(502,'Storage operation failed')
            total+=proof['bytes']
            safe_verified[name]={'bytes':proof['bytes'],'sha256':proof['sha256']}
        if (total>MAX_BUNDLE+MAX_NETWORK+320*1024*1024
                or safe_verified['bundle.json']!={'bytes':len(raw),'sha256':hashlib.sha256(raw).hexdigest()}
                or safe_verified[evolution[0]]!={'bytes':len(network),'sha256':hashlib.sha256(network).hexdigest()}):
            raise HTTPException(502,'Storage operation failed')
        analysis_archive=bundle_files.get('analysis_archive.gz')
        if analysis_archive is not None and safe_verified.get('analysis-archive.gz')!={
                'bytes':len(analysis_archive),'sha256':hashlib.sha256(analysis_archive).hexdigest()}:
            raise HTTPException(502,'Storage operation failed')
        for filename,content in bundle_files.items():
            if filename.startswith('analysis/') and safe_verified.get(filename)!={
                    'bytes':len(content),'sha256':hashlib.sha256(content).hexdigest()}:
                raise HTTPException(502,'Storage operation failed')
        for role in ('fitness','fastest_lap','clean_laps'):
            inner=f'analysis/winners/{role}.evolution'
            key_match=AUTOSAVE_APP_KEY.fullmatch(result.get('key',''))
            sidecar=(winner_sidecar_filename(key_match.group('track'),key_match.group('vehicle'),
                key_match.group('timestamp'),key_match.group('uuid')[:8],role) if key_match else '')
            if inner in bundle_files and safe_verified.get(sidecar)!={
                    'bytes':len(bundle_files[inner]),'sha256':hashlib.sha256(bundle_files[inner]).hexdigest()}:
                raise HTTPException(502,'Storage operation failed')
        return JSONResponse({key:result[key] for key in ('status','bucket_id','key','bytes','sha256')}|{'verified_files':safe_verified},headers={'Cache-Control':'no-store'})

    @app.post('/api/bucket/restore')
    async def bucket_restore(request:Request):
        result=await bucket_operation(request,'restore',fields={'bucket_id','key'})
        return Response(result['bundle_bytes'],media_type='application/json',headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})

    @app.post('/api/bucket/recipes/list')
    async def bucket_recipes_list(request:Request):
        result=await bucket_operation(request,'recipes_list',fields={'bucket_id'})
        items=result.get('items')
        if (not isinstance(items,list) or type(result.get('truncated')) is not bool
                or type(result.get('scan_incomplete')) is not bool or len(items)>50):
            raise HTTPException(502,'Storage operation failed')
        clean=[]
        for item in items:
            if (not isinstance(item,dict) or set(item)!={'key','bytes','label','created_at'}
                    or not valid_recipe_key(item.get('key')) or type(item.get('bytes')) is not int
                    or not 0<=item['bytes']<=MAX_RECIPE_LIBRARY
                    or not isinstance(item.get('label'),str) or len(item['label'])>192
                    or (item.get('created_at') is not None and not isinstance(item.get('created_at'),str))):
                raise HTTPException(502,'Storage operation failed')
            clean.append(item)
        return JSONResponse({'items':clean,'truncated':result['truncated'],
                             'scan_incomplete':result['scan_incomplete']},headers={'Cache-Control':'no-store'})

    @app.post('/api/bucket/recipes/save')
    async def bucket_recipes_save(request:Request):
        result=await bucket_operation(request,'recipes_save',fields={'bucket_id','library'},
                                      limit=MAX_RECIPE_LIBRARY*6+4096)
        raw=result.pop('_validated_library_bytes',None)
        expected_bucket=result.pop('_validated_bucket_id',None)
        verified=result.pop('verified_bytes',None)
        if (not isinstance(raw,bytes) or verified!=raw or result.get('status')!='saved_verified'
                or result.get('bucket_id')!=expected_bucket or not valid_recipe_key(result.get('key'))
                or result.get('bytes')!=len(raw) or result.get('sha256')!=hashlib.sha256(raw).hexdigest()):
            raise HTTPException(502,'Storage operation failed')
        return JSONResponse({key:result[key] for key in ('status','bucket_id','key','bytes','sha256')},
                            headers={'Cache-Control':'no-store'})

    @app.post('/api/bucket/recipes/restore')
    async def bucket_recipes_restore(request:Request):
        result=await bucket_operation(request,'recipes_restore',fields={'bucket_id','key'})
        raw=result.get('recipe_bytes')
        if not isinstance(raw,bytes) or not valid_recipe_key(result.get('key')):
            raise HTTPException(502,'Storage operation failed')
        return Response(raw,media_type='application/json',headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
    @app.get('/api/tracks')
    def track_catalog():
        return JSONResponse(library.catalog(),headers={'Cache-Control':'no-store'})
    @app.post('/api/leaderboard/compare')
    async def leaderboard_compare(request:Request):
        data=bytearray()
        async for chunk in request.stream():
            if len(data)+len(chunk)>4096: raise HTTPException(413,'Request too large')
            data.extend(chunk)
        try:
            body=json.loads(data)
            if not isinstance(body,dict) or set(body)!={'track_id','track_sha256','vehicle_type','lap_seconds'}:
                raise ValueError()
            if not isinstance(body['track_id'],str) or not isinstance(body['track_sha256'],str): raise ValueError()
            if type(body['vehicle_type']) is not int or not 0<=body['vehicle_type']<=3: raise ValueError()
            milliseconds(body['lap_seconds'])
        except Exception: raise HTTPException(400,'Invalid leaderboard comparison request') from None
        finally: data.clear()
        entry=next((track for track in library.catalog()['tracks'] if track['id']==body['track_id']),None)
        if entry is None:
            return {'status':'unsupported_track','message':'Steam comparison is available only for supported built-in tracks.'}
        if body['track_sha256']!=entry['sha256']:
            raise HTTPException(400,'Track identity did not match the built-in catalog')
        if not leaderboard_admission.acquire(blocking=False):
            return {'status':'busy','message':'Another Steam check is in progress. The completed result is unaffected.'}
        try:
            result=await run_in_threadpool(compare_public_best_lap,entry,body['lap_seconds'])
            if result.get('status')=='ok':result['vehicle_type']=body['vehicle_type']
            return result
        except SteamUnavailable as error:
            result={'status':'unavailable','failure_category':error.failure_category,
                'message':'Steam could not provide a complete public leaderboard. The completed result is unaffected.'}
            if error.failure_category == 'rate_limited':
                result['message']='Steam is limiting leaderboard requests. Checks pause for one minute; your completed result is unaffected.'
            if error.upstream_http_status is not None:
                result['upstream_http_status']=error.upstream_http_status
            return result
        finally:
            leaderboard_admission.release()
    @app.get('/api/tracks/{identifier}/file')
    def reference_track(identifier:str):
        try:payload=library.read(identifier)
        except KeyError:raise HTTPException(404,'Built-in track not found') from None
        except (ValueError,OSError):raise HTTPException(503,'Built-in track validation failed') from None
        return Response(payload,media_type='application/octet-stream',headers={'Cache-Control':'no-store'})
    @app.get('/track-library.js')
    def track_script():return FileResponse(ROOT/'track-library.js',media_type='text/javascript')
    @app.get('/track-map.js')
    def map_script():return FileResponse(ROOT/'track-map.js',media_type='text/javascript',headers={'Cache-Control':'no-store'})
    @app.get('/assets/track-atlas.json')
    def track_atlas():return FileResponse(ROOT/'assets/track-atlas.json',media_type='application/json')
    @app.get('/api/auth/config')
    def auth_config():
        return JSONResponse(oauth_config(),headers={'Cache-Control':'no-store'})
    @app.get('/hf-auth.js')
    def auth_script(): return FileResponse(ROOT/'hf-auth.js',media_type='text/javascript')
    @app.get('/hf-oauth.js')
    def oauth_script(): return FileResponse(ROOT/'vendor/hf-oauth/index.js',media_type='text/javascript')
    @app.get('/api/capabilities')
    def capabilities():
        return {'cpu':True,'gpu':gpu_worker is not None,'default':'cpu','configurable_training':True,'build':build_identity()}
    @app.get('/')
    def index():
        # Deliver the bootstrap with the authenticated document: private HF
        # iframe subresource access can fail before an external script runs.
        script=(ROOT/'client.js').read_text().replace('</script','<\\/script')
        map_js=(ROOT/'track-map.js').read_text().replace('</script','<\\/script')
        return HTMLResponse((ROOT/'index.html').read_text().replace(
            '<script src="/track-map.js"></script>','<script>'+map_js+'</script>').replace(
            '<script src="/client.js"></script>','<script>'+script+'</script>'),
            headers={'Referrer-Policy':'no-referrer','Cache-Control':'no-store'})
    @app.get('/client.js')
    def script(): return FileResponse(ROOT/'client.js',media_type='text/javascript')
    @app.get('/feedback')
    def feedback(): return FileResponse(ROOT/'FEEDBACK.md',media_type='text/plain')
    @app.get('/policy')
    def policy(): return FileResponse(ROOT/'DATA_POLICY.md',media_type='text/plain')
    @app.get('/help')
    def help_page(): return FileResponse(ROOT/'help.html',media_type='text/html')
    @app.post('/api/process')
    async def process(request:Request):
        if not admission.acquire(blocking=False): raise HTTPException(429,'Worker busy; try later')
        try:
            data=bytearray()
            async for chunk in request.stream():
                if len(data)+len(chunk)>3*1024*1024: raise HTTPException(413,'Request too large')
                data.extend(chunk)
            try:
                body=json.loads(data)
                automatic=body.get('automatic',True)
                backend=body.get('backend','cpu')
                if backend not in ('cpu','gpu'): raise ValueError()
                if type(automatic) is not bool: raise ValueError()
                track=base64.b64decode(body['track'],validate=True)
                network=base64.b64decode(body['network'],validate=True)
                if not track or not network or max(len(track),len(network))>1024*1024: raise ValueError()
            except Exception: raise HTTPException(400,'Invalid input envelope') from None
            finally: data.clear()
            try:config=validate_config(body['settings']) if 'settings' in body else None
            except ValueError as error:raise HTTPException(400,str(error)) from None
            selected=worker if backend=='cpu' else gpu_worker
            if selected is None: raise HTTPException(503,'GPU is unavailable; select CPU to run without a GPU lease')
            try:
                result=await run_in_threadpool(selected,track,network,config) if config is not None else await run_in_threadpool(selected,track,network)
                payload,report=result if isinstance(result,tuple) else (result,None)
                published,public_report=publish_worker_result(
                    app.state.results,payload,report,automatic,require_analysis=require_analysis)
                return {**published,'backend':backend,'report':public_report}
            except CapacityExceeded: raise HTTPException(503,'Result capacity reached') from None
            except InvalidAnalysisOutput: raise HTTPException(422,'Processing failed; complete analysis could not be verified.') from None
            except Exception: raise HTTPException(422,'Processing failed; no result published. Cleanup status requires verification.') from None
        finally: admission.release()
    @app.post('/api/replay')
    async def recorded_replay(request:Request):
        # Share CPU admission with training; recordings never allocate a GPU.
        if not admission.acquire(blocking=False):
            raise HTTPException(429,'Worker busy; finish the current run before recording a drive')
        data=bytearray()
        try:
            async for chunk in request.stream():
                if len(data)+len(chunk)>3*1024*1024:
                    raise HTTPException(413,'Replay input exceeds the upload limit')
                data.extend(chunk)
            try:
                body=json.loads(data)
                if not isinstance(body,dict) or set(body)!={'track','network','ticks'}:
                    raise ValueError()
                if type(body['ticks']) is not int or not 1<=body['ticks']<=900:
                    raise ValueError()
                track=base64.b64decode(body['track'],validate=True)
                network=base64.b64decode(body['network'],validate=True)
                if not track or not network or max(len(track),len(network))>1024*1024:
                    raise ValueError()
            except Exception:
                raise HTTPException(400,'Invalid replay input; select a track, a network and up to 15 seconds') from None
            finally:
                data.clear()
            from replay import record_replay, ReplayError
            try:
                recording=await run_in_threadpool(record_replay,track,network,
                    binary=ROOT/'bin/altd-sim',importer=ROOT/'bin/extract_saved_track',
                    scene_template=ROOT/'assets/formula_template.json',ticks=body['ticks'])
                recording['build']=build_identity()
                return JSONResponse(recording,headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
            except (TimeoutError,subprocess.TimeoutExpired):
                raise HTTPException(504,'Recording reached its time limit. Try a 5-second drive.') from None
            except (ReplayError,OSError,ValueError,KeyError,TypeError,subprocess.CalledProcessError):
                raise HTTPException(422,'The engine could not produce a complete recorded drive. No estimated playback was substituted.') from None
        finally:
            data.clear()
            admission.release()
    return app

app=create_app()

if __name__=='__main__':
    import uvicorn
    uvicorn.run(app,host='127.0.0.1',port=47831,access_log=False)