printf-sourav commited on
Commit
d769105
·
1 Parent(s): e000265

Fix HF Space config and validator flow

Browse files
Dockerfile CHANGED
@@ -8,5 +8,8 @@ RUN pip install --no-cache-dir -r requirements.txt
8
  COPY . .
9
 
10
  ENV OFFLINE_INFERENCE=1
 
11
 
12
- CMD ["python", "inference.py", "--max-steps", "8", "--policy-mode", "imp", "--trajectories", "4", "--force-local-env"]
 
 
 
8
  COPY . .
9
 
10
  ENV OFFLINE_INFERENCE=1
11
+ ENV PORT=7860
12
 
13
+ EXPOSE 7860
14
+
15
+ CMD ["python", "-m", "uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,3 +1,12 @@
 
 
 
 
 
 
 
 
 
1
  # CI/CD Pipeline Debugger Environment (OpenEnv)
2
 
3
  ## 1. Project Goal
@@ -196,7 +205,7 @@ Coverage includes:
196
  OpenEnv validation:
197
 
198
  ```bash
199
- openenv validate
200
  ```
201
 
202
  Pre-submission script:
@@ -205,13 +214,28 @@ Pre-submission script:
205
  ./validate-submission.sh <your_hf_space_url>
206
  ```
207
 
208
- Docker run:
 
 
 
 
 
 
 
 
209
 
210
  ```bash
211
  docker build -t cicd-debugger-env .
212
- docker run --rm -e OFFLINE_INFERENCE=1 cicd-debugger-env
213
  ```
214
 
 
 
 
 
 
 
 
215
  ## 11. One-line Presentation Summary
216
 
217
  We built an OpenEnv-compliant reinforcement learning environment where AI agents learn to debug real CI/CD pipelines using multi-step reasoning, hybrid grading, anti-hacking safeguards, and robust reward shaping.
 
1
+ ---
2
+ title: CICD_DEBUGGER
3
+ colorFrom: blue
4
+ colorTo: green
5
+ sdk: docker
6
+ app_port: 7860
7
+ pinned: false
8
+ ---
9
+
10
  # CI/CD Pipeline Debugger Environment (OpenEnv)
11
 
12
  ## 1. Project Goal
 
205
  OpenEnv validation:
206
 
207
  ```bash
208
+ python -m openenv.cli.__main__ validate
209
  ```
210
 
211
  Pre-submission script:
 
214
  ./validate-submission.sh <your_hf_space_url>
215
  ```
216
 
217
+ Required environment variables:
218
+
219
+ ```bash
220
+ export API_BASE_URL="https://router.huggingface.co/v1"
221
+ export MODEL_NAME="Qwen/Qwen2.5-72B-Instruct"
222
+ export HF_TOKEN="<your_token>"
223
+ ```
224
+
225
+ Docker run (Space/API mode):
226
 
227
  ```bash
228
  docker build -t cicd-debugger-env .
229
+ docker run --rm -p 7860:7860 cicd-debugger-env
230
  ```
231
 
232
+ Server endpoints used by validators:
233
+
234
+ - `POST /reset`
235
+ - `POST /step`
236
+ - `GET /state`
237
+ - `GET /health`
238
+
239
  ## 11. One-line Presentation Summary
240
 
241
  We built an OpenEnv-compliant reinforcement learning environment where AI agents learn to debug real CI/CD pipelines using multi-step reasoning, hybrid grading, anti-hacking safeguards, and robust reward shaping.
env/rewards.py CHANGED
@@ -70,7 +70,7 @@ class RewardCalculator:
70
  )
71
  reward += self._penalty_reward(state=state, result=result, current_config=current_config)
72
 
73
- return round(float(reward), 4)
74
 
75
  def _progress_reward(self, action: str, result: dict[str, Any]) -> float:
76
  reward = self.ACTION_PROGRESS_REWARDS.get(action, 0.0)
 
70
  )
71
  reward += self._penalty_reward(state=state, result=result, current_config=current_config)
72
 
73
+ return round(self._clamp_01(reward), 4)
74
 
75
  def _progress_reward(self, action: str, result: dict[str, Any]) -> float:
76
  reward = self.ACTION_PROGRESS_REWARDS.get(action, 0.0)
inference.py CHANGED
@@ -529,7 +529,7 @@ async def run_episode(args: argparse.Namespace) -> int:
529
  metadata={"broken_token": "npm tset", "fixed_token": "npm test"},
530
  )
531
 
532
- combined_reward = round(float(calculated_reward) + float(env_reward), 4)
533
  metrics.add_step(action=action_text, reward=combined_reward, error=step_error, done=done)
