diff --git a/positronic/server/positronic_server.py b/positronic/server/positronic_server.py
index e71d9a77c..0e1bf0f67 100644
--- a/positronic/server/positronic_server.py
+++ b/positronic/server/positronic_server.py
@@ -103,6 +103,17 @@ async def cache_rerun_assets(request: Request, call_next):
return response
+def _make_serializable(obj):
+ """Ensure obj is JSON serializable (e.g. convert datetime)."""
+ if isinstance(obj, datetime):
+ return obj.isoformat()
+ if isinstance(obj, dict):
+ return {k: _make_serializable(v) for k, v in obj.items()}
+ if isinstance(obj, list):
+ return [_make_serializable(v) for v in obj]
+ return obj
+
+
def _iter_file_chunks(path: str, *, chunk_size: int = 128 * 1024):
with open(path, 'rb') as source:
while True:
@@ -188,16 +199,6 @@ async def episode_viewer(request: Request, episode_id: int):
size_mb = meta.get('size_mb')
size_mb_display = f'{size_mb:.2f}' if isinstance(size_mb, int | float) else None
- # Ensure static_data is JSON serializable (e.g. handle datetime)
- def _make_serializable(obj):
- if isinstance(obj, datetime):
- return obj.isoformat()
- if isinstance(obj, dict):
- return {k: _make_serializable(v) for k, v in obj.items()}
- if isinstance(obj, list):
- return [_make_serializable(v) for v in obj]
- return obj
-
return templates.TemplateResponse(
'episode.html',
{
@@ -428,6 +429,28 @@ async def api_dataset_status():
}
+@app.get('/api/episode/{episode_id}')
+@require_dataset
+async def api_episode(episode_id: int):
+ ds = app_state.get('dataset')
+ try:
+ episode = ds[episode_id]
+ except IndexError as e:
+ raise HTTPException(status_code=404, detail='Episode not found') from e
+
+ meta = episode.meta
+ size_mb = meta.get('size_mb')
+
+ return {
+ 'episode_id': episode_id,
+ 'num_episodes': len(ds),
+ 'task': episode.static.get('task', None),
+ 'episode_path': meta.get('path'),
+ 'episode_size_mb': f'{size_mb:.2f}' if isinstance(size_mb, int | float) else None,
+ 'static_data': _make_serializable(episode.static),
+ }
+
+
@app.get('/api/episode_rrd/{episode_id}')
@require_dataset
async def api_episode_rrd(episode_id: int):
diff --git a/positronic/server/static/app.js b/positronic/server/static/app.js
index 8ce011dcb..fdad64381 100644
--- a/positronic/server/static/app.js
+++ b/positronic/server/static/app.js
@@ -615,6 +615,7 @@ function loadSidebarState() {
function initializeSidebar(staticData) {
const tbody = document.querySelector('.sidebar-content-wrapper tbody');
+ tbody.innerHTML = '';
tbody.insertAdjacentHTML('beforeend', renderLevel('', staticData));
document.querySelectorAll('.expand-button').forEach((button) => {
diff --git a/positronic/server/static/sw.js b/positronic/server/static/sw.js
new file mode 100644
index 000000000..3f204a035
--- /dev/null
+++ b/positronic/server/static/sw.js
@@ -0,0 +1,31 @@
+// Service Worker to cache rerun WASM and JS assets.
+// These files are large (~35MB WASM) and don't change between episodes.
+
+const CACHE_NAME = 'rerun-assets-v1';
+const RERUN_PATH_PREFIX = '/static/rerun/';
+
+self.addEventListener('install', () => self.skipWaiting());
+self.addEventListener('activate', (event) => {
+ event.waitUntil(
+ caches.keys().then((names) =>
+ Promise.all(names.filter((n) => n !== CACHE_NAME).map((n) => caches.delete(n)))
+ ).then(() => self.clients.claim())
+ );
+});
+
+self.addEventListener('fetch', (event) => {
+ const url = new URL(event.request.url);
+ if (!url.pathname.startsWith(RERUN_PATH_PREFIX)) return;
+
+ event.respondWith(
+ caches.open(CACHE_NAME).then((cache) =>
+ cache.match(event.request).then((cached) => {
+ if (cached) return cached;
+ return fetch(event.request).then((response) => {
+ if (response.ok) cache.put(event.request, response.clone());
+ return response;
+ });
+ })
+ )
+ );
+});
diff --git a/positronic/server/templates/base.html b/positronic/server/templates/base.html
index 5995fbb9a..a2f3427b3 100644
--- a/positronic/server/templates/base.html
+++ b/positronic/server/templates/base.html
@@ -28,6 +28,7 @@
+
{% block scripts %}{% endblock %}