root commited on
Commit
38819be
·
1 Parent(s): 14de67e
README.md CHANGED
@@ -94,6 +94,7 @@ This creates reproducible benchmark records for easy/medium/hard tasks.
94
  - `Dockerfile` is included and starts `uvicorn server.app:app`.
95
  - Space metadata is configured in this `README.md` frontmatter with `sdk: docker`.
96
  - Health endpoint: `/health`
 
97
 
98
  Local container run:
99
  ```bash
 
94
  - `Dockerfile` is included and starts `uvicorn server.app:app`.
95
  - Space metadata is configured in this `README.md` frontmatter with `sdk: docker`.
96
  - Health endpoint: `/health`
97
+ - **Web playground:** open the Space URL (`/`). Click **Connect session** to open a WebSocket to `/ws`, then **Start episode (reset)** and **Run query (step)**. OpenEnv’s HTTP `POST /reset` and `POST /step` each use a fresh environment instance (stateless); the WebSocket session keeps one episode alive for the UI.
98
 
99
  Local container run:
100
  ```bash
__pycache__/inference.cpython-313.pyc CHANGED
Binary files a/__pycache__/inference.cpython-313.pyc and b/__pycache__/inference.cpython-313.pyc differ
 
core/__pycache__/__init__.cpython-313.pyc CHANGED
Binary files a/core/__pycache__/__init__.cpython-313.pyc and b/core/__pycache__/__init__.cpython-313.pyc differ
 
database/__pycache__/__init__.cpython-313.pyc CHANGED
Binary files a/database/__pycache__/__init__.cpython-313.pyc and b/database/__pycache__/__init__.cpython-313.pyc differ
 
database/__pycache__/sqlite_manager.cpython-313.pyc CHANGED
Binary files a/database/__pycache__/sqlite_manager.cpython-313.pyc and b/database/__pycache__/sqlite_manager.cpython-313.pyc differ
 
inference.py CHANGED
@@ -87,8 +87,16 @@ def main():
87
  obs = self.target.reset()
88
  return type('StepResult', (), {'observation': obs, 'reward': 0.0, 'done': False})()
89
  def step(self, action):
90
- obs, reward, done = self.target.step(action)
91
- return type('StepResult', (), {'observation': obs, 'reward': reward, 'done': done})()
 
 
 
 
 
 
 
 
92
  def close(self):
93
  pass
94
  env = DirectClient(base_env)
 
87
  obs = self.target.reset()
88
  return type('StepResult', (), {'observation': obs, 'reward': 0.0, 'done': False})()
89
  def step(self, action):
90
+ obs = self.target.step(action)
91
+ return type(
92
+ 'StepResult',
93
+ (),
94
+ {
95
+ 'observation': obs,
96
+ 'reward': obs.reward if obs.reward is not None else 0.0,
97
+ 'done': obs.done,
98
+ },
99
+ )()
100
  def close(self):
101
  pass
102
  env = DirectClient(base_env)
server/__pycache__/app.cpython-313.pyc CHANGED
Binary files a/server/__pycache__/app.cpython-313.pyc and b/server/__pycache__/app.cpython-313.pyc differ
 
server/__pycache__/environment.cpython-313.pyc CHANGED
Binary files a/server/__pycache__/environment.cpython-313.pyc and b/server/__pycache__/environment.cpython-313.pyc differ
 
server/app.py CHANGED
@@ -1,3 +1,5 @@
 
 
1
  from openenv.core.env_server import create_fastapi_app
2
  from fastapi.responses import HTMLResponse
3
  import os
@@ -8,25 +10,13 @@ from server.environment import SqlEnvironment
8
 
9
  app = create_fastapi_app(SqlEnvironment, SqlAction, SqlObservation)
10
 
 
 
11
 
12
  @app.get("/")
13
  def root():
14
- return HTMLResponse(
15
- """
16
- <html>
17
- <head><title>SQL OpenEnv Space</title></head>
18
- <body style="font-family: sans-serif; margin: 2rem;">
19
- <h2>SQL Data Analyst OpenEnv Environment</h2>
20
- <p>Environment is running.</p>
21
- <ul>
22
- <li>Health check: <a href="/health">/health</a></li>
23
- <li>OpenAPI docs: <a href="/docs">/docs</a></li>
24
- </ul>
25
- <p>Use the OpenEnv client in <code>inference.py</code> to run baseline episodes.</p>
26
- </body>
27
- </html>
28
- """
29
- )
30
 