534
  steps_taken = step
535
 
 
529
  metadata={"broken_token": "npm tset", "fixed_token": "npm test"},
530
  )
531
 
532
+ combined_reward = round(max(0.0, min(1.0, float(calculated_reward) + float(env_reward))), 4)
533
  metrics.add_step(action=action_text, reward=combined_reward, error=step_error, done=done)
534
  steps_taken = step
535
 
server/app.py CHANGED
@@ -1,19 +1,258 @@
1
  from __future__ import annotations
2
 
 
 
 
3
  from fastapi import FastAPI
 
 
4
  import uvicorn
5
 
6
 
7
  app = FastAPI(title="CI/CD Debugger OpenEnv Server")
8
 
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  @app.get("/health")
11
  def health() -> dict[str, str]:
12
  return {"status": "ok"}
13
 
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  def main() -> None:
16
- uvicorn.run(app, host="0.0.0.0", port=8000)
 
17
 
18
 
19
  if __name__ == "__main__":
 
1
  from __future__ import annotations
2
 
3
+ from dataclasses import dataclass
4
+ import os
5
+
6
  from fastapi import FastAPI
7
+ from fastapi import HTTPException
8
+ from pydantic import BaseModel, Field
9
  import uvicorn
10
 
11
 
12
  app = FastAPI(title="CI/CD Debugger OpenEnv Server")
13
 
14
 
15
+ DEFAULT_ORIGINAL_CONFIG = """
16
+ name: CI
17
+ on: [push]
18
+ jobs:
19
+ test:
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+ - run: npm ci
24
+ - run: npm tset
25
+ """.strip()
26
+
27
+ DEFAULT_EXPECTED_CONFIG = """
28
+ name: CI
29
+ on: [push]
30
+ jobs:
31
+ test:
32
+ runs-on: ubuntu-latest
33
+ steps:
34
+ - uses: actions/checkout@v4
35
+ - run: npm ci
36
+ - run: npm test
37
+ """.strip()
38
+
39
+ DEFAULT_ERROR_MESSAGE = "command not found"
40
+
41
+
42
+ class ObservationModel(BaseModel):
43
+ config: str
44
+ error_message: str
45
+ logs: str
46
+ last_action_error: str | None = None
47
+
48
+
49
+ class ResetRequest(BaseModel):
50
+ task_id: str = "cicd-debugger-task"
51
+ original_config: str | None = None
52
+ expected_config: str | None = None
53
+ error_message: str | None = None
54
+
55
+
56
+ class StepRequest(BaseModel):
57
+ action: str = Field(min_length=1, max_length=600)
58
+
59
+
60
+ class StepResponse(BaseModel):
61
+ task_id: str
62
+ step_count: int
63
+ reward: float
64
+ done: bool
65
+ observation: ObservationModel
66
+ last_action: str | None = None
67
+
68
+
69
+ class StateResponse(BaseModel):
70
+ initialized: bool
71
+ task_id: str | None = None
72
+ step_count: int = 0
73
+ done: bool = False
74
+ last_action: str | None = None
75
+ observation: ObservationModel | None = None
76
+
77
+
78
+ @dataclass
79
+ class LocalObservation:
80
+ config: str
81
+ error_message: str
82
+ logs: str
83
+ last_action_error: str | None = None
84
+
85
+
86
+ @dataclass
87
+ class LocalStepResult:
88
+ observation: LocalObservation
89
+ reward: float
90
+ done: bool
91
+ last_action_error: str | None = None
92
+
93
+
94
+ class LocalCICDDebuggerEnv:
95
+ def __init__(self, original_config: str, expected_config: str, error_message: str):
96
+ self.original_config = original_config
97
+ self.expected_config = expected_config
98
+ self.error_message = error_message
99
+ self.current_config = original_config
100
+
101
+ def reset(self) -> LocalStepResult:
102
+ self.current_config = self.original_config
103
+ observation = LocalObservation(
104
+ config=self.current_config,
105
+ error_message=self.error_message,
106
+ logs="CI failed in test step: npm tset is not a valid command.",
107
+ )
108
+ return LocalStepResult(observation=observation, reward=0.0, done=False)
109
+
110
+ def step(self, action: str) -> LocalStepResult:
111
+ action_text = str(action or "").strip()
112
+ lower_action = action_text.lower()
113
+ previous = self.current_config
114
+ step_error: str | None = None
115
+ logs = "No effective change applied."
116
+
117
+ if _is_hacking_action(action_text):
118
+ step_error = "disallowed_hacking_pattern"
119
+ logs = "Rejected unsafe action pattern."
120
+ elif "npm tset" in lower_action and "npm test" in lower_action and "npm tset" in previous:
121
+ self.current_config = previous.replace("npm tset", "npm test")
122
+ logs = "Patched CI command typo from npm tset to npm test."
123
+ elif "replace" in lower_action and "npm test" in lower_action and "npm tset" in previous:
124
+ self.current_config = previous.replace("npm tset", "npm test")
125
+ logs = "Applied replace operation for broken test command."
126
+ elif "npm test" in lower_action and "npm tset" in previous:
127
+ self.current_config = previous.replace("npm tset", "npm test")
128
+ logs = "Applied inferred command fix."
129
+
130
+ done = "npm tset" not in self.current_config.lower() and "npm test" in self.current_config.lower()
131
+ reward = 1.0 if done else 0.0
132
+ error_message = "" if done else self.error_message
133
+
134
+ observation = LocalObservation(
135
+ config=self.current_config,
136
+ error_message=error_message,
137
+ logs=logs,
138
+ last_action_error=step_error,
139
+ )
140
+ return LocalStepResult(observation=observation, reward=reward, done=done, last_action_error=step_error)
141
+
142
+
143
+ @dataclass
144
+ class RuntimeSession:
145
+ task_id: str
146
+ env: LocalCICDDebuggerEnv
147
+ step_count: int = 0
148
+ done: bool = False
149
+ last_action: str | None = None
150
+ last_result: LocalStepResult | None = None
151
+
152
+
153
+ runtime_session: RuntimeSession | None = None
154
+
155
+
156
+ def _is_hacking_action(action_text: str) -> bool:
157
+ value = (action_text or "").lower()
158
+ patterns = (
159
+ "if: false",
160
+ "when: never",
161
+ "echo \"tests passed\"",
162
+ "echo 'tests passed'",
163
+ "exit 0",
164
+ "force success",
165
+ )
166
+ return any(token in value for token in patterns)
167
+
168
+
169
+ def _as_observation_model(observation: LocalObservation) -> ObservationModel:
170
+ return ObservationModel(
171
+ config=observation.config,
172
+ error_message=observation.error_message,
173
+ logs=observation.logs,
174
+ last_action_error=observation.last_action_error,
175
+ )
176
+
177
+
178
+ def _build_step_response(session: RuntimeSession, result: LocalStepResult) -> StepResponse:
179
+ return StepResponse(
180
+ task_id=session.task_id,
181
+ step_count=session.step_count,
182
+ reward=float(result.reward),
183
+ done=bool(result.done),
184
+ observation=_as_observation_model(result.observation),
185
+ last_action=session.last_action,
186
+ )
187
+
188
+
189
  @app.get("/health")
