AzraelH commited on
Commit
c812e62
·
1 Parent(s): 40c10a7

Add engineer manager app

Browse files
Files changed (17) hide show
  1. .gitignore +2 -0
  2. Dockerfile +17 -0
  3. README.md +46 -0
  4. __init__.py +10 -0
  5. app.py +606 -0
  6. client.py +35 -0
  7. focus_resource_env.py +373 -0
  8. models.py +35 -0
  9. openenv.yaml +6 -0
  10. pyproject.toml +25 -0
  11. run_sim.py +121 -0
  12. server/__init__.py +1 -0
  13. server/app.py +48 -0
  14. server/engineer_manager_environment.py +107 -0
  15. styles.css +176 -0
  16. uv.lock +2 -0
  17. validate-submission.sh +139 -0
.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
Dockerfile ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN pip install --no-cache-dir \
6
+ fastapi \
7
+ numpy \
8
+ "openenv-core[core]" \
9
+ pydantic \
10
+ streamlit \
11
+ uvicorn
12
+
13
+ COPY . /app
14
+
15
+ EXPOSE 8000
16
+
17
+ CMD ["python", "-m", "server.app", "--host", "0.0.0.0", "--port", "8000"]
README.md ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Engineer Manager Environment Server
3
+ emoji: "🗂️"
4
+ colorFrom: yellow
5
+ colorTo: yellow
6
+ sdk: docker
7
+ pinned: false
8
+ app_port: 8000
9
+ base_path: /web
10
+ tags:
11
+ - openenv
12
+ - scheduling
13
+ - reinforcement-learning
14
+ ---
15
+
16
+ # Engineer Manager Environment
17
+
18
+ `Engineer Manager` is an OpenEnv-compatible scheduling simulator for balancing deep work, meetings, and communication load across a workday.
19
+
20
+ ## What it exposes
21
+
22
+ - `POST /reset` to start a fresh episode
23
+ - `POST /step` to apply a scheduling action
24
+ - `GET /state` for the current session state
25
+ - `GET /schema` for the action and observation schemas
26
+ - `GET /web` for the built-in OpenEnv web UI
27
+
28
+ ## Local usage
29
+
30
+ ```bash
31
+ python -m server.app --port 8000
32
+ openenv validate .
33
+ openenv validate http://127.0.0.1:8000
34
+ ```
35
+
36
+ ## Action model
37
+
38
+ - `target_slot`: target half-hour slot
39
+ - `operation`: `0` idle, `1` schedule work, `2` reschedule meeting, `3` mute comms
40
+
41
+ ## Observation highlights
42
+
43
+ - `timeline`: day plan encoded as empty/work/meeting slots
44
+ - `task_buffer`: pending tasks with estimated duration and hidden complexity
45
+ - `flow_score`, `social_debt`, `calendar_churn`: core scoring metrics
46
+ - `current_slot`, `current_time`, `recovery_state`, `mute_comms`: live execution state
__init__.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """Engineer Manager OpenEnv package."""
2
+
3
+ from .client import EngineerManagerEnv
4
+ from .models import EngineerManagerAction, EngineerManagerObservation
5
+
6
+ __all__ = [
7
+ "EngineerManagerAction",
8
+ "EngineerManagerEnv",
9
+ "EngineerManagerObservation",
10
+ ]
app.py ADDED
@@ -0,0 +1,606 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Dict, List, Optional
5
+
6
+ import streamlit as st
7
+
8
+ from focus_resource_env import (
9
+ DEEP_WORK,
10
+ EMPTY,
11
+ OP_IDLE,
12
+ OP_MUTE_COMMS,
13
+ OP_RESCHEDULE_MEETING,
14
+ FocusResourceEnv,
15
+ Task,
16
+ )
17
+
18
+ BLOCK_TYPES = ["Focus", "Meeting"]
19
+ COMPLEXITY_OPTIONS = [1.0, 1.25, 1.5, 1.75]
20
+ DEFAULT_TASK_NAMES = ["Architecture", "Review", "Execution"]
21
+
22
+
23
+ def inject_styles() -> None:
24
+ theme_tokens = {
25
+ "__BG0__": "#050816",
26
+ "__BG1__": "#0b1224",
27
+ "__LINE__": "rgba(159, 184, 255, 0.16)",
28
+ "__TEXT__": "#edf3ff",
29
+ "__MUTED__": "#96a7cb",
30
+ "__ACCENT__": "#7ce7ff",
31
+ "__ACCENT2__": "#7cf0c5",
32
+ "__BUTTON_TEXT__": "#06131c",
33
+ "__METRIC_BG__": "linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02))",
34
+ "__SHADOW__": "0 14px 40px rgba(0, 0, 0, 0.22)",
35
+ "__INPUT_BG__": "rgba(255,255,255,0.04)",
36
+ "__HERO_BG__": "linear-gradient(135deg, rgba(16, 26, 52, 0.94), rgba(11, 20, 38, 0.94))",
37
+ }
38
+ with open("styles.css", encoding="utf-8") as css_file:
39
+ css = css_file.read()
40
+ for key, value in theme_tokens.items():
41
+ css = css.replace(key, value)
42
+ st.markdown(f"<style>{css}</style>", unsafe_allow_html=True)
43
+
44
+
45
+ def friendly_error(exc: Exception) -> str:
46
+ if "end_hour must be after start_hour" in str(exc):
47
+ return "End time must be later than start time."
48
+ return "Those control panel settings do not fit together yet. Try adjusting the workday range and resetting the studio."
49
+
50
+
51
+ def create_env(start_hour: str, end_hour: str, distraction_risk: float, seed: int) -> FocusResourceEnv:
52
+ return FocusResourceEnv(
53
+ start_hour=start_hour,
54
+ end_hour=end_hour,
55
+ distraction_risk=distraction_risk,
56
+ seed=seed,
57
+ )
58
+
59
+
60
+ def default_task_name(index: int) -> str:
61
+ return DEFAULT_TASK_NAMES[index] if index < len(DEFAULT_TASK_NAMES) else f"Task {index + 1}"
62
+
63
+
64
+ def init_state() -> None:
65
+ st.session_state.setdefault("ui_error", "")
66
+ st.session_state.setdefault("ui_error_until", 0.0)
67
+ st.session_state.setdefault("start_hour", "09:00")
68
+ st.session_state.setdefault("end_hour", "17:00")
69
+ st.session_state.setdefault("distraction_risk", 0.15)
70
+ st.session_state.setdefault("seed", 7)
71
+ st.session_state.setdefault("selection_start", None)
72
+ st.session_state.setdefault("selected_range", None)
73
+ st.session_state.setdefault("selected_block_id", None)
74
+ st.session_state.setdefault("move_block_id", None)
75
+ st.session_state.setdefault("armed_task_index", None)
76
+ st.session_state.setdefault("next_block_id", 1)
77
+
78
+
79
+ def build_initial_blocks(env: FocusResourceEnv) -> List[dict]:
80
+ blocks: List[dict] = []
81
+ seen_meetings = set()
82
+ for _, meta in sorted(env.meeting_meta.items()):
83
+ meeting_id = meta["meeting_id"]
84
+ if meeting_id in seen_meetings:
85
+ continue
86
+ seen_meetings.add(meeting_id)
87
+ blocks.append(
88
+ {
89
+ "id": f"meeting-{meeting_id}",
90
+ "start": int(meta["start"]),
91
+ "end": int(meta["start"] + meta["length"] - 1),
92
+ "type": "Meeting",
93
+ "label": "Meeting",
94
+ "priority": int(meta["priority"]),
95
+ }
96
+ )
97
+ return sorted(blocks, key=lambda block: block["start"])
98
+
99
+
100
+ def ensure_task_names(env: FocusResourceEnv) -> None:
101
+ names = st.session_state.get("task_names", [])
102
+ st.session_state.task_names = [names[i] if i < len(names) else default_task_name(i) for i in range(len(env.task_buffer))]
103
+
104
+
105
+ def sync_from_env(env: FocusResourceEnv) -> None:
106
+ st.session_state.env = env
107
+ st.session_state.observation = env._observation()
108
+ st.session_state.done = env.current_slot >= env.timeline_length
109
+ st.session_state.setdefault("last_reward", 0.0)
110
+ st.session_state.setdefault("last_info", {})
111
+ ensure_task_names(env)
112
+
113
+
114
+ def get_env() -> FocusResourceEnv | None:
115
+ if "env" not in st.session_state:
116
+ try:
117
+ env = create_env(
118
+ st.session_state.get("start_hour", "09:00"),
119
+ st.session_state.get("end_hour", "17:00"),
120
+ float(st.session_state.get("distraction_risk", 0.15)),
121
+ int(st.session_state.get("seed", 7)),
122
+ )
123
+ env.reset()
124
+ sync_from_env(env)
125
+ st.session_state.blocks = build_initial_blocks(env)
126
+ except ValueError as exc:
127
+ set_ui_error(friendly_error(exc), seconds=6.0)
128
+ return None
129
+ return st.session_state.env
130
+
131
+
132
+ def set_ui_error(message: str, seconds: float = 5.0) -> None:
133
+ st.session_state.ui_error = message
134
+ st.session_state.ui_error_until = time.time() + seconds
135
+
136
+
137
+ def clear_ui_error() -> None:
138
+ st.session_state.ui_error = ""
139
+ st.session_state.ui_error_until = 0.0
140
+
141
+
142
+ def render_flash_error() -> None:
143
+ message = st.session_state.get("ui_error", "")
144
+ until = float(st.session_state.get("ui_error_until", 0.0))
145
+ if not message or time.time() >= until:
146
+ if message:
147
+ clear_ui_error()
148
+ return
149
+ remaining_ms = max(0, int((until - time.time()) * 1000))
150
+ st.markdown(
151
+ f"""
152
+ <div id="focus-studio-error" style="
153
+ margin: 0.4rem 0 1rem 0;
154
+ padding: 0.9rem 1rem;
155
+ border-radius: 16px;
156
+ border: 1px solid rgba(255, 120, 145, 0.25);
157
+ background: linear-gradient(180deg, rgba(90, 19, 33, 0.88), rgba(60, 15, 25, 0.88));
158
+ color: #ffe8ed;
159
+ box-shadow: 0 14px 32px rgba(0, 0, 0, 0.22);
160
+ ">
161
+ <strong>Check the plan</strong><br>{message}
162
+ </div>
163
+ <script>
164
+ setTimeout(function() {{
165
+ const el = window.parent.document.getElementById("focus-studio-error");
166
+ if (el) {{
167
+ el.style.transition = "opacity 220ms ease";
168
+ el.style.opacity = "0";
169
+ setTimeout(function() {{ if (el) el.remove(); }}, 240);
170
+ }}
171
+ }}, {remaining_ms});
172
+ </script>
173
+ """,
174
+ unsafe_allow_html=True,
175
+ )
176
+
177
+
178
+ def reset_env(start_hour: str, end_hour: str, distraction_risk: float, seed: int) -> None:
179
+ env = create_env(start_hour, end_hour, distraction_risk, seed)
180
+ env.reset()
181
+ st.session_state.start_hour = start_hour
182
+ st.session_state.end_hour = end_hour
183
+ st.session_state.distraction_risk = float(distraction_risk)
184
+ st.session_state.seed = int(seed)
185
+ st.session_state.last_reward = 0.0
186
+ st.session_state.last_info = {}
187
+ clear_ui_error()
188
+ st.session_state.selection_start = None
189
+ st.session_state.selected_range = None
190
+ st.session_state.selected_block_id = None
191
+ st.session_state.move_block_id = None
192
+ st.session_state.armed_task_index = None
193
+ st.session_state.blocks = build_initial_blocks(env)
194
+ sync_from_env(env)
195
+ st.rerun()
196
+
197
+
198
+ def sync_tasks_from_widgets() -> None:
199
+ env = st.session_state.env
200
+ new_buffer: List[Task] = []
201
+ new_names: List[str] = []
202
+ for i, task in enumerate(env.task_buffer):
203
+ name = str(st.session_state.get(f"task_name_{i}", default_task_name(i))).strip() or default_task_name(i)
204
+ slots = max(1, int(st.session_state.get(f"task_slots_{i}", task.duration)))
205
+ complexity = float(st.session_state.get(f"task_complexity_{i}", task.hidden_complexity))
206
+ new_buffer.append(Task(duration=slots, hidden_complexity=complexity))
207
+ new_names.append(name)
208
+ env.task_buffer = new_buffer
209
+ st.session_state.task_names = new_names
210
+
211
+
212
+ def block_at_slot(slot: int) -> Optional[dict]:
213
+ for block in st.session_state.get("blocks", []):
214
+ if block["start"] <= slot <= block["end"]:
215
+ return block
216
+ return None
217
+
218
+
219
+ def sort_blocks() -> None:
220
+ st.session_state.blocks = sorted(st.session_state.get("blocks", []), key=lambda item: item["start"])
221
+
222
+
223
+ def remove_overlaps(start: int, end: int, ignore_id: Optional[str] = None) -> None:
224
+ kept = []
225
+ for block in st.session_state.get("blocks", []):
226
+ overlaps = not (block["end"] < start or block["start"] > end)
227
+ if overlaps and block["id"] != ignore_id:
228
+ continue
229
+ kept.append(block)
230
+ st.session_state.blocks = kept
231
+
232
+
233
+ def apply_blocks_to_env() -> None:
234
+ env = st.session_state.env
235
+ env.timeline[:] = EMPTY
236
+ env.meeting_meta = {}
237
+ for block in st.session_state.get("blocks", []):
238
+ if block["type"] == "Focus":
239
+ env.timeline[block["start"] : block["end"] + 1] = DEEP_WORK
240
+ else:
241
+ env._place_meeting(
242
+ block["start"],
243
+ block["end"] - block["start"] + 1,
244
+ int(block.get("priority", 5)),
245
+ env._next_meeting_id(),
246
+ )
247
+ sync_from_env(env)
248
+
249
+
250
+ def clear_selection() -> None:
251
+ st.session_state.selection_start = None
252
+ st.session_state.selected_range = None
253
+ st.session_state.selected_block_id = None
254
+ st.session_state.move_block_id = None
255
+
256
+
257
+ def create_block(start: int, end: int, block_type: str, label: str, priority: int) -> None:
258
+ start, end = sorted((start, end))
259
+ remove_overlaps(start, end)
260
+ st.session_state.blocks.append(
261
+ {
262
+ "id": f"user-{st.session_state.get('next_block_id', 1)}",
263
+ "start": start,
264
+ "end": end,
265
+ "type": block_type,
266
+ "label": label.strip() or block_type,
267
+ "priority": int(priority),
268
+ }
269
+ )
270
+ st.session_state.next_block_id = int(st.session_state.get("next_block_id", 1)) + 1
271
+ sort_blocks()
272
+ apply_blocks_to_env()
273
+ clear_selection()
274
+ st.rerun()
275
+
276
+
277
+ def update_block(block_id: str, block_type: str, label: str, priority: int) -> None:
278
+ for block in st.session_state.get("blocks", []):
279
+ if block["id"] == block_id:
280
+ block["type"] = block_type
281
+ block["label"] = label.strip() or block_type
282
+ block["priority"] = int(priority)
283
+ break
284
+ apply_blocks_to_env()
285
+ st.rerun()
286
+
287
+
288
+ def delete_block(block_id: str) -> None:
289
+ st.session_state.blocks = [block for block in st.session_state.get("blocks", []) if block["id"] != block_id]
290
+ apply_blocks_to_env()
291
+ clear_selection()
292
+ st.rerun()
293
+
294
+
295
+ def move_block(block_id: str, new_start: int) -> None:
296
+ block = next((item for item in st.session_state.get("blocks", []) if item["id"] == block_id), None)
297
+ if block is None:
298
+ return
299
+ duration = block["end"] - block["start"]
300
+ new_end = min(st.session_state.env.timeline_length - 1, new_start + duration)
301
+ new_start = max(0, new_end - duration)
302
+ remove_overlaps(new_start, new_end, ignore_id=block_id)
303
+ block["start"] = new_start
304
+ block["end"] = new_end
305
+ sort_blocks()
306
+ apply_blocks_to_env()
307
+ st.session_state.move_block_id = None
308
+ st.session_state.selected_block_id = block_id
309
+ st.rerun()
310
+
311
+
312
+ def arm_task(index: int) -> None:
313
+ sync_tasks_from_widgets()
314
+ st.session_state.armed_task_index = index
315
+
316
+
317
+ def place_armed_task(start: int, end: int) -> None:
318
+ env = st.session_state.env
319
+ sync_tasks_from_widgets()
320
+ task_index = st.session_state.get("armed_task_index")
321
+ if task_index is None or not (0 <= task_index < len(env.task_buffer)):
322
+ set_ui_error("Pick a task from the queue before placing it on the calendar.")
323
+ return
324
+ task = env.task_buffer.pop(task_index)
325
+ task_name = st.session_state.task_names.pop(task_index)
326
+ desired = max(1, int(round(task.duration * task.hidden_complexity)))
327
+ start, end = sorted((start, end))
328
+ end = min(env.timeline_length - 1, max(end, start + desired - 1))
329
+ st.session_state.armed_task_index = None
330
+ create_block(start, end, "Focus", task_name, 5)
331
+
332
+
333
+ def run_simulation_step(operation: int) -> None:
334
+ env = st.session_state.env
335
+ sync_tasks_from_widgets()
336
+ apply_blocks_to_env()
337
+ current_slot = int(env.current_slot)
338
+ target_slot = current_slot
339
+ if operation == OP_RESCHEDULE_MEETING:
340
+ future_meetings = [block for block in st.session_state.get("blocks", []) if block["type"] == "Meeting" and block["start"] >= current_slot]
341
+ if not future_meetings:
342
+ set_ui_error("No future meeting is available to move right now.")
343
+ return
344
+ target_slot = future_meetings[0]["start"]
345
+ obs, reward, done, info = env.step((target_slot, operation))
346
+ st.session_state.observation = obs
347
+ st.session_state.last_reward = reward
348
+ st.session_state.last_info = info
349
+ st.session_state.done = done
350
+ if operation == OP_RESCHEDULE_MEETING and info.get("action_info", {}).get("status") == "meeting_rescheduled":
351
+ moved = next((block for block in st.session_state.get("blocks", []) if block["type"] == "Meeting" and block["start"] == int(info["action_info"]["from_slot"])), None)
352
+ if moved is not None:
353
+ duration = moved["end"] - moved["start"]
354
+ moved["start"] = int(info["action_info"]["to_slot"])
355
+ moved["end"] = moved["start"] + duration
356
+ sort_blocks()
357
+ st.rerun()
358
+
359
+
360
+ def render_header(observation: Dict[str, object]) -> None:
361
+ st.markdown(
362
+ """
363
+ <div class="hero">
364
+ <div class="hero-kicker">Cognitive Resource Simulator</div>
365
+ <h1>engineer-manager</h1>
366
+ <p>Design the day directly on the calendar, lock in meaningful work blocks, and pressure-test the plan against interruption cost with a cleaner single-source workflow.</p>
367
+ <span class="help-chip">One interface, one truth</span>
368
+ <span class="help-chip">Blocks keep their own type and label</span>
369
+ <span class="help-chip">The task queue never rewrites scheduled work</span>
370
+ </div>
371
+ """,
372
+ unsafe_allow_html=True,
373
+ )
374
+ a, b, c, d = st.columns(4)
375
+ a.metric("Current time", observation["current_time"])
376
+ b.metric("Current slot", str(observation["current_slot"]))
377
+ c.metric("Focus Fortress", "Active" if observation["mute_comms"] else "Inactive")
378
+ d.metric("Last reward", f"{st.session_state.get('last_reward', 0.0):.2f}")
379
+
380
+
381
+ def render_setup() -> None:
382
+ st.markdown("### Control Panel")
383
+ start_hour = st.text_input("Start", value=st.session_state.get("start_hour", "09:00"), key="setup_start")
384
+ end_hour = st.text_input("End", value=st.session_state.get("end_hour", "17:00"), key="setup_end")
385
+ risk = st.slider(
386
+ "Distraction risk",
387
+ min_value=0.0,
388
+ max_value=1.0,
389
+ value=float(st.session_state.get("distraction_risk", 0.15)),
390
+ step=0.05,
391
+ help="Higher values create noisier days and lower uninterrupted focus potential.",
392
+ key="setup_risk",
393
+ )
394
+ seed = st.number_input(
395
+ "Seed",
396
+ min_value=0,
397
+ max_value=100000,
398
+ value=int(st.session_state.get("seed", 7)),
399
+ step=1,
400
+ help="? Scenario Consistency: Use a fixed Seed number to regenerate this exact daily challenge for comparison testing.",
401
+ key="setup_seed",
402
+ )
403
+ st.caption("Professional scheduling note: the end time must be later than the start time for the studio to build a valid workday.")
404
+ if st.button("Reset Studio", use_container_width=True):
405
+ try:
406
+ reset_env(start_hour, end_hour, float(risk), int(seed))
407
+ except ValueError as exc:
408
+ set_ui_error(friendly_error(exc), seconds=6.0)
409
+
410
+
411
+ def render_calendar(env: FocusResourceEnv) -> None:
412
+ st.markdown("### Day Plan")
413
+ st.caption("Click one open slot to start a range. Click a second slot to finish it. Click an existing block to edit it or move it.")
414
+ current_slot = int(env.current_slot)
415
+ selection_start = st.session_state.get("selection_start")
416
+ selected_range = st.session_state.get("selected_range")
417
+ selected_block = next((block for block in st.session_state.get("blocks", []) if block["id"] == st.session_state.get("selected_block_id")), None)
418
+ move_block_id = st.session_state.get("move_block_id")
419
+
420
+ for slot in range(env.timeline_length):
421
+ block = block_at_slot(slot)
422
+ time_col, body_col, action_col = st.columns([1.0, 4.25, 1.95])
423
+ time_col.markdown(f"**{env._slot_label(slot)}**")
424
+ if block is None:
425
+ label = "Open"
426
+ else:
427
+ label = f"{block['type']} | {block['label']}"
428
+ if slot == current_slot:
429
+ body_col.markdown(f"`NOW` {label}")
430
+ elif selection_start is not None and min(selection_start, slot) <= slot <= max(selection_start, slot):
431
+ body_col.markdown(f"`SELECTED` {label}")
432
+ else:
433
+ body_col.markdown(label)
434
+ if move_block_id:
435
+ if action_col.button("Drop", key=f"slot_drop_{slot}", use_container_width=True, type="secondary"):
436
+ move_block(move_block_id, slot)
437
+ elif block is not None:
438
+ start_col, edit_col = action_col.columns([1, 1])
439
+ start_text = "Finish" if selection_start is not None else "Start"
440
+ if start_col.button(start_text, key=f"slot_start_{slot}", use_container_width=True, type="primary"):
441
+ if selection_start is None:
442
+ st.session_state.selection_start = slot
443
+ else:
444
+ st.session_state.selected_range = (selection_start, slot)
445
+ st.rerun()
446
+ if edit_col.button("Edit", key=f"slot_edit_{slot}", use_container_width=True, type="secondary"):
447
+ st.session_state.selected_block_id = block["id"]
448
+ st.session_state.selection_start = None
449
+ st.session_state.selected_range = None
450
+ st.rerun()
451
+ else:
452
+ button_text = "Start" if selection_start is None else "Finish"
453
+ if action_col.button(button_text, key=f"slot_open_{slot}", use_container_width=True, type="primary"):
454
+ if selection_start is None:
455
+ st.session_state.selection_start = slot
456
+ else:
457
+ st.session_state.selected_range = (selection_start, slot)
458
+ st.rerun()
459
+
460
+ if selected_range is not None:
461
+ start, end = sorted(selected_range)
462
+ st.markdown("#### New block")
463
+ block_type = st.radio("Type", options=BLOCK_TYPES, horizontal=True, key="new_block_type")
464
+ armed_task_index = st.session_state.get("armed_task_index")
465
+ if block_type == "Focus" and armed_task_index is not None and armed_task_index < len(st.session_state.get("task_names", [])):
466
+ default_label = st.session_state["task_names"][armed_task_index]
467
+ else:
468
+ default_label = ""
469
+ label = st.text_input("Label", value=default_label, key="new_block_label")
470
+ priority = st.slider("Meeting priority", 1, 10, 5, key="new_block_priority")
471
+ a, b, c = st.columns(3)
472
+ if a.button("Create block", use_container_width=True):
473
+ if block_type == "Focus" and armed_task_index is not None:
474
+ place_armed_task(start, end)
475
+ else:
476
+ create_block(start, end, block_type, label, int(priority))
477
+ if b.button("Clear to open", use_container_width=True):
478
+ remove_overlaps(start, end)
479
+ apply_blocks_to_env()
480
+ clear_selection()
481
+ st.rerun()
482
+ if c.button("Cancel selection", use_container_width=True):
483
+ clear_selection()
484
+ st.rerun()
485
+
486
+ if selected_block is not None:
487
+ st.markdown("#### Edit block")
488
+ block_type = st.radio("Block type", options=BLOCK_TYPES, index=BLOCK_TYPES.index(selected_block["type"]), horizontal=True, key="edit_block_type")
489
+ label = st.text_input("Block label", value=selected_block["label"], key="edit_block_label")
490
+ priority = st.slider("Meeting priority", 1, 10, int(selected_block.get("priority", 5)), key="edit_block_priority")
491
+ a, b, c = st.columns(3)
492
+ if a.button("Save block", use_container_width=True):
493
+ update_block(selected_block["id"], block_type, label, int(priority))
494
+ if b.button("Move block", use_container_width=True):
495
+ st.session_state.move_block_id = selected_block["id"]
496
+ st.rerun()
497
+ if c.button("Delete block", use_container_width=True):
498
+ delete_block(selected_block["id"])
499
+ if st.button("Close inspector", use_container_width=True):
500
+ clear_selection()
501
+ st.rerun()
502
+
503
+
504
+ def render_task_queue(env: FocusResourceEnv) -> None:
505
+ st.markdown("### Task Queue")
506
+ sync_tasks_from_widgets()
507
+ if not env.task_buffer:
508
+ st.info("The queue is empty. Add a task to keep planning.")
509
+ for i, task in enumerate(env.task_buffer):
510
+ cols = st.columns([2.0, 0.78, 0.92, 1.38, 0.52])
511
+ cols[0].text_input("Task", value=st.session_state.get("task_names", [default_task_name(i)])[i], key=f"task_name_{i}", label_visibility="collapsed")
512
+ cols[1].number_input("Slots", min_value=1, max_value=12, value=int(task.duration), key=f"task_slots_{i}", label_visibility="collapsed")
513
+ current_complexity = float(task.hidden_complexity)
514
+ cols[2].selectbox("Complexity", COMPLEXITY_OPTIONS, index=COMPLEXITY_OPTIONS.index(current_complexity) if current_complexity in COMPLEXITY_OPTIONS else 0, key=f"task_complexity_{i}", label_visibility="collapsed")
515
+ armed = st.session_state.get("armed_task_index") == i
516
+ if cols[3].button("Selected" if armed else "Use", key=f"use_task_{i}", use_container_width=True):
517
+ arm_task(i)
518
+ st.rerun()
519
+ if cols[4].button("X", key=f"cancel_task_{i}", use_container_width=True):
520
+ sync_tasks_from_widgets()
521
+ env.task_buffer.pop(i)
522
+ st.session_state.task_names.pop(i)
523
+ st.session_state.armed_task_index = None
524
+ st.rerun()
525
+
526
+ st.markdown("#### Add task")
527
+ a, b, c, d = st.columns([2.2, 0.8, 1.0, 0.9])
528
+ task_name = a.text_input("Name", value="", placeholder="Execution block", key="add_task_name")
529
+ slots = b.number_input("Slots", min_value=1, max_value=12, value=2, key="add_task_slots")
530
+ complexity = c.selectbox("Complexity", COMPLEXITY_OPTIONS, index=1, key="add_task_complexity")
531
+ if d.button("Add", use_container_width=True):
532
+ sync_tasks_from_widgets()
533
+ env.task_buffer.append(Task(duration=int(slots), hidden_complexity=float(complexity)))
534
+ st.session_state.task_names.append(task_name.strip() or default_task_name(len(st.session_state.task_names)))
535
+ st.rerun()
536
+
537
+
538
+ def render_status(observation: Dict[str, object]) -> None:
539
+ st.markdown("### Live Scoring")
540
+ scores = st.session_state.get("last_info", {}).get("score_breakdown", {})
541
+ current_block = block_at_slot(int(observation["current_slot"]))
542
+ a, b = st.columns(2)
543
+ a.metric("Flow efficiency", f"{scores.get('flow_score', observation.get('flow_score', 0.0)):.2f}")
544
+ recovery_state = int(observation.get("recovery_state", 0))
545
+ card_class = "status-card-warning" if recovery_state > 0 else "status-card-subtle"
546
+ current_flow_label = current_block["label"] if current_block and current_block["type"] == "Focus" else "Open"
547
+ current_flow_note = "Recovery is active. Focus output is temporarily reduced." if recovery_state > 0 else "Flow is live and ready to compound."
548
+ with b:
549
+ st.markdown(
550
+ f"""
551
+ <div class="status-card {card_class}">
552
+ <div class="status-card-label">Current Flow Block</div>
553
+ <div class="status-card-value">{current_flow_label}</div>
554
+ <div class="status-card-label" style="margin-top:0.4rem;text-transform:none;letter-spacing:0;color:var(--muted);">
555
+ {current_flow_note}
556
+ </div>
557
+ </div>
558
+ """,
559
+ unsafe_allow_html=True,
560
+ )
561
+ c, d = st.columns(2)
562
+ c.metric("Social Debt", f"{scores.get('social_debt', observation.get('social_debt', 0.0)):.2f}")
563
+ d.metric("Calendar Churn", int(scores.get('calendar_churn', observation.get('calendar_churn', 0))))
564
+
565
+
566
+ def render_simulator(observation: Dict[str, object]) -> None:
567
+ st.markdown("### Simulation Command Center")
568
+ quiet_mode = bool(observation.get("mute_comms", False))
569
+ st.caption(
570
+ "?? Focus Fortress: Quiet Mode is active. Notifications are suppressed, boosting your continuous work potential."
571
+ if quiet_mode
572
+ else "?? Focus Fortress is standing by. Activate it when you want maximum uninterrupted focus potential."
573
+ )
574
+ a, b = st.columns(2)
575
+ if a.button("Deactivate Focus Fortress" if quiet_mode else "Activate Focus Fortress", use_container_width=True):
576
+ run_simulation_step(OP_MUTE_COMMS)
577
+ if b.button("Step Simulator", use_container_width=True):
578
+ run_simulation_step(OP_IDLE)
579
+ if st.button("Move next meeting", use_container_width=True):
580
+ run_simulation_step(OP_RESCHEDULE_MEETING)
581
+
582
+
583
+ def main() -> None:
584
+ st.set_page_config(page_title="Focus Studio", layout="wide")
585
+ init_state()
586
+ inject_styles()
587
+ env = get_env()
588
+ if env is None:
589
+ render_flash_error()
590
+ st.stop()
591
+ observation = st.session_state.get("observation", env._observation())
592
+ render_flash_error()
593
+ render_header(observation)
594
+ col1, col2, col3 = st.columns([0.88, 1.5, 1.28], gap="large")
595
+ with col1:
596
+ render_setup()
597
+ with col2:
598
+ render_calendar(env)
599
+ with col3:
600
+ render_task_queue(env)
601
+ render_status(observation)
602
+ render_simulator(observation)
603
+
604
+
605
+ if __name__ == "__main__":
606
+ main()
client.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OpenEnv client for the Engineer Manager environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from openenv.core import EnvClient
8
+ from openenv.core.client_types import StepResult
9
+ from openenv.core.env_server.types import State
10
+
11
+ from .models import EngineerManagerAction, EngineerManagerObservation
12
+
13
+
14
+ class EngineerManagerEnv(
15
+ EnvClient[EngineerManagerAction, EngineerManagerObservation, State]
16
+ ):
17
+ """Persistent client for a running Engineer Manager OpenEnv server."""
18
+
19
+ def _step_payload(self, action: EngineerManagerAction) -> dict[str, Any]:
20
+ return action.model_dump()
21
+
22
+ def _parse_result(
23
+ self, payload: dict[str, Any]
24
+ ) -> StepResult[EngineerManagerObservation]:
25
+ observation = EngineerManagerObservation.model_validate(
26
+ payload.get("observation", {})
27
+ )
28
+ return StepResult(
29
+ observation=observation,
30
+ reward=payload.get("reward"),
31
+ done=payload.get("done", observation.done),
32
+ )
33
+
34
+ def _parse_state(self, payload: dict[str, Any]) -> State:
35
+ return State.model_validate(payload)
focus_resource_env.py ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Dict, List, Optional, Tuple
5
+
6
+ import numpy as np
7
+
8
+ try:
9
+ from openenv.env import Env
10
+ except ImportError:
11
+ class Env: # type: ignore[override]
12
+ """Compatibility shim when only openenv-core is installed."""
13
+
14
+ def __init__(self, *_: object, **__: object) -> None:
15
+ pass
16
+
17
+
18
+ EMPTY = 0
19
+ DEEP_WORK = 1
20
+ MEETING = 2
21
+
22
+ OP_IDLE = 0
23
+ OP_SCHEDULE_WORK = 1
24
+ OP_RESCHEDULE_MEETING = 2
25
+ OP_MUTE_COMMS = 3
26
+
27
+ RECOVERY_STEPS = 2
28
+
29
+
30
+ @dataclass
31
+ class Task:
32
+ duration: int
33
+ hidden_complexity: float
34
+
35
+ def to_dict(self) -> Dict[str, float]:
36
+ return {
37
+ "duration": int(self.duration),
38
+ "hidden_complexity": float(self.hidden_complexity),
39
+ }
40
+
41
+
42
+ class FocusResourceEnv(Env):
43
+ def __init__(
44
+ self,
45
+ start_hour: str = "09:00",
46
+ end_hour: str = "17:00",
47
+ distraction_risk: float = 0.15,
48
+ seed: Optional[int] = None,
49
+ ) -> None:
50
+ self.start_hour = start_hour
51
+ self.end_hour = end_hour
52
+ self.distraction_risk = float(distraction_risk)
53
+ self.rng = np.random.default_rng(seed)
54
+ self.slot_minutes = 30
55
+
56
+ self.timeline_length = self._compute_timeline_length(start_hour, end_hour)
57
+ if self.timeline_length <= 0:
58
+ raise ValueError("end_hour must be after start_hour")
59
+
60
+ super().__init__(
61
+ name="FocusResourceEnv",
62
+ state_space={
63
+ "timeline": self.timeline_length,
64
+ "task_buffer": 3,
65
+ "distraction_risk": (0.0, 1.0),
66
+ },
67
+ action_space={
68
+ "target_slot": (0, self.timeline_length - 1),
69
+ "operation": {
70
+ OP_IDLE: "Idle",
71
+ OP_SCHEDULE_WORK: "Schedule Work",
72
+ OP_RESCHEDULE_MEETING: "Reschedule Meeting",
73
+ OP_MUTE_COMMS: "Mute Comms",
74
+ },
75
+ },
76
+ episode_max_length=self.timeline_length,
77
+ )
78
+
79
+ self.reset()
80
+
81
+ def reset(self) -> Dict[str, Any]:
82
+ self.current_slot = 0
83
+ self.timeline = np.zeros(self.timeline_length, dtype=np.int8)
84
+ self.meeting_meta: Dict[int, Dict[str, int]] = {}
85
+ self._meeting_id_counter = 0
86
+ self.task_buffer = self._generate_task_buffer()
87
+ self.current_work_streak_slots = 0
88
+ self.recovery_remaining = 0
89
+ self.mute_comms = False
90
+ self.social_debt = 0.0
91
+ self.calendar_churn = 0
92
+ self.flow_score = 0.0
93
+ self.last_executed_kind = EMPTY
94
+ self.interruptions = 0
95
+ self.invalid_actions = 0
96
+ self._scatter_initial_meetings()
97
+ return self._observation()
98
+
99
+ def step(self, action: Tuple[int, int]) -> Tuple[Dict[str, Any], float, bool, Dict[str, Any]]:
100
+ target_slot, operation = self._normalize_action(action)
101
+ action_info = self._apply_action(target_slot, operation)
102
+ previous_score = self._total_score()
103
+ transition_info = self._advance_execution()
104
+ done = self.current_slot >= self.timeline_length
105
+ reward = self._total_score() - previous_score
106
+
107
+ info = {
108
+ "slot_executed": self.current_slot - 1,
109
+ "action": {"target_slot": target_slot, "operation": operation},
110
+ "action_info": action_info,
111
+ "transition_info": transition_info,
112
+ "score_breakdown": {
113
+ "flow_score": self.flow_score,
114
+ "social_debt": self.social_debt,
115
+ "calendar_churn": self.calendar_churn,
116
+ "total_score": self._total_score(),
117
+ },
118
+ }
119
+ return self._observation(), reward, done, info
120
+
121
+ def render_text(self) -> str:
122
+ symbols = {EMPTY: ".", DEEP_WORK: "W", MEETING: "M"}
123
+ timeline = "".join(symbols[int(slot)] for slot in self.timeline)
124
+ return (
125
+ f"time={self._slot_label(self.current_slot)} "
126
+ f"muted={self.mute_comms} recovery={self.recovery_remaining} "
127
+ f"flow={self.flow_score:.2f} debt={self.social_debt:.2f} churn={self.calendar_churn} "
128
+ f"timeline={timeline}"
129
+ )
130
+
131
+ def _normalize_action(self, action: Tuple[int, int]) -> Tuple[int, int]:
132
+ if not isinstance(action, (tuple, list)) or len(action) != 2:
133
+ raise ValueError("action must be a (target_slot, operation) pair")
134
+
135
+ target_slot = int(action[0])
136
+ operation = int(action[1])
137
+ if target_slot < 0 or target_slot >= self.timeline_length:
138
+ raise ValueError("target_slot is outside the work day")
139
+ if operation not in {OP_IDLE, OP_SCHEDULE_WORK, OP_RESCHEDULE_MEETING, OP_MUTE_COMMS}:
140
+ raise ValueError("operation is invalid")
141
+ return target_slot, operation
142
+
143
+ def _apply_action(self, target_slot: int, operation: int) -> Dict[str, Any]:
144
+ if target_slot < self.current_slot:
145
+ self.invalid_actions += 1
146
+ self.social_debt += 0.25
147
+ return {"status": "invalid_past_slot"}
148
+
149
+ if operation == OP_IDLE:
150
+ return {"status": "idle"}
151
+ if operation == OP_MUTE_COMMS:
152
+ self.mute_comms = not self.mute_comms
153
+ return {"status": "mute_toggled", "muted": self.mute_comms}
154
+ if operation == OP_SCHEDULE_WORK:
155
+ return self._schedule_work(target_slot)
156
+ if operation == OP_RESCHEDULE_MEETING:
157
+ return self._reschedule_meeting(target_slot)
158
+ return {"status": "noop"}
159
+
160
+ def _schedule_work(self, target_slot: int) -> Dict[str, Any]:
161
+ if not self.task_buffer:
162
+ self.invalid_actions += 1
163
+ return {"status": "no_tasks_available"}
164
+ if self.timeline[target_slot] == MEETING:
165
+ self.invalid_actions += 1
166
+ self.calendar_churn += 1
167
+ return {"status": "meeting_blocks_target"}
168
+
169
+ task = self.task_buffer.pop(0)
170
+ true_slots = int(np.ceil(task.duration * task.hidden_complexity))
171
+ contiguous = self._contiguous_empty_slots_from(target_slot)
172
+ scheduled_slots = min(true_slots, contiguous)
173
+ if scheduled_slots == 0:
174
+ self.invalid_actions += 1
175
+ self.task_buffer.insert(0, task)
176
+ return {"status": "no_capacity"}
177
+
178
+ self.timeline[target_slot : target_slot + scheduled_slots] = DEEP_WORK
179
+ overflow = true_slots - scheduled_slots
180
+
181
+ if overflow > 0:
182
+ self.social_debt += 0.5
183
+ self.invalid_actions += 1
184
+
185
+ return {
186
+ "status": "work_scheduled",
187
+ "estimated_slots": task.duration,
188
+ "true_slots": true_slots,
189
+ "scheduled_slots": scheduled_slots,
190
+ "overflow_slots": overflow,
191
+ }
192
+
193
+ def _reschedule_meeting(self, target_slot: int) -> Dict[str, Any]:
194
+ if self.timeline[target_slot] != MEETING:
195
+ self.invalid_actions += 1
196
+ return {"status": "no_meeting_at_target"}
197
+
198
+ meeting = self.meeting_meta.get(target_slot)
199
+ if meeting is None:
200
+ self.invalid_actions += 1
201
+ return {"status": "missing_meeting_metadata"}
202
+
203
+ length = meeting["length"]
204
+ priority = meeting["priority"]
205
+ meeting_id = meeting["meeting_id"]
206
+ start = meeting["start"]
207
+
208
+ candidate = self._find_latest_empty_block(length, exclude=(start, start + length))
209
+ self._clear_meeting(start, length)
210
+ self.calendar_churn += 1
211
+ # High-priority meeting churn needs to outweigh the quadratic flow upside
212
+ # from deleting collaboration entirely, while still charging a smaller
213
+ # cost when a meeting is merely moved instead of cancelled.
214
+ reschedule_penalty = priority / 2.0
215
+ cancellation_penalty = (priority**2) / 5.0
216
+
217
+ if candidate is None:
218
+ self.social_debt += cancellation_penalty
219
+ return {"status": "meeting_cancelled", "meeting_id": meeting_id, "priority": priority}
220
+
221
+ self.social_debt += reschedule_penalty
222
+ self._place_meeting(candidate, length, priority, meeting_id)
223
+ return {
224
+ "status": "meeting_rescheduled",
225
+ "meeting_id": meeting_id,
226
+ "from_slot": start,
227
+ "to_slot": candidate,
228
+ "priority": priority,
229
+ }
230
+
231
+ def _advance_execution(self) -> Dict[str, Any]:
232
+ if self.current_slot >= self.timeline_length:
233
+ return {"status": "episode_complete"}
234
+
235
+ slot_kind = int(self.timeline[self.current_slot])
236
+ event = {
237
+ "slot_kind": slot_kind,
238
+ "recovery_triggered": False,
239
+ "interrupted": False,
240
+ "productive": False,
241
+ }
242
+
243
+ if self._is_context_switch(self.last_executed_kind, slot_kind):
244
+ self.recovery_remaining = RECOVERY_STEPS
245
+ self.current_work_streak_slots = 0
246
+ event["recovery_triggered"] = True
247
+
248
+ if self.recovery_remaining > 0:
249
+ self.recovery_remaining -= 1
250
+ self.current_work_streak_slots = 0
251
+ elif slot_kind == DEEP_WORK:
252
+ if not self.mute_comms and self.rng.random() < self.distraction_risk:
253
+ self.current_work_streak_slots = 0
254
+ self.interruptions += 1
255
+ event["interrupted"] = True
256
+ else:
257
+ previous_streak = self.current_work_streak_slots
258
+ self.current_work_streak_slots += 1
259
+ self.flow_score += self._power_law_delta(previous_streak, self.current_work_streak_slots)
260
+ event["productive"] = True
261
+ else:
262
+ self.current_work_streak_slots = 0
263
+
264
+ self.last_executed_kind = slot_kind
265
+ self.current_slot += 1
266
+ return event
267
+
268
+ def _observation(self) -> Dict[str, Any]:
269
+ return {
270
+ "timeline": self.timeline.astype(int).tolist(),
271
+ "task_buffer": [task.to_dict() for task in self.task_buffer],
272
+ "distraction_risk": float(self.distraction_risk),
273
+ "current_slot": int(self.current_slot),
274
+ "current_time": self._slot_label(self.current_slot),
275
+ "recovery_state": int(self.recovery_remaining),
276
+ "mute_comms": bool(self.mute_comms),
277
+ "social_debt": float(self.social_debt),
278
+ "calendar_churn": int(self.calendar_churn),
279
+ "flow_score": float(self.flow_score),
280
+ }
281
+
282
+ def _generate_task_buffer(self) -> List[Task]:
283
+ return [self._make_task() for _ in range(3)]
284
+
285
+ def _make_task(self) -> Task:
286
+ return Task(
287
+ duration=int(self.rng.integers(1, 5)),
288
+ hidden_complexity=float(self.rng.choice([1.0, 1.25, 1.5, 1.75])),
289
+ )
290
+
291
+ def _scatter_initial_meetings(self) -> None:
292
+ meeting_count = int(self.rng.integers(3, 6))
293
+ attempts = 0
294
+ while meeting_count > 0 and attempts < 100:
295
+ attempts += 1
296
+ length = int(self.rng.integers(1, 3))
297
+ latest_start = self.timeline_length - length
298
+ if latest_start < 0:
299
+ break
300
+
301
+ start = int(self.rng.integers(0, latest_start + 1))
302
+ if np.any(self.timeline[start : start + length] != EMPTY):
303
+ continue
304
+
305
+ priority = int(self.rng.integers(1, 11))
306
+ meeting_id = self._next_meeting_id()
307
+ self._place_meeting(start, length, priority, meeting_id)
308
+ meeting_count -= 1
309
+
310
+ def _place_meeting(self, start: int, length: int, priority: int, meeting_id: int) -> None:
311
+ self.timeline[start : start + length] = MEETING
312
+ for slot in range(start, start + length):
313
+ self.meeting_meta[slot] = {
314
+ "meeting_id": meeting_id,
315
+ "start": start,
316
+ "length": length,
317
+ "priority": priority,
318
+ }
319
+
320
+ def _clear_meeting(self, start: int, length: int) -> None:
321
+ self.timeline[start : start + length] = EMPTY
322
+ for slot in range(start, start + length):
323
+ self.meeting_meta.pop(slot, None)
324
+
325
+ def _find_latest_empty_block(
326
+ self,
327
+ length: int,
328
+ exclude: Optional[Tuple[int, int]] = None,
329
+ ) -> Optional[int]:
330
+ for start in range(self.timeline_length - length, -1, -1):
331
+ end = start + length
332
+ if exclude is not None and not (end <= exclude[0] or start >= exclude[1]):
333
+ continue
334
+ if np.all(self.timeline[start:end] == EMPTY):
335
+ return start
336
+ return None
337
+
338
+ def _contiguous_empty_slots_from(self, start: int) -> int:
339
+ count = 0
340
+ for slot in range(start, self.timeline_length):
341
+ if self.timeline[slot] != EMPTY:
342
+ break
343
+ count += 1
344
+ return count
345
+
346
+ def _compute_timeline_length(self, start_hour: str, end_hour: str) -> int:
347
+ return int((self._to_minutes(end_hour) - self._to_minutes(start_hour)) / self.slot_minutes)
348
+
349
+ def _slot_label(self, slot_index: int) -> str:
350
+ minute_value = self._to_minutes(self.start_hour) + slot_index * self.slot_minutes
351
+ hours = (minute_value // 60) % 24
352
+ minutes = minute_value % 60
353
+ return f"{hours:02d}:{minutes:02d}"
354
+
355
+ def _to_minutes(self, hhmm: str) -> int:
356
+ hours, minutes = hhmm.split(":")
357
+ return int(hours) * 60 + int(minutes)
358
+
359
+ def _power_law_delta(self, previous_streak_slots: int, current_streak_slots: int) -> float:
360
+ prev_hours = previous_streak_slots * 0.5
361
+ curr_hours = current_streak_slots * 0.5
362
+ return curr_hours ** 2 - prev_hours ** 2
363
+
364
+ def _is_context_switch(self, previous_kind: int, current_kind: int) -> bool:
365
+ work_meeting = {DEEP_WORK, MEETING}
366
+ return previous_kind in work_meeting and current_kind in work_meeting and previous_kind != current_kind
367
+
368
+ def _total_score(self) -> float:
369
+ return self.flow_score - self.social_debt - self.calendar_churn
370
+
371
+ def _next_meeting_id(self) -> int:
372
+ self._meeting_id_counter += 1
373
+ return self._meeting_id_counter
models.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic models for the Engineer Manager environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from openenv.core.env_server.types import Action, Observation
8
+ from pydantic import Field
9
+
10
+
11
+ class EngineerManagerAction(Action):
12
+ """Scheduling action applied to the focus-planning environment."""
13
+
14
+ target_slot: int = Field(..., ge=0, description="Target half-hour slot index.")
15
+ operation: int = Field(
16
+ ...,
17
+ ge=0,
18
+ le=3,
19
+ description="Operation id: 0 idle, 1 schedule work, 2 reschedule meeting, 3 mute comms.",
20
+ )
21
+
22
+
23
+ class EngineerManagerObservation(Observation):
24
+ """Serializable observation returned by the environment server."""
25
+
26
+ timeline: list[int] = Field(default_factory=list)
27
+ task_buffer: list[dict[str, Any]] = Field(default_factory=list)
28
+ distraction_risk: float = Field(default=0.15)
29
+ current_slot: int = Field(default=0)
30
+ current_time: str = Field(default="09:00")
31
+ recovery_state: int = Field(default=0)
32
+ mute_comms: bool = Field(default=False)
33
+ social_debt: float = Field(default=0.0)
34
+ calendar_churn: int = Field(default=0)
35
+ flow_score: float = Field(default=0.0)
openenv.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ spec_version: 1
2
+ name: engineer-manager
3
+ type: space
4
+ runtime: fastapi
5
+ app: server.app:app
6
+ port: 8000
pyproject.toml ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "engineer-manager-openenv"
7
+ version = "0.1.0"
8
+ description = "OpenEnv server for the Engineer Manager scheduling simulator"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "fastapi>=0.135.0",
13
+ "numpy>=2.0.0",
14
+ "openenv-core[core]>=0.2.0",
15
+ "pydantic>=2.0.0",
16
+ "streamlit>=1.40.0",
17
+ "uvicorn>=0.30.0",
18
+ ]
19
+
20
+ [project.scripts]
21
+ server = "server.app:main"
22
+
23
+ [tool.setuptools]
24
+ include-package-data = true
25
+ packages = ["server"]
run_sim.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from typing import Tuple
5
+
6
+ import numpy as np
7
+
8
+ from focus_resource_env import (
9
+ DEEP_WORK,
10
+ EMPTY,
11
+ MEETING,
12
+ OP_IDLE,
13
+ OP_MUTE_COMMS,
14
+ OP_RESCHEDULE_MEETING,
15
+ OP_SCHEDULE_WORK,
16
+ FocusResourceEnv,
17
+ )
18
+
19
+
20
+ def choose_action(env: FocusResourceEnv) -> Tuple[int, int]:
21
+ current = env.current_slot
22
+
23
+ if env.current_slot == 0 and not env.mute_comms and env.distraction_risk > 0.0:
24
+ return current, OP_MUTE_COMMS
25
+
26
+ if env.recovery_remaining > 0:
27
+ return current, OP_IDLE
28
+
29
+ if current < env.timeline_length and env.timeline[current] == EMPTY:
30
+ return current, OP_SCHEDULE_WORK
31
+
32
+ empty_slots = np.where(env.timeline[current:] == EMPTY)[0]
33
+ if empty_slots.size > 0:
34
+ target = current + int(empty_slots[0])
35
+ return target, OP_SCHEDULE_WORK
36
+
37
+ fragmented_meetings = future_meeting_starts(env, current)
38
+ if fragmented_meetings:
39
+ _, largest_empty_len = largest_empty_block(env.timeline, current)
40
+ if largest_empty_len < 8:
41
+ return fragmented_meetings[0], OP_RESCHEDULE_MEETING
42
+
43
+ return current, OP_IDLE
44
+
45
+
46
+ def largest_empty_block(timeline: np.ndarray, start_index: int) -> Tuple[int, int]:
47
+ best_start = start_index
48
+ best_len = 0
49
+ idx = start_index
50
+ while idx < len(timeline):
51
+ if timeline[idx] != EMPTY:
52
+ idx += 1
53
+ continue
54
+ run_start = idx
55
+ while idx < len(timeline) and timeline[idx] == EMPTY:
56
+ idx += 1
57
+ run_len = idx - run_start
58
+ if run_len > best_len:
59
+ best_start = run_start
60
+ best_len = run_len
61
+ return best_start, best_len
62
+
63
+
64
+ def future_meeting_starts(env: FocusResourceEnv, current: int) -> list[int]:
65
+ starts = []
66
+ seen = set()
67
+ for slot in range(current, env.timeline_length):
68
+ if env.timeline[slot] != MEETING:
69
+ continue
70
+ meta = env.meeting_meta.get(slot)
71
+ if meta is None:
72
+ continue
73
+ start = meta["start"]
74
+ if start in seen or start < current:
75
+ continue
76
+ seen.add(start)
77
+ starts.append(start)
78
+ return starts
79
+
80
+
81
+ def main() -> None:
82
+ parser = argparse.ArgumentParser(description="Run the FocusResourceEnv simulation.")
83
+ parser.add_argument("--start-hour", default="09:00")
84
+ parser.add_argument("--end-hour", default="17:00")
85
+ parser.add_argument("--distraction-risk", type=float, default=0.15)
86
+ parser.add_argument("--seed", type=int, default=7)
87
+ args = parser.parse_args()
88
+
89
+ env = FocusResourceEnv(
90
+ start_hour=args.start_hour,
91
+ end_hour=args.end_hour,
92
+ distraction_risk=args.distraction_risk,
93
+ seed=args.seed,
94
+ )
95
+
96
+ observation = env.reset()
97
+ total_reward = 0.0
98
+ print("Initial observation:")
99
+ print(observation)
100
+ print(env.render_text())
101
+
102
+ done = False
103
+ while not done:
104
+ action = choose_action(env)
105
+ observation, reward, done, info = env.step(action)
106
+ total_reward += reward
107
+ print(f"action={action} reward={reward:.2f}")
108
+ print(info)
109
+ print(env.render_text())
110
+
111
+ print("\nFinal Flow Efficiency score")
112
+ print(f"episode_reward={total_reward:.2f}")
113
+ print(f"flow_score={env.flow_score:.2f}")
114
+ print(f"social_debt={env.social_debt:.2f}")
115
+ print(f"calendar_churn={env.calendar_churn}")
116
+ print(f"interruptions={env.interruptions}")
117
+ print(f"invalid_actions={env.invalid_actions}")
118
+
119
+
120
+ if __name__ == "__main__":
121
+ main()
server/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Server package for the Engineer Manager OpenEnv app."""
server/app.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI application for the Engineer Manager OpenEnv server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uvicorn
6
+ from openenv.core import create_app
7
+
8
+ try:
9
+ from ..models import EngineerManagerAction, EngineerManagerObservation
10
+ from .engineer_manager_environment import EngineerManagerEnvironment
11
+ except ImportError:
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ repo_root = Path(__file__).resolve().parents[1]
16
+ if str(repo_root) not in sys.path:
17
+ sys.path.insert(0, str(repo_root))
18
+ from models import EngineerManagerAction, EngineerManagerObservation
19
+ from server.engineer_manager_environment import EngineerManagerEnvironment
20
+
21
+
22
+ app = create_app(
23
+ EngineerManagerEnvironment,
24
+ EngineerManagerAction,
25
+ EngineerManagerObservation,
26
+ env_name="engineer-manager",
27
+ max_concurrent_envs=2,
28
+ )
29
+
30
+
31
+ def run(host: str = "0.0.0.0", port: int = 8000) -> None:
32
+ """Run the OpenEnv HTTP server."""
33
+ uvicorn.run(app, host=host, port=port)
34
+
35
+
36
+ def main() -> None:
37
+ """CLI entrypoint expected by the OpenEnv validator."""
38
+ import argparse
39
+
40
+ parser = argparse.ArgumentParser()
41
+ parser.add_argument("--host", default="0.0.0.0")
42
+ parser.add_argument("--port", type=int, default=8000)
43
+ args = parser.parse_args()
44
+ run(host=args.host, port=args.port)
45
+
46
+
47
+ if __name__ == "__main__":
48
+ main()
server/engineer_manager_environment.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OpenEnv server wrapper for the focus scheduling simulator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from uuid import uuid4
6
+
7
+ from openenv.core.env_server.interfaces import Environment, EnvironmentMetadata
8
+ from openenv.core.env_server.types import State
9
+
10
+ from focus_resource_env import FocusResourceEnv
11
+
12
+ try:
13
+ from ..models import EngineerManagerAction, EngineerManagerObservation
14
+ except ImportError:
15
+ from models import EngineerManagerAction, EngineerManagerObservation
16
+
17
+
18
+ class EngineerManagerEnvironment(
19
+ Environment[EngineerManagerAction, EngineerManagerObservation, State]
20
+ ):
21
+ """Expose the scheduling simulator through the OpenEnv HTTP contract."""
22
+
23
+ SUPPORTS_CONCURRENT_SESSIONS = True
24
+
25
+ def __init__(
26
+ self,
27
+ start_hour: str = "09:00",
28
+ end_hour: str = "17:00",
29
+ distraction_risk: float = 0.15,
30
+ seed: int | None = 7,
31
+ ) -> None:
32
+ super().__init__()
33
+ self._start_hour = start_hour
34
+ self._end_hour = end_hour
35
+ self._distraction_risk = distraction_risk
36
+ self._seed = seed
37
+ self._step_count = 0
38
+ self._episode_id = str(uuid4())
39
+ self._env = FocusResourceEnv(
40
+ start_hour=start_hour,
41
+ end_hour=end_hour,
42
+ distraction_risk=distraction_risk,
43
+ seed=seed,
44
+ )
45
+
46
+ def reset(
47
+ self,
48
+ seed: int | None = None,
49
+ episode_id: str | None = None,
50
+ **_: object,
51
+ ) -> EngineerManagerObservation:
52
+ self._seed = self._seed if seed is None else seed
53
+ self._episode_id = episode_id or str(uuid4())
54
+ self._step_count = 0
55
+ self._env = FocusResourceEnv(
56
+ start_hour=self._start_hour,
57
+ end_hour=self._end_hour,
58
+ distraction_risk=self._distraction_risk,
59
+ seed=self._seed,
60
+ )
61
+ return self._to_observation(self._env.reset(), reward=0.0, done=False)
62
+
63
+ def step(
64
+ self,
65
+ action: EngineerManagerAction,
66
+ timeout_s: float | None = None,
67
+ **_: object,
68
+ ) -> EngineerManagerObservation:
69
+ del timeout_s
70
+ observation, reward, done, info = self._env.step(
71
+ (action.target_slot, action.operation)
72
+ )
73
+ self._step_count += 1
74
+ return self._to_observation(observation, reward=reward, done=done, info=info)
75
+
76
+ @property
77
+ def state(self) -> State:
78
+ return State(
79
+ episode_id=self._episode_id,
80
+ step_count=self._step_count,
81
+ current_slot=self._env.current_slot,
82
+ done=self._env.current_slot >= self._env.timeline_length,
83
+ )
84
+
85
+ def get_metadata(self) -> EnvironmentMetadata:
86
+ return EnvironmentMetadata(
87
+ name="Engineer Manager",
88
+ description=(
89
+ "Manage a workday by scheduling deep work, rescheduling meetings, "
90
+ "and controlling communication noise."
91
+ ),
92
+ version="0.1.0",
93
+ )
94
+
95
+ def _to_observation(
96
+ self,
97
+ observation: dict[str, object],
98
+ *,
99
+ reward: float | None,
100
+ done: bool,
101
+ info: dict[str, object] | None = None,
102
+ ) -> EngineerManagerObservation:
103
+ payload = dict(observation)
104
+ payload["reward"] = reward
105
+ payload["done"] = done
106
+ payload["metadata"] = info or {}
107
+ return EngineerManagerObservation.model_validate(payload)
styles.css ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg-0: __BG0__;
3
+ --bg-1: __BG1__;
4
+ --line: __LINE__;
5
+ --text: __TEXT__;
6
+ --muted: __MUTED__;
7
+ --accent: __ACCENT__;
8
+ --accent-2: __ACCENT2__;
9
+ }
10
+
11
+ .stApp {
12
+ background:
13
+ radial-gradient(circle at top left, rgba(30, 86, 198, 0.20), transparent 30%),
14
+ radial-gradient(circle at top right, rgba(15, 150, 120, 0.16), transparent 28%),
15
+ linear-gradient(180deg, var(--bg-0) 0%, var(--bg-1) 48%, #0a1120 100%);
16
+ color: var(--text);
17
+ }
18
+
19
+ #MainMenu, header, footer, [data-testid="stToolbar"], [data-testid="stStatusWidget"], [data-testid="stDecoration"] {
20
+ visibility: hidden !important;
21
+ height: 0 !important;
22
+ position: fixed !important;
23
+ }
24
+
25
+ [data-testid="stAppViewContainer"] > .main {
26
+ padding-top: 1.1rem;
27
+ }
28
+
29
+ .block-container {
30
+ max-width: 1640px;
31
+ }
32
+
33
+ h1, h2, h3, p, label, span, div {
34
+ color: var(--text);
35
+ }
36
+
37
+ .stButton > button {
38
+ color: __BUTTON_TEXT__ !important;
39
+ background: linear-gradient(135deg, var(--accent), var(--accent-2)) !important;
40
+ border: 0 !important;
41
+ font-weight: 800 !important;
42
+ font-size: 0.92rem !important;
43
+ border-radius: 999px !important;
44
+ min-height: 2.7rem !important;
45
+ padding-left: 0.7rem !important;
46
+ padding-right: 0.7rem !important;
47
+ }
48
+
49
+ .stButton > button[kind="secondary"] {
50
+ background: linear-gradient(180deg, rgba(66, 128, 255, 0.10), rgba(66, 128, 255, 0.05)) !important;
51
+ color: #8dc2ff !important;
52
+ border: 1px solid rgba(98, 156, 255, 0.28) !important;
53
+ }
54
+
55
+ .stButton > button * {
56
+ color: __BUTTON_TEXT__ !important;
57
+ }
58
+
59
+ .stButton > button[kind="secondary"] * {
60
+ color: #8dc2ff !important;
61
+ }
62
+
63
+ div[data-testid="stMetric"] {
64
+ background: __METRIC_BG__;
65
+ border: 1px solid var(--line);
66
+ border-radius: 20px;
67
+ padding: 0.8rem;
68
+ box-shadow: __SHADOW__;
69
+ }
70
+
71
+ .stTextInput input, .stNumberInput input, .stSelectbox div[data-baseweb="select"] > div {
72
+ background: __INPUT_BG__ !important;
73
+ color: var(--text) !important;
74
+ border-radius: 14px !important;
75
+ }
76
+
77
+ .stDataFrame, [data-testid="stDataFrame"] {
78
+ border-radius: 18px !important;
79
+ overflow: hidden;
80
+ border: 1px solid var(--line);
81
+ }
82
+
83
+ .hero {
84
+ background: __HERO_BG__;
85
+ border: 1px solid var(--line);
86
+ border-radius: 32px;
87
+ padding: 1.5rem 1.6rem 1.35rem 1.6rem;
88
+ margin-bottom: 1.2rem;
89
+ box-shadow: __SHADOW__;
90
+ position: relative;
91
+ overflow: hidden;
92
+ }
93
+
94
+ .hero::after {
95
+ content: "";
96
+ position: absolute;
97
+ inset: auto -8% -35% auto;
98
+ width: 18rem;
99
+ height: 18rem;
100
+ border-radius: 999px;
101
+ background: radial-gradient(circle, rgba(124, 231, 255, 0.22), transparent 62%);
102
+ pointer-events: none;
103
+ filter: blur(6px);
104
+ }
105
+
106
+ .hero-kicker {
107
+ position: relative;
108
+ z-index: 1;
109
+ text-transform: uppercase;
110
+ letter-spacing: 0.18em;
111
+ font-size: 0.72rem;
112
+ color: var(--muted);
113
+ margin-bottom: 0.55rem;
114
+ }
115
+
116
+ .hero h1 {
117
+ margin: 0;
118
+ position: relative;
119
+ z-index: 1;
120
+ font-size: 3rem;
121
+ line-height: 0.95;
122
+ letter-spacing: -0.06em;
123
+ text-shadow: 0 10px 30px rgba(0, 0, 0, 0.18);
124
+ }
125
+
126
+ .hero p {
127
+ position: relative;
128
+ z-index: 1;
129
+ margin: 0.5rem 0 0 0;
130
+ max-width: 54rem;
131
+ color: var(--muted);
132
+ font-size: 1rem;
133
+ }
134
+
135
+ .help-chip {
136
+ position: relative;
137
+ z-index: 1;
138
+ display: inline-block;
139
+ margin: 0.55rem 0.45rem 0 0;
140
+ padding: 0.35rem 0.7rem;
141
+ border-radius: 999px;
142
+ border: 1px solid var(--line);
143
+ background: rgba(255,255,255,0.05);
144
+ color: var(--muted);
145
+ font-size: 0.83rem;
146
+ }
147
+
148
+ .status-card {
149
+ border: 1px solid var(--line);
150
+ border-radius: 20px;
151
+ padding: 0.85rem 0.95rem;
152
+ box-shadow: __SHADOW__;
153
+ margin-bottom: 0.5rem;
154
+ }
155
+
156
+ .status-card-label {
157
+ color: var(--muted);
158
+ font-size: 0.82rem;
159
+ text-transform: uppercase;
160
+ letter-spacing: 0.08em;
161
+ }
162
+
163
+ .status-card-value {
164
+ margin-top: 0.25rem;
165
+ font-size: 1.15rem;
166
+ font-weight: 700;
167
+ }
168
+
169
+ .status-card-subtle {
170
+ background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02));
171
+ }
172
+
173
+ .status-card-warning {
174
+ background: linear-gradient(180deg, rgba(145, 104, 17, 0.34), rgba(89, 64, 13, 0.34));
175
+ border-color: rgba(255, 204, 92, 0.30);
176
+ }
uv.lock ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Placeholder lockfile for local validator compatibility.
2
+ # Regenerate with `uv lock` in an environment where `uv` is installed.
validate-submission.sh ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ #
3
+ # validate-submission.sh - Enhanced OpenEnv Submission Validator
4
+ #
5
+
6
+ set -uo pipefail
7
+
8
+ DOCKER_BUILD_TIMEOUT=600
9
+ REQUIRED_CMDS=("curl" "docker" "openenv")
10
+
11
+ if [ -t 1 ]; then
12
+ RED='\033[0;31m'
13
+ GREEN='\033[0;32m'
14
+ YELLOW='\033[1;33m'
15
+ BLUE='\033[0;34m'
16
+ BOLD='\033[1m'
17
+ NC='\033[0m'
18
+ else
19
+ RED='' GREEN='' YELLOW='' BLUE='' BOLD='' NC=''
20
+ fi
21
+
22
+ log() { printf "[%s] %b\n" "$(date +%H:%M:%S)" "$*"; }
23
+ pass() { log "${GREEN}${BOLD}PASS${NC} -- $1"; }
24
+ fail() { log "${RED}${BOLD}FAIL${NC} -- $1"; }
25
+ warn() { log "${YELLOW}${BOLD}WARN${NC} -- $1"; }
26
+ hint() { printf " ${YELLOW}Hint:${NC} %b\n" "$1"; }
27
+
28
+ show_spinner() {
29
+ local pid=$1
30
+ local delay=0.1
31
+ local spinstr='|/-\'
32
+ while kill -0 "$pid" 2>/dev/null; do
33
+ local temp=${spinstr#?}
34
+ printf " [%c] " "$spinstr"
35
+ spinstr=$temp${spinstr%"$temp"}
36
+ sleep "$delay"
37
+ printf "\b\b\b\b\b\b"
38
+ done
39
+ printf " \b\b\b\b"
40
+ }
41
+
42
+ stop_at() {
43
+ printf "\n${RED}${BOLD}Validation stopped at %s.${NC} Please address the error above.\n" "$1"
44
+ exit 1
45
+ }
46
+
47
+ PING_URL="${1:-}"
48
+ REPO_DIR="${2:-.}"
49
+
50
+ if [ -z "$PING_URL" ]; then
51
+ printf "${BOLD}Usage:${NC} %s <ping_url> [repo_dir]\n" "$0"
52
+ exit 1
53
+ fi
54
+
55
+ [[ ! $PING_URL =~ ^http ]] && PING_URL="https://$PING_URL"
56
+ PING_URL="${PING_URL%/}"
57
+
58
+ if ! REPO_DIR="$(cd "$REPO_DIR" 2>/dev/null && pwd)"; then
59
+ fail "Directory '$REPO_DIR' not found."
60
+ exit 1
61
+ fi
62
+
63
+ printf "\n${BOLD}========================================${NC}\n"
64
+ printf "${BOLD} OpenEnv Submission Validator v2.0 ${NC}\n"
65
+ printf "${BOLD}========================================${NC}\n"
66
+ log "Target Repo: $REPO_DIR"
67
+ log "Remote URL: $PING_URL"
68
+ printf "\n"
69
+
70
+ log "${BOLD}Step 0/3: Checking Environment...${NC}"
71
+ for cmd in "${REQUIRED_CMDS[@]}"; do
72
+ if ! command -v "$cmd" >/dev/null 2>&1; then
73
+ fail "Dependency missing: $cmd"
74
+ [[ "$cmd" == "openenv" ]] && hint "Run: pip install openenv-core"
75
+ stop_at "Pre-flight"
76
+ fi
77
+ done
78
+
79
+ if ! docker info >/dev/null 2>&1; then
80
+ fail "Docker daemon is not running."
81
+ hint "Ensure Docker Desktop or the Docker Engine is active."
82
+ stop_at "Pre-flight"
83
+ fi
84
+ pass "Environment is ready."
85
+
86
+ log "${BOLD}Step 1/3: Pinging HF Space${NC} ($PING_URL/reset) ..."
87
+ HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
88
+ -H "Content-Type: application/json" -d '{}' \
89
+ "$PING_URL/reset" --max-time 20 || echo "000")
90
+
91
+ if [ "$HTTP_CODE" = "200" ]; then
92
+ pass "HF Space is live and responding."
93
+ else
94
+ fail "HF Space /reset returned $HTTP_CODE"
95
+ hint "Check if your Space is sleepy or still building."
96
+ stop_at "Step 1"
97
+ fi
98
+
99
+ log "${BOLD}Step 2/3: Running Docker Build${NC} (This may take a while)..."
100
+ if [ -f "$REPO_DIR/Dockerfile" ]; then
101
+ DOCKER_CONTEXT="$REPO_DIR"
102
+ elif [ -f "$REPO_DIR/server/Dockerfile" ]; then
103
+ DOCKER_CONTEXT="$REPO_DIR/server"
104
+ else
105
+ fail "No Dockerfile found."
106
+ stop_at "Step 2"
107
+ fi
108
+
109
+ BUILD_LOG=$(mktemp)
110
+ (docker build -t openenv-test "$DOCKER_CONTEXT" > "$BUILD_LOG" 2>&1) &
111
+ BUILD_PID=$!
112
+ show_spinner "$BUILD_PID"
113
+ wait "$BUILD_PID"
114
+
115
+ if [ $? -eq 0 ]; then
116
+ pass "Docker build successful."
117
+ else
118
+ fail "Docker build failed."
119
+ printf "${YELLOW}--- Build Error Snippet ---${NC}\n"
120
+ tail -n 15 "$BUILD_LOG"
121
+ hint "Full logs can be found at $BUILD_LOG"
122
+ stop_at "Step 2"
123
+ fi
124
+
125
+ log "${BOLD}Step 3/3: Running OpenEnv Validate${NC} ..."
126
+ if (cd "$REPO_DIR" && openenv validate); then
127
+ pass "Schema validation passed."
128
+ else
129
+ fail "openenv validate discovered structural errors."
130
+ stop_at "Step 3"
131
+ fi
132
+
133
+ printf "\n"
134
+ printf "${GREEN}${BOLD}========================================${NC}\n"
135
+ printf "${GREEN}${BOLD} ALL CHECKS PASSED SUCCESSFULLY! ${NC}\n"
136
+ printf "${GREEN}${BOLD} Your submission is ready for HF. ${NC}\n"
137
+ printf "${GREEN}${BOLD}========================================${NC}\n\n"
138
+
139
+ exit 0