Spaces:
Sleeping
Sleeping
Add engineer manager app
Browse files- .gitignore +2 -0
- Dockerfile +17 -0
- README.md +46 -0
- __init__.py +10 -0
- app.py +606 -0
- client.py +35 -0
- focus_resource_env.py +373 -0
- models.py +35 -0
- openenv.yaml +6 -0
- pyproject.toml +25 -0
- run_sim.py +121 -0
- server/__init__.py +1 -0
- server/app.py +48 -0
- server/engineer_manager_environment.py +107 -0
- styles.css +176 -0
- uv.lock +2 -0
- 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
|