190
  def health() -> dict[str, str]:
191
  return {"status": "ok"}
192
 
193
 
194
+ @app.post("/reset", response_model=StepResponse)
195
+ def reset(payload: ResetRequest | None = None) -> StepResponse:
196
+ global runtime_session
197
+
198
+ request = payload or ResetRequest()
199
+ env = LocalCICDDebuggerEnv(
200
+ original_config=request.original_config or DEFAULT_ORIGINAL_CONFIG,
201
+ expected_config=request.expected_config or DEFAULT_EXPECTED_CONFIG,
202
+ error_message=request.error_message or DEFAULT_ERROR_MESSAGE,
203
+ )
204
+ result = env.reset()
205
+
206
+ runtime_session = RuntimeSession(task_id=request.task_id, env=env, last_result=result)
207
+ return _build_step_response(runtime_session, result)
208
+
209
+
210
+ @app.post("/step", response_model=StepResponse)
211
+ def step(payload: StepRequest) -> StepResponse:
212
+ global runtime_session
213
+
214
+ if runtime_session is None:
215
+ raise HTTPException(status_code=400, detail="Environment not initialized. Call /reset first.")
216
+
217
+ if runtime_session.done and runtime_session.last_result is not None:
218
+ return _build_step_response(runtime_session, runtime_session.last_result)
219
+
220
+ result = runtime_session.env.step(payload.action)
221
+ runtime_session.step_count += 1
222
+ runtime_session.done = bool(result.done)
223
+ runtime_session.last_action = payload.action
224
+ runtime_session.last_result = result
225
+
226
+ return _build_step_response(runtime_session, result)
227
+
228
+
229
+ @app.get("/state", response_model=StateResponse)
230
+ def state() -> StateResponse:
231
+ if runtime_session is None:
232
+ return StateResponse(initialized=False)
233
+
234
+ observation = None
235
+ if runtime_session.last_result is not None:
236
+ observation = _as_observation_model(runtime_session.last_result.observation)
237
+
238
+ return StateResponse(
239
+ initialized=True,
240
+ task_id=runtime_session.task_id,
241
+ step_count=runtime_session.step_count,
242
+ done=runtime_session.done,
243
+ last_action=runtime_session.last_action,
244
+ observation=observation,
245
+ )
246
+
247
+
248
+ @app.post("/state", response_model=StateResponse)
249
+ def state_post() -> StateResponse:
250
+ return state()
251
+
252
+
253
  def main() -> None:
254
+ port = int(os.getenv("PORT", "7860"))
255
+ uvicorn.run(app, host="0.0.0.0", port=port)
256
 
257
 
258
  if __name__ == "__main__":
tests/test_day2_engine.py CHANGED
@@ -191,7 +191,8 @@ jobs:
191
  expected_config=EXPECTED_CONFIG,
192
  )
193
 
194
- self.assertLess(reward, 0.0)
 
195
 
196
 
197
  if __name__ == "__main__":
 
191
  expected_config=EXPECTED_CONFIG,
192
  )
193
 
194
+ self.assertGreaterEqual(reward, 0.0)
195
+ self.assertLessEqual(reward, 0.3)
196
 
197
 
198
  if __name__ == "__main__":
tests/test_server_api.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+
3
+ from fastapi.testclient import TestClient
4
+
5
+ from server.app import app
6
+ import server.app as server_app
7
+
8
+
9
+ class ServerApiTests(unittest.TestCase):
10
+ def setUp(self):
11
+ server_app.runtime_session = None
12
+ self.client = TestClient(app)
13
+
14
+ def test_health(self):
15
+ response = self.client.get("/health")
16
+ self.assertEqual(response.status_code, 200)
17
+ self.assertEqual(response.json().get("status"), "ok")
18
+
19
+ def test_reset_state_step_flow(self):
20
+ reset_response = self.client.post("/reset", json={})
21
+ self.assertEqual(reset_response.status_code, 200)
22
+ reset_payload = reset_response.json()
23
+ self.assertIn("observation", reset_payload)
24
+ self.assertIn("step_count", reset_payload)
25
+ self.assertEqual(reset_payload["step_count"], 0)
26
+
27
+ state_response = self.client.get("/state")
28
+ self.assertEqual(state_response.status_code, 200)
29
+ state_payload = state_response.json()
30
+ self.assertTrue(state_payload.get("initialized"))
31
+
32
+ step_response = self.client.post(
33
+ "/step",
34
+ json={"action": "edit_config: replace npm tset with npm test"},
35
+ )
36
+ self.assertEqual(step_response.status_code, 200)
37
+ step_payload = step_response.json()
38
+ self.assertIn("reward", step_payload)
39
+ self.assertIn("done", step_payload)
40
+
41
+ def test_step_requires_reset(self):
42
+ server_app.runtime_session = None
43
+ client = TestClient(app)
44
+ response = client.post("/step", json={"action": "read_logs: inspect logs"})
45
+ self.assertEqual(response.status_code, 400)
46
+
47
+
48
+ if __name__ == "__main__":
49
+ unittest.main()
validate-submission.sh CHANGED
@@ -2,7 +2,7 @@
2
  #
3
  # validate-submission.sh - OpenEnv Submission Validator
4
  #
5
- # Checks that your HF Space is live, Docker image builds, and openenv validate passes.
6
  #
7
  # Usage:
8
  # ./validate-submission.sh <ping_url> [repo_dir]
@@ -10,6 +10,7 @@
10
  set -uo pipefail
11
 
12
  DOCKER_BUILD_TIMEOUT=600
 
13
  if [ -t 1 ]; then
14
  RED='\033[0;31m'
15
  GREEN='\033[0;32m'
@@ -66,6 +67,19 @@ fi
66
 
67
  PING_URL="${PING_URL%/}"
68
  PASS=0
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
  log() { printf "[%s] %b\n" "$(date -u +%H:%M:%S)" "$*"; }
71
  pass() { log "${GREEN}PASSED${NC} -- $1"; PASS=$((PASS + 1)); }
@@ -83,9 +97,43 @@ printf "${BOLD} OpenEnv Submission Validator${NC}\n"
83
  printf "${BOLD}========================================${NC}\n"
84
  log "Repo: $REPO_DIR"
85
  log "Ping URL: $PING_URL"
 
86
  printf "\n"
87
 
88
- log "${BOLD}Step 1/3: Pinging HF Space${NC} ($PING_URL/reset) ..."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
  CURL_OUTPUT=$(portable_mktemp "validate-curl")
91
  CLEANUP_FILES+=("$CURL_OUTPUT")