31
 
32
  @app.get("/health")
 
1
+ from pathlib import Path
2
+
3
  from openenv.core.env_server import create_fastapi_app
4
  from fastapi.responses import HTMLResponse
5
  import os
 
10
 
11
  app = create_fastapi_app(SqlEnvironment, SqlAction, SqlObservation)
12
 
13
+ _PLAYGROUND_HTML = Path(__file__).resolve().parent / "playground.html"
14
+
15
 
16
  @app.get("/")
17
  def root():
18
+ html = _PLAYGROUND_HTML.read_text(encoding="utf-8")
19
+ return HTMLResponse(html)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
 
22
  @app.get("/health")
server/environment.py CHANGED
@@ -1,5 +1,4 @@
1
  import uuid
2
- from typing import Tuple
3
 
4
  from openenv.core.env_server import Environment
5
  from models import SqlAction, SqlObservation, SqlState, SqlReward
@@ -35,13 +34,17 @@ class SqlEnvironment(Environment):
35
  difficulty=first_task.difficulty_label,
36
  )
37
 
 
38
  def state(self) -> SqlState:
39
  return self._state
40
 
41
- def step(self, action: SqlAction) -> Tuple[SqlObservation, float, bool]:
42
  self._state.step_count += 1
43
  query = action.query.strip()
44
  current_idx = self._state.current_task_index
 
 
 
45
  task = self.task_registry.get_task(current_idx)
46
  self._state.attempts_per_task[current_idx] += 1
47
 
@@ -135,6 +138,8 @@ class SqlEnvironment(Environment):
135
  execution_error=error_str,
136
  task_score=score,
137
  grader_feedback=grader_feedback,
 
 
138
  )
139
-
140
- return obs, reward.total, done
 
1
  import uuid
 
2
 
3
  from openenv.core.env_server import Environment
4
  from models import SqlAction, SqlObservation, SqlState, SqlReward
 
34
  difficulty=first_task.difficulty_label,
35
  )
36
 
37
+ @property
38
  def state(self) -> SqlState:
39
  return self._state
40
 
41
+ def step(self, action: SqlAction) -> SqlObservation:
42
  self._state.step_count += 1
43
  query = action.query.strip()
44
  current_idx = self._state.current_task_index
45
+ n_tasks = self.task_registry.get_total_tasks()
46
+ while len(self._state.attempts_per_task) < n_tasks:
47
+ self._state.attempts_per_task.append(0)
48
  task = self.task_registry.get_task(current_idx)
49
  self._state.attempts_per_task[current_idx] += 1
50
 
 
138
  execution_error=error_str,
139
  task_score=score,
140
  grader_feedback=grader_feedback,
141
+ reward=reward.total,
142
+ done=done,
143
  )
