backplane-tracecheck 0.2.0__py3-none-any.whl

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.
tracecheck/report.py ADDED
@@ -0,0 +1,14 @@
1
+ import html
2
+ import json
3
+
4
+ STYLE = '''body{font:16px/1.55 system-ui,sans-serif;color:#203348;background:#eef3f7;margin:0}main{max-width:1120px;margin:auto;padding:36px}h1{font-size:36px;line-height:1.15}h2{margin:0 0 12px}article,.intro{background:white;border:1px solid #dae3eb;border-radius:14px;padding:24px;margin:20px 0}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:13px/1.6 Consolas,monospace;background:#f4f7fa;padding:14px;border-radius:8px}table{border-collapse:collapse;width:100%}td,th{text-align:left;padding:12px;border-bottom:1px solid #ddd}.pass{color:#116746}.fail{color:#b62b35}.warn{color:#865500}.skipped{color:#596979}.badge{font-weight:750;text-transform:uppercase}.sub{color:#526579}a{color:#126b83}code{overflow-wrap:anywhere}@media print{body{background:white}main{padding:0}article{break-inside:avoid}h1{font-size:28px}}'''
5
+
6
+ def render(r):
7
+ esc = lambda x: html.escape(str(x))
8
+ rows = ''.join(f'<tr><td>{x["id"]}</td><td><a href="#{x["id"]}">{esc(x["name"])}</a></td><td class="badge {x["status"]}">{x["status"]}</td></tr>' for x in r['checks'])
9
+ sections = []
10
+ for x in r['checks']:
11
+ findings = ''.join(f'<p><span class="badge {f["status"]}">{f["status"]}</span> · {esc(f["detail"])}</p><pre>{esc(json.dumps({k:v for k,v in f.items() if k not in ["detail","status"]}, indent=2, ensure_ascii=False))}</pre>' for f in x['findings'])
12
+ sections.append(f'<article id="{x["id"]}"><h2>{x["id"]} · {esc(x["name"])}</h2>{findings}</article>')
13
+ provenance = {k:v for k,v in r.items() if k not in ['checks','inventory','contract']}
14
+ return f'''<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'"><title>Tracecheck · {esc(r['source']['name'])}</title><style>{STYLE}</style><main><p class="sub">BACKPLANE TRACECHECK · v{esc(r['tool']['version'])}</p><h1>{esc(r['source']['name'])}</h1><div class="intro"><h2 class="{r['overall']}">{r['overall'].upper()} · contract review</h2><p>Scan complete: <b>{str(r['scan_complete']).lower()}</b> · {r['status_counts']['pass']} pass · {r['status_counts']['warn']} warn · {r['status_counts']['fail']} fail · {r['status_counts']['skipped']} skipped</p><p>{esc(r['interpretation'])}</p><p>Skipped means no conclusion. Check-level status is the worst applicable finding; inspect skipped sub-findings too.</p></div><table><thead><tr><th>Check</th><th>Requirement</th><th>Status</th></tr></thead><tbody>{rows}</tbody></table>{''.join(sections)}<article><h2>Provenance and limits</h2><pre>{esc(json.dumps(provenance,indent=2,ensure_ascii=False))}</pre></article><article><h2>Contract used</h2><pre>{esc(json.dumps(r['contract'],indent=2,ensure_ascii=False))}</pre></article><article><h2>Channel inventory</h2><pre>{esc(json.dumps(r.get('inventory',[]),indent=2,ensure_ascii=False))}</pre></article></main></html>'''
tracecheck/samples.py ADDED
@@ -0,0 +1,103 @@
1
+ """Deterministic invented recordings; no robot data, imported assets, or fault execution."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from mcap.writer import CompressionType, Writer
7
+
8
+ CONTRACT = {'version': 1, 'name': 'Synthetic 10 Hz demo (edit for your recording)', 'profile': '',
9
+ 'metadata': {'session': {'software_version': 'demo-1'}}, 'topics': {
10
+ '/demo/position': {'required': True, 'message_encoding': 'json', 'schema_name': 'DemoPosition',
11
+ 'schema_encoding': 'jsonschema', 'min_count': 11, 'max_count': 11,
12
+ 'clock_confirmed': True, 'time_basis': 'log_time', 'min_rate_hz': 10, 'max_rate_hz': 10,
13
+ 'max_gap_ns': 100000000, 'check_regression': True, 'sequence': 'increment_mod32',
14
+ 'interval_ns': [0, 1000000000], 'boundary_tolerance_ns': 0}}}
15
+
16
+ # Contract for the larger multi-topic demo written by write_fleet_sample().
17
+ FLEET_CONTRACT = {
18
+ 'version': 1, 'name': 'Synthetic mobile-robot demo', 'missing_crc': 'warn',
19
+ 'metadata': {'session': {'robot_id': None, 'software_version': None}},
20
+ 'topics': {
21
+ '/imu': {'message_encoding': 'cdr', 'schema_name': 'sensor_msgs/msg/Imu', 'clock_confirmed': True,
22
+ 'min_rate_hz': 190, 'max_rate_hz': 210, 'max_gap_ns': 20000000, 'check_regression': True},
23
+ '/odom': {'message_encoding': 'cdr', 'schema_name': 'nav_msgs/msg/Odometry', 'clock_confirmed': True,
24
+ 'min_rate_hz': 45, 'max_rate_hz': 55, 'max_gap_ns': 50000000, 'check_regression': True},
25
+ '/scan': {'message_encoding': 'cdr', 'schema_name': 'sensor_msgs/msg/LaserScan', 'clock_confirmed': True,
26
+ 'min_rate_hz': 9, 'max_rate_hz': 11, 'max_gap_ns': 200000000},
27
+ '/camera/image/compressed': {'message_encoding': 'cdr', 'schema_name': 'sensor_msgs/msg/CompressedImage',
28
+ 'clock_confirmed': True, 'min_rate_hz': 14, 'max_rate_hz': 16,
29
+ 'max_gap_ns': 150000000},
30
+ },
31
+ }
32
+
33
+
34
+ def write_sample(path, fault=False, chunked=True, compression=CompressionType.NONE, crc=True,
35
+ times=None, seq=None, metadata=True, schema=True, encoding='json'):
36
+ with Path(path).open('wb') as f:
37
+ w = Writer(f, use_chunking=chunked, compression=compression, enable_crcs=crc, enable_data_crcs=crc)
38
+ w.start(profile='', library='backplane-tracecheck synthetic generator')
39
+ sid = w.register_schema('DemoPosition', 'jsonschema', b'{"type":"object"}') if schema else 0
40
+ cid = w.register_channel('/demo/position', encoding, sid)
41
+ if metadata and not fault:
42
+ w.add_metadata('session', {'software_version': 'demo-1'})
43
+ ts = times if times is not None else ([0,100000000,200000000,800000000,700000000,1000000000] if fault else [i*100000000 for i in range(11)])
44
+ for i,t in enumerate(ts):
45
+ sequence = seq[i] if seq is not None else (i+2 if fault and i>=3 else i)
46
+ w.add_message(cid, t, b'{"x":1,"synthetic":true}', t, sequence=sequence)
47
+ w.finish()
48
+
49
+
50
+ def write_fleet_sample(path, seconds=60, compression=CompressionType.ZSTD, dropout=None, payload_scale=1,
51
+ chunk_size=1024 * 1024):
52
+ """A multi-topic, ROS 2-shaped synthetic recording (payloads are filler bytes, not real CDR).
53
+
54
+ ``dropout`` = (topic, start_s, end_s) removes that topic's messages in the window to create a gap.
55
+ ``payload_scale`` multiplies payload sizes, to produce large files for streaming tests.
56
+ """
57
+ start = 1_760_000_000_000_000_000 # fixed epoch, 2025-10-09
58
+ rates = {'/imu': 200, '/odom': 50, '/scan': 10, '/camera/image/compressed': 15}
59
+ sizes = {'/imu': 300, '/odom': 700, '/scan': 4400, '/camera/image/compressed': 60000}
60
+ with Path(path).open('wb') as f:
61
+ w = Writer(f, compression=compression, chunk_size=chunk_size)
62
+ w.start(profile='ros2', library='backplane-tracecheck synthetic generator')
63
+ cids = {}
64
+ for topic, schema in [('/imu', 'sensor_msgs/msg/Imu'), ('/odom', 'nav_msgs/msg/Odometry'),
65
+ ('/scan', 'sensor_msgs/msg/LaserScan'),
66
+ ('/camera/image/compressed', 'sensor_msgs/msg/CompressedImage')]:
67
+ sid = w.register_schema(schema, 'ros2msg', f'# synthetic placeholder for {schema}'.encode())
68
+ cids[topic] = w.register_channel(topic, 'cdr', sid, {'offered_qos_profiles': '- history: 3\n depth: 0\n'})
69
+ w.add_metadata('session', {'robot_id': 'demo-amr-01', 'software_version': 'demo-2.3.1', 'synthetic': 'true'})
70
+ events = []
71
+ for topic, hz in rates.items():
72
+ period = 1_000_000_000 // hz
73
+ for i in range(seconds * hz):
74
+ t = start + i * period
75
+ if dropout and dropout[0] == topic and dropout[1] * 1e9 <= i * period < dropout[2] * 1e9:
76
+ continue
77
+ events.append((t, topic, i))
78
+ events.sort()
79
+ blobs = {topic: bytes((i * 31 + 7) & 0xFF for i in range(n * payload_scale)) for topic, n in sizes.items()}
80
+ for t, topic, i in events:
81
+ w.add_message(cids[topic], t, blobs[topic], t, sequence=i)
82
+ w.finish()
83
+
84
+
85
+ def generate(dest):
86
+ dest = Path(dest)
87
+ dest.mkdir(exist_ok=True)
88
+ (dest/'demo-contract.json').write_text(json.dumps(CONTRACT, indent=2), encoding='utf-8')
89
+ (dest/'fleet-contract.json').write_text(json.dumps(FLEET_CONTRACT, indent=2), encoding='utf-8')
90
+ write_sample(dest/'clean.mcap')
91
+ write_sample(dest/'clean-zstd.mcap', compression=CompressionType.ZSTD)
92
+ write_sample(dest/'clean-lz4.mcap', compression=CompressionType.LZ4)
93
+ write_sample(dest/'faults.mcap', fault=True)
94
+ write_sample(dest/'unchunked.mcap', chunked=False)
95
+ write_sample(dest/'no-crc.mcap', compression=CompressionType.ZSTD, crc=False)
96
+ write_sample(dest/'unsupported-codec.mcap', compression=CompressionType.ZSTD, crc=False)
97
+ p = dest/'unsupported-codec.mcap'
98
+ p.write_bytes(p.read_bytes().replace(b'zstd', b'brot'))
99
+ (dest/'malformed.mcap').write_bytes(b'not an MCAP recording\x00')
100
+ (dest/'truncated.mcap').write_bytes((dest/'clean.mcap').read_bytes()[:-12])
101
+ write_fleet_sample(dest/'fleet-zstd.mcap', seconds=20)
102
+ write_fleet_sample(dest/'fleet-dropout-lz4.mcap', seconds=20, compression=CompressionType.LZ4,
103
+ dropout=('/scan', 8, 9.5))
tracecheck/server.py ADDED
@@ -0,0 +1,158 @@
1
+ """Loopback, token/origin guarded, one cancellable worker; no persistent uploads."""
2
+ import base64
3
+ import json
4
+ import secrets
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ import threading
9
+ import webbrowser
10
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
11
+ from pathlib import Path
12
+ from .contract import loads
13
+ from .report import render
14
+ from .samples import CONTRACT
15
+
16
+ ROOT = Path(__file__).resolve().parent
17
+ UPLOAD_LIMIT = 64 * 1024 * 1024 # base64-in-JSON upload; use the CLI or the web app for larger files
18
+ MAX_FILE = UPLOAD_LIMIT
19
+ TIMEOUT = 30
20
+
21
+ class LocalServer(ThreadingHTTPServer):
22
+ daemon_threads = True
23
+ def __init__(self, port):
24
+ super().__init__(('127.0.0.1', port), Handler)
25
+ self.token = secrets.token_urlsafe(32)
26
+ self.origin = f'http://127.0.0.1:{self.server_port}'
27
+ self.gate = threading.Lock()
28
+ self.proc_lock = threading.Lock()
29
+ self.proc = None
30
+ self.cancelled = False
31
+
32
+ class Handler(BaseHTTPRequestHandler):
33
+ def setup(self):
34
+ super().setup(); self.connection.settimeout(15)
35
+ def log_message(self, *args):
36
+ pass # No filenames, contracts, payloads or tokens in access logs.
37
+ def send(self, status, body, content_type='application/json'):
38
+ if getattr(self, 'defer_response', False):
39
+ self.pending_response = (status, body, content_type)
40
+ return
41
+ b = body.encode('utf-8')
42
+ self.send_response(status)
43
+ self.send_header('Content-Type', content_type+'; charset=utf-8')
44
+ self.send_header('Content-Length', str(len(b)))
45
+ self.send_header('Cache-Control', 'no-store')
46
+ self.send_header('X-Content-Type-Options', 'nosniff')
47
+ self.send_header('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; frame-src blob:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'")
48
+ self.end_headers()
49
+ try:
50
+ self.wfile.write(b)
51
+ except (BrokenPipeError, ConnectionResetError):
52
+ pass
53
+ def authorized(self, post=False):
54
+ if self.headers.get('Host') != self.server.origin.removeprefix('http://'):
55
+ self.send(403, '{"error":"Host rejected"}'); return False
56
+ if post and (self.headers.get('X-Session-Token') != self.server.token or
57
+ self.headers.get('Origin') != self.server.origin):
58
+ self.send(403, '{"error":"Local session token/origin rejected"}'); return False
59
+ return True
60
+ def discard_body(self):
61
+ # Draining bounded competing uploads avoids TCP reset before the 409 is read on Windows.
62
+ try:
63
+ remaining = int(self.headers.get('Content-Length', '0'))
64
+ if 0 <= remaining <= MAX_FILE*4//3+200000:
65
+ while remaining:
66
+ part = self.rfile.read(min(65536, remaining))
67
+ if not part: break
68
+ remaining -= len(part)
69
+ except (ValueError, OSError):
70
+ pass
71
+ def do_GET(self):
72
+ if not self.authorized(): return
73
+ if self.path == '/':
74
+ text = (ROOT/'ui.html').read_text(encoding='utf-8').replace('__TOKEN__', self.server.token)
75
+ self.send(200, text, 'text/html')
76
+ elif self.path == '/default-contract':
77
+ self.send(200, json.dumps(CONTRACT, indent=2))
78
+ elif self.path == '/status':
79
+ self.send(200, json.dumps({'busy': self.server.gate.locked()}))
80
+ else:
81
+ self.send(404, '{"error":"Not found"}')
82
+ def do_POST(self):
83
+ if not self.authorized(True): return
84
+ if self.path == '/cancel':
85
+ with self.server.proc_lock:
86
+ self.server.cancelled = True
87
+ if self.server.proc and self.server.proc.poll() is None:
88
+ self.server.proc.kill()
89
+ self.send(200, '{"cancelled":true}'); return
90
+ if self.path not in ['/run', '/validate']:
91
+ self.send(404, '{"error":"Not found"}'); return
92
+ if not self.server.gate.acquire(blocking=False):
93
+ self.discard_body()
94
+ self.send(409, '{"error":"A local check is running. Cancel it or wait, then retry."}'); return
95
+ self.defer_response = True
96
+ try:
97
+ self.server.cancelled = False
98
+ length = int(self.headers.get('Content-Length', '0'))
99
+ limit = MAX_FILE*4//3+200000 if self.path == '/run' else 150000
100
+ if not 0 < length <= limit or self.headers.get('Transfer-Encoding'):
101
+ raise ValueError('Request exceeds limit or unsupported transfer encoding')
102
+ body = self.rfile.read(length)
103
+ if len(body) != length:
104
+ raise ValueError('Incomplete upload')
105
+ request = json.loads(body)
106
+ contract = loads(request['contract'])
107
+ if self.path == '/validate':
108
+ self.send(200, '{"valid":true}'); return
109
+ data = base64.b64decode(request['data'], validate=True)
110
+ if len(data) > MAX_FILE:
111
+ raise ValueError('Recording exceeds 64 MiB')
112
+ name = str(request.get('filename', 'recording.mcap')).replace('\\', '/').split('/')[-1][:256]
113
+ with tempfile.TemporaryDirectory(prefix='tracecheck-') as temp:
114
+ folder = Path(temp)
115
+ (folder/'input.mcap').write_bytes(data)
116
+ (folder/'contract.json').write_text(json.dumps(contract), encoding='utf-8')
117
+ with self.server.proc_lock:
118
+ if self.server.cancelled:
119
+ raise ValueError('Cancelled')
120
+ proc = subprocess.Popen([sys.executable, '-m', 'tracecheck', '_worker', str(folder/'input.mcap'),
121
+ str(folder/'contract.json'), '--name', name], stdout=subprocess.PIPE,
122
+ stderr=subprocess.PIPE, text=True, encoding='utf-8')
123
+ self.server.proc = proc
124
+ try:
125
+ stdout, stderr = proc.communicate(timeout=TIMEOUT)
126
+ except subprocess.TimeoutExpired:
127
+ proc.kill(); proc.communicate(); raise ValueError('30-second worker limit reached; scan incomplete') from None
128
+ if self.server.cancelled:
129
+ raise ValueError('Cancelled; no report retained')
130
+ if proc.returncode:
131
+ raise ValueError('Worker failed; no conclusions. Run the CLI on this file for details.')
132
+ r = json.loads(stdout)
133
+ self.send(200, json.dumps({'overall': r['overall'], 'scan_complete': r['scan_complete'],
134
+ 'counts': r['status_counts'], 'json': json.dumps(r, indent=2), 'html': render(r)}))
135
+ except Exception as e:
136
+ message = str(e) if isinstance(e, ValueError) else type(e).__name__
137
+ self.send(400, json.dumps({'error': message.replace(str(Path.home()), '~')[:500]}))
138
+ finally:
139
+ with self.server.proc_lock:
140
+ self.server.proc = None
141
+ self.server.gate.release()
142
+ self.defer_response = False
143
+ self.send(*self.pending_response)
144
+
145
+ def serve(port=8765, open_browser=False):
146
+ server = LocalServer(port)
147
+ print('Backplane Tracecheck:', server.origin, flush=True)
148
+ print('Loopback only. Ctrl+C stops. Upload copies are temporary; reports stay in this browser until downloaded.', flush=True)
149
+ if open_browser:
150
+ webbrowser.open(server.origin)
151
+ try:
152
+ server.serve_forever(poll_interval=0.2)
153
+ except KeyboardInterrupt:
154
+ pass
155
+ finally:
156
+ with server.proc_lock:
157
+ if server.proc and server.proc.poll() is None: server.proc.kill()
158
+ server.server_close()
tracecheck/ui.html ADDED
@@ -0,0 +1,21 @@
1
+ <!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Backplane Tracecheck</title>
2
+ <style>
3
+ :root{font:16px/1.55 system-ui,sans-serif;color:#24384b;background:#edf3f6}*{box-sizing:border-box}body{margin:0}main{max-width:1200px;margin:auto;padding:32px}header{display:flex;justify-content:space-between;align-items:center;gap:20px}h1{font-size:38px;line-height:1.15;letter-spacing:-1px;margin:8px 0 16px}h2{font-size:20px;margin:0 0 16px}.eyebrow{color:#376f79;letter-spacing:2px;font-size:12px;font-weight:750}.pill{border:1px solid #b6cece;border-radius:50px;padding:7px 14px;white-space:nowrap;color:#176250;background:#e5f4eb}.lead{color:#597083;max-width:810px}.grid{display:grid;grid-template-columns:0.8fr 1.2fr;gap:24px;margin-top:28px}.card{background:white;border:1px solid #d8e2e9;border-radius:16px;padding:24px;box-shadow:0 4px 20px #28425606}label{display:block;font-weight:650;margin:12px 0 7px}input[type=file]{width:100%;padding:15px;border:1px dashed #92aebf;border-radius:8px;background:#f5f8fa}textarea{width:100%;height:370px;border:1px solid #bdcbd7;border-radius:8px;padding:14px;font:13px/1.5 Consolas,monospace;resize:vertical;color:#263f58;background:#fbfcfd}.hint{font-size:13px;color:#627386}.actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:16px}button,.link{font:inherit;cursor:pointer;border-radius:8px;padding:10px 16px;border:1px solid #b7cbd3;background:white;color:#234559;text-decoration:none}button.primary{background:#156a78;border-color:#156a78;color:white;font-weight:650}button:disabled{opacity:.45;cursor:default}#status{min-height:28px;font-weight:650;overflow-wrap:anywhere;margin-top:18px}.error{color:#b12936}.good{color:#116647}#result{margin-top:24px}iframe{width:100%;height:800px;border:1px solid #cbd7df;border-radius:10px;background:white;margin-top:20px}footer{font-size:13px;color:#627386;margin:28px 0}a{color:#176779}@media(max-width:800px){.grid{grid-template-columns:1fr}main{padding:20px}h1{font-size:30px}header{align-items:flex-start;flex-direction:column}}
4
+ </style></head><body><main><header><div><div class="eyebrow">BACKPLANE ROBOTICS</div><h1>Backplane Tracecheck</h1></div><span class="pill">● Local only</span></header><p class="lead">Check whether a robot recording meets your recording contract. Get ten explicit checks, exact evidence locations, and a report you can keep.</p><div class="grid"><section class="card"><h2>1 · Choose a recording</h2><label for="recording">MCAP file</label><input id="recording" type="file" accept=".mcap"><p class="hint">MCAP, uncompressed or zstd/lz4 compressed · 64 MiB per file in this local UI (use the CLI or backplanerobotics.com for larger files) · 30 seconds.</p><p>Start with <b>examples/clean.mcap</b>, then try <b>examples/faults.mcap</b>. Both contain invented data.</p><h2 style="margin-top:32px">3 · Review the evidence</h2><p class="hint">PASS: evaluated requirement met.<br>WARN: limitation needs attention.<br>FAIL: requirement unmet or scan incomplete.<br>SKIPPED: no conclusion available.</p><div class="actions"><button class="primary" id="run">Check recording</button><button id="cancel" disabled>Cancel</button><button id="reset">Clear</button></div><div id="status" role="status" aria-live="polite">Choose a file to begin.</div></section><section class="card"><h2>2 · Set your recording contract</h2><p class="hint">The demo contract expects one synthetic 10 Hz topic. Edit it for your recording. Only confirm clocks and sequence semantics you know. Nanosecond integers are kept exact.</p><label for="contractFile">Import contract (.json)</label><input type="file" id="contractFile" accept=".json"><label for="contract">Editable JSON contract</label><textarea id="contract" spellcheck="false" aria-label="Editable JSON contract"></textarea><div class="actions"><button id="validate">Validate contract</button><button id="saveContract">Download contract</button><button id="demo">Restore demo</button></div></section></div><section id="result" class="card" hidden><h2 id="summary"></h2><div class="actions"><a class="link" id="downloadHtml" download="tracecheck-report.html">Download HTML report</a><a class="link" id="downloadJson" download="tracecheck-report.json">Download JSON report</a></div><iframe id="preview" title="Recording check report" sandbox=""></iframe></section><footer>Backplane Tracecheck · No payload decoding, telemetry, accounts, or cloud upload. A temporary local upload copy is removed after checking. Downloaded reports include names, topics, contract metadata, and timestamps: review them before sharing. Hashes identify bytes; they do not establish truthful recordings, safety, compliance, or cause.</footer></main>
5
+ <script>
6
+ const token='__TOKEN__';const $=id=>document.getElementById(id);let busy=false, urls=[], generation=0;
7
+ function status(text,error=false){$('status').textContent=text;$('status').className=error?'error':'good'}
8
+ function clearResult(){urls.forEach(URL.revokeObjectURL);urls=[];$('result').hidden=true;$('preview').removeAttribute('src')}
9
+ function blob(text,type){const u=URL.createObjectURL(new Blob([text],{type}));urls.push(u);return u}
10
+ function setBusy(b){busy=b;$('run').disabled=b;$('validate').disabled=b;$('cancel').disabled=!b}
11
+ async function post(path,value){const r=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json','X-Session-Token':token},body:JSON.stringify(value)});const v=await r.json();if(!r.ok)throw Error(v.error||'Request failed');return v}
12
+ async function demo(){$('contract').value=await (await fetch('/default-contract')).text()}
13
+ $('demo').onclick=demo;
14
+ $('contractFile').onchange=async()=>{const f=$('contractFile').files[0];if(f){if(f.size>65536){status('Contract exceeds 64 KiB.',true);return}$('contract').value=await f.text();status('Contract imported. Validate or edit it before checking.')}};
15
+ $('validate').onclick=async()=>{try{await post('/validate',{contract:$('contract').value});status('Contract valid. Confirm its assumptions match your recording.')}catch(e){status(e.message,true)}};
16
+ $('saveContract').onclick=async()=>{try{await post('/validate',{contract:$('contract').value});const a=document.createElement('a');a.href=blob($('contract').value,'application/json');a.download='recording-contract.json';a.click();status('Contract download ready.')}catch(e){status(e.message,true)}};
17
+ $('cancel').onclick=async()=>{generation++;await post('/cancel',{});status('Cancellation requested. Wait for the worker to stop.');clearResult()};
18
+ $('reset').onclick=async()=>{generation++;if(busy)await post('/cancel',{});$('recording').value='';$('contractFile').value='';clearResult();status('Cleared. Choose a recording to begin.')};
19
+ $('run').onclick=async()=>{const file=$('recording').files[0];if(!file){status('Choose an MCAP file first.',true);return}if(file.size>64*1024*1024){status('Recording exceeds 64 MiB.',true);return}const run=++generation;clearResult();setBusy(true);status('Reading and checking locally…');try{const bytes=new Uint8Array(await file.arrayBuffer());let parts=[];for(let i=0;i<bytes.length;i+=32768)parts.push(String.fromCharCode(...bytes.subarray(i,i+32768)));const data=btoa(parts.join(''));if(run!==generation)return;const r=await post('/run',{filename:file.name,contract:$('contract').value,data});if(run!==generation)return;$('summary').textContent=r.overall.toUpperCase()+' · '+(r.scan_complete?'scan complete':'scan incomplete');$('downloadHtml').href=blob(r.html,'text/html');$('downloadJson').href=blob(r.json,'application/json');$('preview').src=$('downloadHtml').href;$('result').hidden=false;status('Report ready: '+r.counts.pass+' pass, '+r.counts.warn+' warn, '+r.counts.fail+' fail, '+r.counts.skipped+' skipped.',r.overall==='fail')}catch(e){if(run===generation)status(e.message,true)}finally{setBusy(false)}};
20
+ demo();fetch('/status').then(r=>r.json()).then(r=>{if(r.busy){setBusy(true);status('A previous check is still running. Cancel it or wait.');const timer=setInterval(async()=>{const s=await(await fetch('/status')).json();if(!s.busy){clearInterval(timer);setBusy(false);status('Ready for a new check. Previous report was cleared by reload.')}},700)}});
21
+ </script></body></html>