@@ -99,19 +147,49 @@ elif [ "$HTTP_CODE" = "000" ]; then
99
  fail "HF Space not reachable (connection failed or timed out)"
100
  hint "Check your network connection and that the Space is running."
101
  hint "Try: curl -s -o /dev/null -w '%{http_code}' -X POST $PING_URL/reset"
102
- stop_at "Step 1"
103
  else
104
  fail "HF Space /reset returned HTTP $HTTP_CODE (expected 200)"
105
  hint "Make sure your Space is running and the URL is correct."
106
- stop_at "Step 1"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  fi
108
 
109
- log "${BOLD}Step 2/3: Running docker build${NC} ..."
110
 
111
  if ! command -v docker &>/dev/null; then
112
  fail "docker command not found"
113
  hint "Install Docker: https://docs.docker.com/get-docker/"
114
- stop_at "Step 2"
115
  fi
116
 
117
  if [ -f "$REPO_DIR/Dockerfile" ]; then
@@ -120,7 +198,7 @@ elif [ -f "$REPO_DIR/server/Dockerfile" ]; then
120
  DOCKER_CONTEXT="$REPO_DIR/server"
121
  else
122
  fail "No Dockerfile found in repo root or server/ directory"
123
- stop_at "Step 2"
124
  fi
125
 
126
  BUILD_OK=false
@@ -131,31 +209,192 @@ if [ "$BUILD_OK" = true ]; then
131
  else
132
  fail "Docker build failed (timeout=${DOCKER_BUILD_TIMEOUT}s)"
133
  printf "%s\n" "$BUILD_OUTPUT" | tail -20
134
- stop_at "Step 2"
135
  fi
136
 
137
- log "${BOLD}Step 3/3: Running openenv validate${NC} ..."
138
-
139
- if ! command -v openenv &>/dev/null; then
140
- fail "openenv command not found"
141
- hint "Install it: pip install openenv-core"
142
- stop_at "Step 3"
143
- fi
144
 
145
  VALIDATE_OK=false
146
- VALIDATE_OUTPUT=$(cd "$REPO_DIR" && openenv validate 2>&1) && VALIDATE_OK=true
147
 
148
  if [ "$VALIDATE_OK" = true ]; then
149
  pass "openenv validate passed"
150
  else
151
  fail "openenv validate failed"
152
  printf "%s\n" "$VALIDATE_OUTPUT"
153
- stop_at "Step 3"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  fi
155
 
156
  printf "\n"
157
  printf "${BOLD}========================================${NC}\n"
158
- printf "${GREEN}${BOLD} All 3/3 checks passed!${NC}\n"
159
  printf "${GREEN}${BOLD} Your submission is ready to submit.${NC}\n"
160
  printf "${BOLD}========================================${NC}\n"
161
  printf "\n"
 
2
  #
3
  # validate-submission.sh - OpenEnv Submission Validator
4
  #
5
+ # Checks all mandatory pre-submission gates for this challenge.
6
  #
7
  # Usage:
8
  # ./validate-submission.sh <ping_url> [repo_dir]
 
10
  set -uo pipefail
11
 
12
  DOCKER_BUILD_TIMEOUT=600
13
+ INFERENCE_TIMEOUT=1200
14
  if [ -t 1 ]; then
15
  RED='\033[0;31m'
16
  GREEN='\033[0;32m'
 
67
 
68
  PING_URL="${PING_URL%/}"
69
  PASS=0
70
+ PYTHON_CMD="python3"
71
+ if [ -x "$REPO_DIR/.venv/bin/python" ]; then
72
+ PYTHON_CMD="$REPO_DIR/.venv/bin/python"
73
+ fi
74
+
75
+ run_openenv_validate() {
76
+ if command -v openenv &>/dev/null; then
77
+ if (cd "$REPO_DIR" && openenv validate); then
78
+ return 0
79
+ fi
80
+ fi
81
+ (cd "$REPO_DIR" && "$PYTHON_CMD" -m openenv.cli.__main__ validate)
82
+ }
83
 
84
  log() { printf "[%s] %b\n" "$(date -u +%H:%M:%S)" "$*"; }
85
  pass() { log "${GREEN}PASSED${NC} -- $1"; PASS=$((PASS + 1)); }
 
97
  printf "${BOLD}========================================${NC}\n"
98
  log "Repo: $REPO_DIR"
99
  log "Ping URL: $PING_URL"
100
+ log "Python: $PYTHON_CMD"
101
  printf "\n"
102
 