144
+
145
+ return obs
server/playground.html ADDED
@@ -0,0 +1,477 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>SQL Analyst OpenEnv · Playground</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,700;1,6..72,400&display=swap" rel="stylesheet" />
10
+ <style>
11
+ :root {
12
+ --bg: #0f1419;
13
+ --surface: #1a222d;
14
+ --border: #2d3a4a;
15
+ --text: #e7ecf3;
16
+ --muted: #8b9cb3;
17
+ --accent: #3ddc97;
18
+ --warn: #f5a623;
19
+ --err: #ff6b6b;
20
+ --sql-bg: #121820;
21
+ }
22
+ * { box-sizing: border-box; }
23
+ body {
24
+ margin: 0;
25
+ min-height: 100vh;
26
+ font-family: "Newsreader", Georgia, serif;
27
+ background: var(--bg);
28
+ color: var(--text);
29
+ background-image:
30
+ radial-gradient(ellipse 120% 80% at 100% -20%, rgba(61, 220, 151, 0.08), transparent),
31
+ radial-gradient(ellipse 80% 50% at 0% 100%, rgba(45, 58, 74, 0.5), transparent);
32
+ }
33
+ .wrap {
34
+ max-width: 1180px;
35
+ margin: 0 auto;
36
+ padding: 1.75rem 1.25rem 3rem;
37
+ }
38
+ header {
39
+ display: flex;
40
+ flex-wrap: wrap;
41
+ align-items: baseline;
42
+ justify-content: space-between;
43
+ gap: 1rem;
44
+ margin-bottom: 1.5rem;
45
+ border-bottom: 1px solid var(--border);
46
+ padding-bottom: 1rem;
47
+ }
48
+ h1 {
49
+ font-size: 1.65rem;
50
+ font-weight: 700;
51
+ margin: 0;
52
+ letter-spacing: -0.02em;
53
+ }
54
+ .tag {
55
+ font-family: "JetBrains Mono", monospace;
56
+ font-size: 0.7rem;
57
+ text-transform: uppercase;
58
+ letter-spacing: 0.12em;
59
+ color: var(--accent);
60
+ border: 1px solid var(--border);
61
+ padding: 0.25rem 0.5rem;
62
+ border-radius: 4px;
63
+ }
64
+ .links a {
65
+ color: var(--muted);
66
+ font-size: 0.9rem;
67
+ margin-left: 1rem;
68
+ }
69
+ .links a:hover { color: var(--accent); }
70
+ .grid {
71
+ display: grid;
72
+ grid-template-columns: 1fr 1fr;
73
+ gap: 1rem;
74
+ }
75
+ @media (max-width: 900px) { .grid { grid-template-columns: 1fr; } }
76
+ .panel {
77
+ background: var(--surface);
78
+ border: 1px solid var(--border);
79
+ border-radius: 10px;
80
+ padding: 1rem 1.1rem;
81
+ }
82
+ .panel h2 {
83
+ font-size: 0.85rem;
84
+ font-weight: 700;
85
+ text-transform: uppercase;
86
+ letter-spacing: 0.06em;
87
+ color: var(--muted);
88
+ margin: 0 0 0.75rem;
89
+ }
90
+ .task-meta {
91
+ display: flex;
92
+ gap: 0.5rem;
93
+ flex-wrap: wrap;
94
+ margin-bottom: 0.75rem;
95
+ }
96
+ .pill {
97
+ font-family: "JetBrains Mono", monospace;
98
+ font-size: 0.72rem;
99
+ padding: 0.2rem 0.5rem;
100
+ border-radius: 4px;
101
+ background: var(--sql-bg);
102
+ border: 1px solid var(--border);
103
+ }
104
+ .pill.diff-easy { color: var(--accent); }
105
+ .pill.diff-medium { color: var(--warn); }
106
+ .pill.diff-hard { color: var(--err); }
107
+ pre.schema, .instruction {
108
+ font-size: 0.95rem;
109
+ line-height: 1.55;
110
+ white-space: pre-wrap;
111
+ word-break: break-word;
112
+ margin: 0;
113
+ color: var(--muted);
114
+ }
115
+ .instruction { color: var(--text); }
116
+ textarea#sql {
117
+ width: 100%;
118
+ min-height: 140px;
119
+ font-family: "JetBrains Mono", monospace;
120
+ font-size: 0.82rem;
121
+ line-height: 1.5;
122
+ background: var(--sql-bg);
123
+ color: var(--text);
124
+ border: 1px solid var(--border);
125
+ border-radius: 8px;
126
+ padding: 0.85rem;
127
+ resize: vertical;
128
+ }
129
+ textarea#sql:focus {
130
+ outline: none;
131
+ border-color: var(--accent);
132
+ box-shadow: 0 0 0 1px rgba(61, 220, 151, 0.25);
133
+ }
134
+ .actions {
135
+ display: flex;
136
+ flex-wrap: wrap;
137
+ gap: 0.6rem;
138
+ margin-top: 0.75rem;
139
+ }
140
+ button {
141
+ font-family: "JetBrains Mono", monospace;
142
+ font-size: 0.78rem;
143
+ padding: 0.55rem 1rem;
144
+ border-radius: 6px;
145
+ border: 1px solid var(--border);
146
+ cursor: pointer;
147
+ background: var(--surface);
148
+ color: var(--text);
149
+ }
150
+ button.primary {
151
+ background: rgba(61, 220, 151, 0.15);
152
+ border-color: var(--accent);
153
+ color: var(--accent);
154
+ }
155
+ button:hover { filter: brightness(1.12); }
156
+ button:disabled { opacity: 0.45; cursor: not-allowed; }
157
+ .metrics {
158
+ display: grid;
159
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
160
+ gap: 0.5rem;
161
+ margin-top: 0.75rem;
162
+ }
163
+ .metric {
164
+ background: var(--sql-bg);
165
+ border: 1px solid var(--border);
166
+ border-radius: 6px;
167
+ padding: 0.5rem 0.65rem;
168
+ }
169
+ .metric label {
170
+ display: block;
171
+ font-size: 0.65rem;
172
+ text-transform: uppercase;
173
+ letter-spacing: 0.08em;
174
+ color: var(--muted);
175
+ }
176
+ .metric span {
177
+ font-family: "JetBrains Mono", monospace;
178
+ font-size: 1rem;
179
+ }
180
+ .error { color: var(--err); font-size: 0.9rem; margin-top: 0.5rem; }
181
+ .feedback { color: var(--warn); font-size: 0.9rem; margin-top: 0.35rem; }
182
+ table.result {
183
+ width: 100%;
184
+ border-collapse: collapse;
185
+ font-family: "JetBrains Mono", monospace;
186
+ font-size: 0.75rem;
187
+ margin-top: 0.5rem;
188
+ }
189
+ table.result th, table.result td {
190
+ border: 1px solid var(--border);
191
+ padding: 0.35rem 0.5rem;
192
+ text-align: left;
193
+ }
194
+ table.result th { background: var(--sql-bg); color: var(--muted); }
195
+ .log {
196
+ font-family: "JetBrains Mono", monospace;
197
+ font-size: 0.72rem;
198
+ line-height: 1.5;
199
+ max-height: 220px;
200
+ overflow-y: auto;
201
+ background: var(--sql-bg);
202
+ border: 1px solid var(--border);
203
+ border-radius: 8px;
204
+ padding: 0.65rem;
205
+ color: var(--muted);
206
+ white-space: pre-wrap;
207
+ word-break: break-all;
208
+ }
209
+ .full { grid-column: 1 / -1; }
210
+ .status-bar {
211
+ font-family: "JetBrains Mono", monospace;
212
+ font-size: 0.72rem;
213
+ color: var(--muted);
214
+ margin-top: 0.5rem;
215
+ }
216
+ .status-bar.ok { color: var(--accent); }
217
+ .status-bar.bad { color: var(--err); }
218
+ </style>
219
+ </head>
220
+ <body>
221
+ <div class="wrap">
222
+ <header>
223
+ <div>
224
+ <span class="tag">OpenEnv</span>
225
+ <h1>SQL data analyst playground</h1>
226
+ </div>
227
+ <div class="links">
228
+ <a href="/docs">API docs</a>
229
+ <a href="/health">Health</a>
230
+ </div>
231
+ </header>
232
+ <p class="status-bar" style="margin:-0.5rem 0 1.25rem;">
233
+ Interactive mode uses a persistent <strong>WebSocket</strong> session at <code>/ws</code>
234
+ (OpenEnv HTTP <code>/step</code> is stateless). Connect, then run reset → SQL steps.
235
+ </p>
236
+
237
+ <div class="grid">
238
+ <div class="panel">
239
+ <h2>Current task</h2>
240
+ <div class="task-meta">
241
+ <span id="taskId" class="pill">—</span>
242
+ <span id="difficulty" class="pill">—</span>
243
+ </div>
244
+ <p id="instruction" class="instruction">Click “Start episode” to load the first task.</p>
245
+ </div>
246
+ <div class="panel">
247
+ <h2>Schema</h2>
248
+ <pre id="schema" class="schema">—</pre>
249
+ </div>
250
+
251
+ <div class="panel full">
252
+ <h2>Your SQL</h2>
253
+ <textarea id="sql" placeholder="SELECT ..."></textarea>
254
+ <div class="actions">
255
+ <button type="button" class="primary" id="btnConnect">Connect session</button>
256
+ <button type="button" class="primary" id="btnReset" disabled>Start episode (reset)</button>
257
+ <button type="button" class="primary" id="btnStep" disabled>Run query (step)</button>
258
+ <button type="button" id="btnState" disabled>Refresh state</button>
259
+ </div>
260
+ <div id="reqStatus" class="status-bar">Disconnected — click “Connect session”.</div>
261
+ </div>
262
+
263
+ <div class="panel">
264
+ <h2>Last step</h2>
265
+ <div class="metrics">
266
+ <div class="metric"><label>Reward</label><span id="reward">—</span></div>
267
+ <div class="metric"><label>Task score</label><span id="score">—</span></div>
268
+ <div class="metric"><label>Episode done</label><span id="done">—</span></div>
269
+ </div>
270
+ <p id="graderFb" class="feedback" hidden></p>
271
+ <p id="execErr" class="error" hidden></p>
272
+ </div>
273
+ <div class="panel">
274
+ <h2>Result preview</h2>
275
+ <div id="resultTable"></div>
276
+ </div>
277
+
278
+ <div class="panel full">
279
+ <h2>Episode log</h2>
280
+ <div id="log" class="log"></div>
281
+ </div>
282
+ </div>
283
+ </div>
284
+
285
+ <script>
286
+ const $ = (id) => document.getElementById(id);
287
+ const logEl = $("log");
288
+ function log(msg) {
289
+ const t = new Date().toISOString().slice(11, 19);
290
+ logEl.textContent += "[" + t + "] " + msg + "\n";
291
+ logEl.scrollTop = logEl.scrollHeight;
292
+ }
293
+ function setDifficultyClass(el, d) {
294
+ el.className = "pill diff-" + (d || "").toLowerCase();
295
+ }
296
+ function renderObservation(obs) {
297
+ if (!obs) return;
298
+ $("taskId").textContent = obs.task_id || "—";
299
+ const diff = obs.difficulty || "";
300
+ const diffEl = $("difficulty");
301
+ diffEl.textContent = diff || "—";
302
+ setDifficultyClass(diffEl, diff);
303
+ $("instruction").textContent = obs.current_task_instruction || "";
304
+ $("schema").textContent = obs.schema_info || "—";
305
+ }
306
+ function renderTableFromJson(jsonStr) {
307
+ const host = $("resultTable");
308
+ host.innerHTML = "";
309
+ if (!jsonStr) {
310
+ host.textContent = "No rows yet.";
311
+ return;
312
+ }
313
+ let rows;
314
+ try { rows = JSON.parse(jsonStr); } catch (e) {
315
+ host.textContent = String(jsonStr).slice(0, 2000);
316
+ return;
317
+ }
318
+ if (!Array.isArray(rows) || rows.length === 0) {
319
+ host.textContent = "0 rows.";
320
+ return;
321
+ }
322
+ const cols = Object.keys(rows[0]);
323
+ const table = document.createElement("table");
324
+ table.className = "result";
325
+ const thead = document.createElement("thead");
326
+ const trh = document.createElement("tr");
327
+ cols.forEach((c) => {
328
+ const th = document.createElement("th");
329
+ th.textContent = c;
330
+ trh.appendChild(th);
331
+ });
332
+ thead.appendChild(trh);
333
+ table.appendChild(thead);
334
+ const tbody = document.createElement("tbody");
335
+ rows.slice(0, 50).forEach((row) => {
336
+ const tr = document.createElement("tr");
337
+ cols.forEach((c) => {
338
+ const td = document.createElement("td");
339
+ const v = row[c];
340
+ td.textContent = v === null || v === undefined ? "" : String(v);
341
+ tr.appendChild(td);
342
+ });
343
+ tbody.appendChild(tr);
344
+ });
345
+ table.appendChild(tbody);
346
+ host.appendChild(table);
347
+ if (rows.length > 50) {
348
+ const note = document.createElement("p");
349
+ note.className = "status-bar";
350
+ note.textContent = "Showing first 50 rows.";
351
+ host.appendChild(note);
352
+ }
353
+ }
354
+ function setStatus(el, ok, text) {
355
+ el.textContent = text;
356
+ el.className = "status-bar " + (ok ? "ok" : "bad");
357
+ }
358
+
359
+ let socket = null;
360
+
361
+ function setConnected(connected) {
362
+ $("btnReset").disabled = !connected;
363
+ $("btnStep").disabled = !connected;
364
+ $("btnState").disabled = !connected;
365
+ $("btnConnect").textContent = connected ? "Reconnect" : "Connect session";
366
+ }
367
+
368
+ function handleWsMessage(raw) {
369
+ let msg;
370
+ try {
371
+ msg = JSON.parse(raw);
372
+ } catch (e) {
373
+ log("parse error: " + e);
374
+ return;
375
+ }
376
+ const status = $("reqStatus");
377
+ if (msg.type === "error") {
378
+ setStatus(status, false, msg.data && msg.data.message ? msg.data.message : "Server error");
379
+ log("error: " + JSON.stringify(msg.data));
380
+ return;
381
+ }
382
+ if (msg.type === "observation") {
383
+ const pack = msg.data || {};
384
+ const obs = pack.observation;
385
+ const rew = pack.reward;
386
+ const done = pack.done;
387
+ setStatus(status, true, "OK");
388
+ log("observation reward=" + rew + " done=" + done);
389
+ $("reward").textContent = rew != null ? Number(rew).toFixed(3) : "—";
390
+ $("done").textContent = String(!!done);
391
+ $("score").textContent = obs && obs.task_score != null ? Number(obs.task_score).toFixed(3) : "—";
392
+ renderObservation(obs);
393
+ renderTableFromJson(obs && obs.execution_result);
394
+ const fb = $("graderFb");
395
+ if (obs && obs.grader_feedback) {
396
+ fb.textContent = obs.grader_feedback;
397
+ fb.hidden = false;
398
+ } else { fb.hidden = true; }
399
+ const er = $("execErr");
400
+ if (obs && obs.execution_error) {
401
+ er.textContent = obs.execution_error;
402
+ er.hidden = false;
403
+ } else { er.hidden = true; }
404
+ return;
405
+ }
406
+ if (msg.type === "state") {
407
+ setStatus(status, true, "State received (see log).");
408
+ log("state: " + JSON.stringify(msg.data).slice(0, 2000));
409
+ return;
410
+ }
411
+ log("unknown msg: " + raw.slice(0, 500));
412
+ }
413
+
414
+ function connectWs() {
415
+ if (socket && socket.readyState === WebSocket.OPEN) {
416
+ socket.close();
417
+ }
418
+ const proto = location.protocol === "https:" ? "wss:" : "ws:";
419
+ const url = proto + "//" + location.host + "/ws";
420
+ setStatus($("reqStatus"), true, "Connecting…");
421
+ socket = new WebSocket(url);
422
+ socket.onopen = function () {
423
+ setConnected(true);
424
+ setStatus($("reqStatus"), true, "WebSocket connected. Run reset to start.");
425
+ log("WebSocket open " + url);
426
+ };
427
+ socket.onclose = function () {
428
+ setConnected(false);
429
+ setStatus($("reqStatus"), false, "Disconnected.");
430
+ log("WebSocket closed");
431
+ };
432
+ socket.onerror = function () {
433
+ setStatus($("reqStatus"), false, "WebSocket error.");
434
+ log("WebSocket error");
435
+ };
436
+ socket.onmessage = function (ev) {
437
+ handleWsMessage(ev.data);
438
+ };
439
+ }
440
+
441
+ function wsSend(obj) {
442
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
443
+ setStatus($("reqStatus"), false, "Not connected.");
444
+ return;
445
+ }
446
+ socket.send(JSON.stringify(obj));
447
+ }
448
+
449
+ $("btnConnect").addEventListener("click", connectWs);
450
+
451
+ $("btnReset").addEventListener("click", function () {
452
+ wsSend({ type: "reset", data: {} });
453
+ $("graderFb").hidden = true;
454
+ $("execErr").hidden = true;
455
+ renderTableFromJson(null);
456
+ log("sent reset");
457
+ });
458
+
459
+ $("btnStep").addEventListener("click", function () {
460
+ const q = $("sql").value.trim();
461
+ if (!q) {
462
+ setStatus($("reqStatus"), false, "Enter a SQL query.");
463
+ return;
464
+ }
465
+ wsSend({ type: "step", data: { query: q } });
466
+ log("sent step");
467
+ });
468
+
469
+ $("btnState").addEventListener("click", function () {
470
+ wsSend({ type: "state" });
471
+ log("sent state");
472
+ });
473
+
474
+ log("Open the Space, click Connect session, then Reset → run SQL.");
475
+ </script>
476
+ </body>
477
+ </html>