103
+ log "${BOLD}Step 1/8: Checking mandatory environment variables${NC} ..."
104
+
105
+ REQUIRED_VARS=(API_BASE_URL MODEL_NAME HF_TOKEN)
106
+ MISSING_VARS=()
107
+ for var_name in "${REQUIRED_VARS[@]}"; do
108
+ if [ -z "${!var_name:-}" ]; then
109
+ MISSING_VARS+=("$var_name")
110
+ fi
111
+ done
112
+
113
+ if [ ${#MISSING_VARS[@]} -eq 0 ]; then
114
+ pass "Required environment variables are set (API_BASE_URL, MODEL_NAME, HF_TOKEN)"
115
+ else
116
+ fail "Missing required environment variables: ${MISSING_VARS[*]}"
117
+ hint "Export them before submission, for example: export API_BASE_URL=... MODEL_NAME=... HF_TOKEN=..."
118
+ stop_at "Step 1"
119
+ fi
120
+
121
+ log "${BOLD}Step 2/8: Verifying inference entrypoint and OpenAI client usage${NC} ..."
122
+
123
+ if [ ! -f "$REPO_DIR/inference.py" ]; then
124
+ fail "inference.py not found at repository root"
125
+ stop_at "Step 2"
126
+ fi
127
+
128
+ if grep -Eq "from[[:space:]]+openai[[:space:]]+import[[:space:]]+OpenAI|openai\\.OpenAI|OpenAI\\(" "$REPO_DIR/inference.py"; then
129
+ pass "Root inference.py exists and uses OpenAI client"
130
+ else
131
+ fail "Could not verify OpenAI client usage in inference.py"
132
+ hint "Use: from openai import OpenAI and initialize client with API_BASE_URL/MODEL_NAME/HF_TOKEN"
133
+ stop_at "Step 2"
134
+ fi
135
+
136
+ log "${BOLD}Step 3/8: Pinging HF Space endpoints${NC} ($PING_URL/reset, /state, /step) ..."
137
 
138
  CURL_OUTPUT=$(portable_mktemp "validate-curl")
139
  CLEANUP_FILES+=("$CURL_OUTPUT")
 
147
  fail "HF Space not reachable (connection failed or timed out)"
148
  hint "Check your network connection and that the Space is running."
149
  hint "Try: curl -s -o /dev/null -w '%{http_code}' -X POST $PING_URL/reset"
150
+ stop_at "Step 3"
151
  else
152
  fail "HF Space /reset returned HTTP $HTTP_CODE (expected 200)"
153
  hint "Make sure your Space is running and the URL is correct."
154
+ stop_at "Step 3"
155
+ fi
156
+
157
+ STATE_OUTPUT=$(portable_mktemp "validate-state")
158
+ CLEANUP_FILES+=("$STATE_OUTPUT")
159
+ STATE_CODE=$(curl -s -o "$STATE_OUTPUT" -w "%{http_code}" "$PING_URL/state" --max-time 30 || printf "000")
160
+ if [ "$STATE_CODE" != "200" ]; then
161
+ STATE_CODE=$(curl -s -o "$STATE_OUTPUT" -w "%{http_code}" -X POST \
162
+ -H "Content-Type: application/json" -d '{}' \
163
+ "$PING_URL/state" --max-time 30 || printf "000")
164
+ fi
165
+
166
+ if [ "$STATE_CODE" = "200" ]; then
167
+ pass "HF Space responds to /state"
168
+ else
169
+ fail "HF Space /state returned HTTP $STATE_CODE (expected 200)"
170
+ stop_at "Step 3"
171
+ fi
172
+
173
+ STEP_OUTPUT=$(portable_mktemp "validate-step")
174
+ CLEANUP_FILES+=("$STEP_OUTPUT")
175
+ STEP_CODE=$(curl -s -o "$STEP_OUTPUT" -w "%{http_code}" -X POST \
176
+ -H "Content-Type: application/json" \
177
+ -d '{"action":"read_logs: inspect failing stage logs"}' \
178
+ "$PING_URL/step" --max-time 30 || printf "000")
179
+
180
+ if [ "$STEP_CODE" = "200" ]; then
181
+ pass "HF Space responds to /step"
182
+ else
183
+ fail "HF Space /step returned HTTP $STEP_CODE (expected 200)"
184
+ stop_at "Step 3"
185
  fi
186
 
187
+ log "${BOLD}Step 4/8: Running docker build${NC} ..."
188
 
189
  if ! command -v docker &>/dev/null; then
190
  fail "docker command not found"
191
  hint "Install Docker: https://docs.docker.com/get-docker/"
192
+ stop_at "Step 4"
193
  fi
194
 
195
  if [ -f "$REPO_DIR/Dockerfile" ]; then
 
198
  DOCKER_CONTEXT="$REPO_DIR/server"
199
  else
200
  fail "No Dockerfile found in repo root or server/ directory"
201
+ stop_at "Step 4"
202
  fi
203
 
204
  BUILD_OK=false
 
209
  else
210
  fail "Docker build failed (timeout=${DOCKER_BUILD_TIMEOUT}s)"
211
  printf "%s\n" "$BUILD_OUTPUT" | tail -20
212
+ stop_at "Step 4"
213
  fi
214
 
215
+ log "${BOLD}Step 5/8: Running openenv validate${NC} ..."
 
 
 
 
 
 
216
 
217
  VALIDATE_OK=false
218
+ VALIDATE_OUTPUT=$(run_openenv_validate 2>&1) && VALIDATE_OK=true
219
 
220
  if [ "$VALIDATE_OK" = true ]; then
221
  pass "openenv validate passed"
222
  else
223
  fail "openenv validate failed"
224
  printf "%s\n" "$VALIDATE_OUTPUT"
225
+ stop_at "Step 5"
226
+ fi
227
+
228
+ log "${BOLD}Step 6/8: Running baseline inference under 20-minute limit${NC} ..."
229
+
230
+ INFER_OK=false
231
+ INFER_OUTPUT=$(run_with_timeout "$INFERENCE_TIMEOUT" env OFFLINE_INFERENCE=1 \
232
+ "$PYTHON_CMD" "$REPO_DIR/inference.py" --max-steps 8 --offline --force-local-env 2>&1) && INFER_OK=true
233
+
234
+ if [ "$INFER_OK" != true ]; then
235
+ fail "inference.py failed or exceeded ${INFERENCE_TIMEOUT}s"
236
+ printf "%s\n" "$INFER_OUTPUT" | tail -40
237
+ stop_at "Step 6"
238
+ fi
239
+
240
+ NON_EMPTY_OUTPUT=$(printf "%s\n" "$INFER_OUTPUT" | sed '/^[[:space:]]*$/d')
241
+ FIRST_LINE=$(printf "%s\n" "$NON_EMPTY_OUTPUT" | head -n 1)
242
+ LAST_LINE=$(printf "%s\n" "$NON_EMPTY_OUTPUT" | tail -n 1)
243
+
244
+ if ! printf "%s\n" "$FIRST_LINE" | grep -Eq '^\[START\] task=[^ ]+ env=[^ ]+ model=.+$'; then
245
+ fail "Missing or malformed [START] line"
246
+ printf "%s\n" "$INFER_OUTPUT" | tail -40
247
+ stop_at "Step 6"
248
+ fi
249
+
250
+ if ! printf "%s\n" "$NON_EMPTY_OUTPUT" | grep -Eq '^\[STEP\] step=[0-9]+ action=.* reward=-?[0-9]+\.[0-9]{2} done=(true|false) error=(null|.+)$'; then
251
+ fail "Missing or malformed [STEP] line"
252
+ printf "%s\n" "$INFER_OUTPUT" | tail -40
253
+ stop_at "Step 6"
254
+ fi
255
+
256
+ if ! printf "%s\n" "$LAST_LINE" | grep -Eq '^\[END\] success=(true|false) steps=[0-9]+ rewards=(-?[0-9]+\.[0-9]{2}(,-?[0-9]+\.[0-9]{2})*)?$'; then
257
+ fail "Missing or malformed [END] line"
258
+ printf "%s\n" "$INFER_OUTPUT" | tail -40
259
+ stop_at "Step 6"
260
+ fi
261
+
262
+ pass "Baseline inference completed and emitted strict [START]/[STEP]/[END] format"
263
+
264
+ log "${BOLD}Step 7/8: Validating 3+ tasks with graders (scores/reward in 0.0-1.0)${NC} ..."
265
+
266
+ PY_CHECK_SCRIPT=$(portable_mktemp "validate-grader")
267
+ CLEANUP_FILES+=("$PY_CHECK_SCRIPT")
268
+ cat >"$PY_CHECK_SCRIPT" <<'PY'
269
+ from __future__ import annotations
270
+
271
+ import json
272
+ import sys
273
+ from pathlib import Path
274
+
275
+ import yaml
276
+
277
+ repo = Path(sys.argv[1]).resolve()
278
+ sys.path.insert(0, str(repo))
279
+
280
+ from env.graders.deterministic import DeterministicGrader
281
+ from env.hidden_tests import HiddenTestRunner
282
+ from env.rewards import RewardCalculator
283
+
284
+
285
+ def build_config(command: str) -> str:
286
+ return f"""
287
+ name: CI
288
+ on: [push]
289
+ jobs:
290
+ test:
291
+ runs-on: ubuntu-latest
292
+ steps:
293
+ - uses: actions/checkout@v4
294
+ - run: npm ci
295
+ - run: {command}
296
+ """.strip()
297
+
298
+
299
+ config_path = repo / "openenv.yaml"
300
+ data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
301
+ tasks = data.get("tasks", [])
302
+ if len(tasks) < 3:
303
+ raise SystemExit("expected at least 3 tasks in openenv.yaml")
304
+
305
+ grader = DeterministicGrader()
306
+ hidden = HiddenTestRunner(grader=grader)
307
+ reward_calculator = RewardCalculator()
308
+
309
+ results = []
310
+ for task in tasks[:3]:
311
+ metadata = dict(task.get("metadata") or {})
312
+ broken_token = str(metadata.get("broken_token", "npm tset"))
313
+ fixed_token = str(metadata.get("fixed_token", "npm test"))
314
+
315
+ original_config = build_config(broken_token)
316
+ fixed_config = build_config(fixed_token)
317
+
318
+ deterministic_score = float(grader.grade(fixed_config, fixed_config, metadata))
319
+ hidden_score = float(hidden.evaluate_fix(fixed_config, expected_config=fixed_config, metadata=metadata))
320
+ reward_score = float(
321
+ reward_calculator.calculate_step_reward(
322
+ state={
323
+ "step_count": 1,
324
+ "previous_config": original_config,
325
+ "expected_config": fixed_config,
326
+ "original_config": original_config,
327
+ "error": "command not found",
328
+ },
329
+ action="validate_fix",
330
+ result={
331
+ "previous_config": original_config,
332
+ "current_config": fixed_config,
333
+ "expected_config": fixed_config,
334
+ "logs_analyzed": True,
335
+ "error_diagnosed": True,
336
+ "fix_proposed": True,
337
+ "pipeline_run": True,
338
+ "tests_passed": True,
339
+ "command_succeeded": True,
340
+ "changed_files_count": 1,
341
+ "changed_lines_count": 2,
342
+ },
343
+ original_config=original_config,
344
+ fixed_config=fixed_config,
345
+ error_message="command not found",
346
+ expected_config=fixed_config,
347
+ metadata=metadata,
348
+ )
349
+ )
350
+
351
+ for label, value in (
352
+ ("deterministic", deterministic_score),
353
+ ("hidden", hidden_score),
354
+ ("reward", reward_score),
355
+ ):
356
+ if value < 0.0 or value > 1.0:
357
+ raise SystemExit(f"{task.get('id', 'unknown')} {label} out of range: {value}")
358
+
359
+ results.append(
360
+ {
361
+ "id": task.get("id", "unknown"),
362
+ "deterministic": round(deterministic_score, 4),
363
+ "hidden": round(hidden_score, 4),
364
+ "reward": round(reward_score, 4),
365
+ }
366
+ )
367
+
368
+ print(json.dumps(results, indent=2, sort_keys=True))
369
+ PY
370
+
371
+ GRADER_OK=false
372
+ GRADER_OUTPUT=$(run_with_timeout 180 "$PYTHON_CMD" "$PY_CHECK_SCRIPT" "$REPO_DIR" 2>&1) && GRADER_OK=true
373
+
374
+ if [ "$GRADER_OK" = true ]; then
375
+ pass "Grader checks passed on 3 tasks with scores/reward in [0.0, 1.0]"
376
+ else
377
+ fail "Grader range check failed"
378
+ printf "%s\n" "$GRADER_OUTPUT"
379
+ stop_at "Step 7"
380
+ fi
381
+
382
+ log "${BOLD}Step 8/8: Verifying unit test suite${NC} ..."
383
+
384
+ TESTS_OK=false
385
+ TESTS_OUTPUT=$(run_with_timeout 300 "$PYTHON_CMD" -m unittest discover -s "$REPO_DIR/tests" -v 2>&1) && TESTS_OK=true
386
+
387
+ if [ "$TESTS_OK" = true ]; then
388
+ pass "Unit tests passed"
389
+ else
390
+ fail "Unit tests failed"
391
+ printf "%s\n" "$TESTS_OUTPUT" | tail -40
392
+ stop_at "Step 8"
393
  fi
394
 
395
  printf "\n"
396
  printf "${BOLD}========================================${NC}\n"
397
+ printf "${GREEN}${BOLD} All 8/8 checks passed!${NC}\n"
398
  printf "${GREEN}${BOLD} Your submission is ready to submit.${NC}\n"
399
  printf "${BOLD}========================================${NC}\n"
400
  printf "\n"