AI Developer Agent commited on
Commit
763ef0d
·
1 Parent(s): d720a61

AI Developer Agent v1.0 backend

Browse files
Dockerfile ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONUNBUFFERED=1 \
4
+ PIP_NO_CACHE_DIR=1 \
5
+ PIP_DISABLE_PIP_VERSION_CHECK=1 \
6
+ DEBIAN_FRONTEND=noninteractive \
7
+ HOME=/home/user \
8
+ PATH=/home/user/.local/bin:$PATH \
9
+ TASKS_DB_PATH=/data/tasks.db \
10
+ PYTHONPATH=/home/user/app
11
+
12
+ RUN apt-get update && apt-get install -y --no-install-recommends \
13
+ build-essential git git-lfs curl ca-certificates \
14
+ libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 \
15
+ libxcomposite1 libxdamage1 libxrandr2 libgbm1 libpango-1.0-0 libcairo2 \
16
+ libasound2 libatspi2.0-0 \
17
+ && rm -rf /var/lib/apt/lists/* \
18
+ && git lfs install --system
19
+
20
+ RUN useradd -m -u 1000 user
21
+ WORKDIR /home/user/app
22
+
23
+ COPY --chown=user:user backend/requirements.txt /home/user/app/backend/requirements.txt
24
+ RUN pip install --no-cache-dir -r /home/user/app/backend/requirements.txt
25
+
26
+ # Playwright Chromium (best-effort; skip if it fails so build still completes)
27
+ RUN python -m playwright install --with-deps chromium || \
28
+ python -m playwright install chromium || \
29
+ echo "playwright install failed; continuing"
30
+
31
+ RUN mkdir -p /data && chown -R user:user /data
32
+
33
+ COPY --chown=user:user . /home/user/app
34
+ RUN chown -R user:user /home/user/app
35
+
36
+ USER user
37
+ EXPOSE 7860
38
+
39
+ CMD ["uvicorn", "backend.app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,10 +1,21 @@
1
  ---
2
- title: Ai Developer Agent
3
- emoji: 📊
4
  colorFrom: indigo
5
- colorTo: gray
6
  sdk: docker
 
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: AI Developer Agent
3
+ emoji: 🤖
4
  colorFrom: indigo
5
+ colorTo: purple
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
+ license: mit
10
  ---
11
 
12
+ # AI Developer Agent (Backend)
13
+
14
+ Persistent autonomous AI Developer Agent. FastAPI service that plans, executes,
15
+ repairs and deploys software tasks end-to-end.
16
+
17
+ See `/health`, `/api/runtime`, `/api/tasks`, `/api/tasks/{id}/stream`, `/api/chat`.
18
+
19
+ Configure via Space Secrets:
20
+ - `GEMINI_API_KEYS`, `SAMBANOVA_API_KEYS`, `OPENAI_API_KEYS`, `GITHUB_LLM_API_KEYS`, `OPENROUTER_API_KEYS`
21
+ - `E2B_API_KEY`, `HF_TOKEN`, `VERCEL_TOKEN`, `GITHUB_TOKEN`
backend/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """AI Developer Agent - backend package."""
2
+ __version__ = "1.0.0"
backend/agent.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Agent orchestrator - ties planner + executor + retry + repair + browser.
3
+
4
+ `run_task(task_id, title, description)` is a generator yielding event dicts
5
+ suitable for SSE streaming. Persists everything to tasks.db.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import time
11
+ import logging
12
+ import traceback
13
+ from typing import Any, Dict, Generator, List, Optional
14
+
15
+ from . import tasks
16
+ from .planner import plan_task, repair_plan
17
+ from .executor import get_executor
18
+ from .classifier import classify
19
+ from .browser import run_browser_action
20
+ from .llm_router import get_router
21
+
22
+ logger = logging.getLogger("agent")
23
+
24
+
25
+ def _event(task_id: str, kind: str, message: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
26
+ payload = {
27
+ "task_id": task_id,
28
+ "kind": kind,
29
+ "message": message,
30
+ "ts": time.time(),
31
+ "data": data or {},
32
+ }
33
+ tasks.log_event(task_id, kind, json.dumps({"message": message, "data": data or {}})[:7000])
34
+ return payload
35
+
36
+
37
+ def run_task(task_id: str, title: str, description: str) -> Generator[Dict[str, Any], None, None]:
38
+ """Generator yielding event dicts. Persists to SQLite."""
39
+ try:
40
+ tasks.update_state(task_id, "planning")
41
+ yield _event(task_id, "state", "planning")
42
+ yield _event(task_id, "thought", f"Planning task: {title}")
43
+
44
+ plan = plan_task(title, description)
45
+ yield _event(task_id, "plan", "Plan generated", {"steps": plan})
46
+
47
+ if not plan:
48
+ yield _event(task_id, "warn", "Empty plan – using fallback note")
49
+ plan = [{"type": "note", "msg": "No actions planned"}]
50
+
51
+ tasks.update_state(task_id, "executing")
52
+ yield _event(task_id, "state", "executing")
53
+
54
+ executor = get_executor()
55
+ for idx, step in enumerate(plan):
56
+ tasks.save_checkpoint(task_id, idx, {"plan": plan, "current": step})
57
+ stype = step.get("type", "note")
58
+ yield _event(task_id, "step.start", f"[{idx + 1}/{len(plan)}] {stype}", {"step": step})
59
+
60
+ try:
61
+ result = _execute_step(task_id, step)
62
+ except Exception as e:
63
+ tb = traceback.format_exc()
64
+ result = {"ok": False, "stderr": str(e), "traceback": tb}
65
+
66
+ yield _event(task_id, "step.result", "ok" if result.get("ok") else "fail", {"step_index": idx, "result": result})
67
+
68
+ # If failed, attempt one repair cycle
69
+ if not result.get("ok"):
70
+ tasks.update_state(task_id, "repairing")
71
+ yield _event(task_id, "state", "repairing")
72
+ err_text = (result.get("stderr") or "") + "\n" + (result.get("traceback") or "")
73
+ err_class = classify(err_text)
74
+ if err_class:
75
+ yield _event(task_id, "diagnose", f"Detected: {err_class.category}", {"detail": err_class.detail, "fix": err_class.suggested_fix})
76
+ repair_actions = repair_plan(err_class.category, err_class.detail)
77
+ for ridx, ra in enumerate(repair_actions):
78
+ yield _event(task_id, "repair.start", f"repair[{ridx + 1}]", {"action": ra})
79
+ rresult = _execute_step(task_id, ra)
80
+ yield _event(task_id, "repair.result", "ok" if rresult.get("ok") else "fail", {"result": rresult})
81
+ # retry original step once
82
+ tasks.update_state(task_id, "retrying")
83
+ yield _event(task_id, "state", "retrying")
84
+ tasks.record_retry(task_id, 1, err_text[:1000])
85
+ try:
86
+ retry_result = _execute_step(task_id, step)
87
+ except Exception as e:
88
+ retry_result = {"ok": False, "stderr": str(e)}
89
+ yield _event(task_id, "retry.result", "ok" if retry_result.get("ok") else "fail", {"step_index": idx, "result": retry_result})
90
+ else:
91
+ yield _event(task_id, "warn", "No automatic repair – continuing")
92
+ tasks.update_state(task_id, "executing")
93
+ yield _event(task_id, "state", "executing")
94
+
95
+ tasks.update_state(task_id, "completed")
96
+ yield _event(task_id, "state", "completed")
97
+ yield _event(task_id, "done", f"Task {task_id} completed")
98
+ except Exception as e:
99
+ logger.exception("run_task fatal")
100
+ tasks.update_state(task_id, "failed")
101
+ yield _event(task_id, "error", f"Fatal: {e}", {"traceback": traceback.format_exc()})
102
+
103
+
104
+ def _execute_step(task_id: str, step: Dict[str, Any]) -> Dict[str, Any]:
105
+ executor = get_executor()
106
+ stype = step.get("type", "note")
107
+
108
+ if stype == "shell":
109
+ cmd = step.get("cmd", "")
110
+ if not cmd:
111
+ return {"ok": True, "stdout": "(empty cmd)"}
112
+ r = executor.shell(cmd, timeout=float(step.get("timeout", 120)))
113
+ return {"ok": r.ok, "stdout": r.stdout[-3000:], "stderr": r.stderr[-3000:], "exit_code": r.exit_code, "duration_ms": r.duration_ms}
114
+
115
+ if stype == "python":
116
+ code = step.get("code", "")
117
+ r = executor.python(code, timeout=float(step.get("timeout", 120)))
118
+ return {"ok": r.ok, "stdout": r.stdout[-3000:], "stderr": r.stderr[-3000:], "exit_code": r.exit_code, "duration_ms": r.duration_ms}
119
+
120
+ if stype == "browser":
121
+ br = run_browser_action(step)
122
+ return {
123
+ "ok": br.ok,
124
+ "stdout": (br.text or "")[:3000],
125
+ "stderr": br.error or "",
126
+ "exit_code": 0 if br.ok else 1,
127
+ "screenshot_b64": br.screenshot_b64[:500] if br.screenshot_b64 else "",
128
+ "url": br.url,
129
+ }
130
+
131
+ if stype == "git":
132
+ op = step.get("op", "status")
133
+ args = step.get("args", "")
134
+ cmd = f"git {op} {args}".strip()
135
+ r = executor.shell(cmd, timeout=120)
136
+ return {"ok": r.ok, "stdout": r.stdout, "stderr": r.stderr, "exit_code": r.exit_code}
137
+
138
+ if stype == "deploy":
139
+ # Real deploy is invoked via dedicated /deploy endpoints; here we just log.
140
+ target = step.get("target", "unknown")
141
+ msg = f"Deploy step requested: {target}. Use /deploy endpoints for real deployment."
142
+ return {"ok": True, "stdout": msg}
143
+
144
+ if stype == "note":
145
+ return {"ok": True, "stdout": step.get("msg", "")}
146
+
147
+ if stype == "sleep":
148
+ time.sleep(float(step.get("seconds", 1)))
149
+ return {"ok": True, "stdout": f"slept {step.get('seconds', 1)}s"}
150
+
151
+ if stype == "llm":
152
+ router = get_router()
153
+ prompt = step.get("prompt", "")
154
+ try:
155
+ out = router.chat([{"role": "user", "content": prompt}], temperature=0.2, max_tokens=800)
156
+ return {"ok": True, "stdout": out[:3000]}
157
+ except Exception as e:
158
+ return {"ok": False, "stderr": str(e)}
159
+
160
+ return {"ok": False, "stderr": f"unknown step type: {stype}"}
backend/app.py ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI backend - AI Developer Agent
3
+ ====================================
4
+ Endpoints:
5
+ GET / service info
6
+ GET /health health check
7
+ GET /api/runtime runtime + provider telemetry
8
+ POST /api/tasks create + run a task (sync queued)
9
+ GET /api/tasks list tasks
10
+ GET /api/tasks/{id} get task
11
+ GET /api/tasks/{id}/events list events (REST)
12
+ GET /api/tasks/{id}/stream SSE event stream (live)
13
+ POST /api/chat one-shot chat (streams)
14
+ POST /api/llm/chat chat (non-streaming JSON)
15
+ POST /api/deploy/huggingface push backend dir to HF Space
16
+ POST /api/deploy/vercel deploy frontend dir to Vercel
17
+ POST /api/git/push commit + push to GitHub branch
18
+
19
+ All endpoints accept JSON bodies and return JSON unless documented otherwise.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import json
25
+ import logging
26
+ import os
27
+ import threading
28
+ import time
29
+ from typing import Any, Dict, List, Optional
30
+
31
+ from fastapi import FastAPI, HTTPException, Request
32
+ from fastapi.middleware.cors import CORSMiddleware
33
+ from fastapi.responses import JSONResponse, StreamingResponse
34
+ from pydantic import BaseModel
35
+
36
+ from . import tasks
37
+ from .agent import run_task
38
+ from .llm_router import get_router
39
+ from .executor import get_executor
40
+ from . import deployers
41
+
42
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
43
+ logger = logging.getLogger("app")
44
+
45
+ app = FastAPI(title="AI Developer Agent", version="1.0.0")
46
+
47
+ app.add_middleware(
48
+ CORSMiddleware,
49
+ allow_origins=os.getenv("CORS_ALLOW_ORIGINS", "*").split(","),
50
+ allow_credentials=False,
51
+ allow_methods=["*"],
52
+ allow_headers=["*"],
53
+ )
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # In-memory task queue (background worker)
58
+ # ---------------------------------------------------------------------------
59
+ _task_queue: "asyncio.Queue" = asyncio.Queue()
60
+ _active_subscribers: Dict[str, List[asyncio.Queue]] = {}
61
+
62
+
63
+ def _publish(task_id: str, event: Dict[str, Any]) -> None:
64
+ for q in list(_active_subscribers.get(task_id, [])):
65
+ try:
66
+ q.put_nowait(event)
67
+ except Exception:
68
+ pass
69
+
70
+
71
+ def _worker_run(task_id: str, title: str, description: str) -> None:
72
+ """Run the agent generator in a thread and publish events."""
73
+ try:
74
+ for ev in run_task(task_id, title, description):
75
+ _publish(task_id, ev)
76
+ except Exception as e:
77
+ logger.exception("worker crashed")
78
+ _publish(task_id, {"task_id": task_id, "kind": "error", "message": str(e), "ts": time.time(), "data": {}})
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Schemas
83
+ # ---------------------------------------------------------------------------
84
+ class CreateTaskBody(BaseModel):
85
+ title: str
86
+ description: str = ""
87
+ payload: Optional[Dict[str, Any]] = None
88
+
89
+
90
+ class ChatBody(BaseModel):
91
+ messages: List[Dict[str, str]]
92
+ model: Optional[str] = None
93
+ temperature: float = 0.2
94
+ max_tokens: int = 1500
95
+ preferred_provider: Optional[str] = None
96
+
97
+
98
+ class HFDeployBody(BaseModel):
99
+ repo_id: str
100
+ source_dir: str = "."
101
+ commit_message: str = "Update from AI Developer Agent"
102
+
103
+
104
+ class VercelDeployBody(BaseModel):
105
+ project_name: str
106
+ source_dir: str
107
+ framework: Optional[str] = "nextjs"
108
+ target: str = "production"
109
+ install_command: Optional[str] = None
110
+ build_command: Optional[str] = None
111
+ env: Optional[Dict[str, str]] = None
112
+
113
+
114
+ class GitPushBody(BaseModel):
115
+ repo_dir: str = "."
116
+ branch: str = "genspark_ai_developer"
117
+ commit_message: str = "AI Developer Agent commit"
118
+ remote_url: Optional[str] = None
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # Routes
123
+ # ---------------------------------------------------------------------------
124
+ @app.get("/")
125
+ def index():
126
+ return {
127
+ "service": "AI Developer Agent",
128
+ "version": "1.0.0",
129
+ "ok": True,
130
+ "endpoints": [
131
+ "/health", "/api/runtime", "/api/tasks", "/api/tasks/{id}/stream",
132
+ "/api/chat", "/api/llm/chat",
133
+ "/api/deploy/huggingface", "/api/deploy/vercel", "/api/git/push",
134
+ ],
135
+ }
136
+
137
+
138
+ @app.get("/health")
139
+ def health():
140
+ router = get_router()
141
+ return {
142
+ "ok": True,
143
+ "ts": time.time(),
144
+ "providers": list(router.telemetry().keys()),
145
+ "executor": "e2b" if (get_executor().sandbox and get_executor().sandbox.available) else "local",
146
+ }
147
+
148
+
149
+ @app.get("/api/runtime")
150
+ def runtime():
151
+ info = get_executor().inspect_runtime()
152
+ info["providers"] = get_router().telemetry()
153
+ info["db"] = tasks.DB_PATH
154
+ return info
155
+
156
+
157
+ # ----- Tasks ---------------------------------------------------------------
158
+ @app.post("/api/tasks")
159
+ def create_task(body: CreateTaskBody):
160
+ task_id = tasks.create_task(body.title, body.description, body.payload or {})
161
+ t = threading.Thread(target=_worker_run, args=(task_id, body.title, body.description), daemon=True)
162
+ t.start()
163
+ return {"task_id": task_id, "title": body.title, "state": "queued"}
164
+
165
+
166
+ @app.get("/api/tasks")
167
+ def list_tasks(limit: int = 50):
168
+ return {"tasks": tasks.list_tasks(limit=limit)}
169
+
170
+
171
+ @app.get("/api/tasks/{task_id}")
172
+ def get_task(task_id: str):
173
+ t = tasks.get_task(task_id)
174
+ if not t:
175
+ raise HTTPException(404, "task not found")
176
+ return t
177
+
178
+
179
+ @app.get("/api/tasks/{task_id}/events")
180
+ def get_events(task_id: str, since_id: int = 0, limit: int = 1000):
181
+ return {"events": tasks.get_events(task_id, since_id=since_id, limit=limit)}
182
+
183
+
184
+ @app.get("/api/tasks/{task_id}/stream")
185
+ async def stream_events(task_id: str, request: Request):
186
+ """Server-Sent Events stream. Replays historical events then live events."""
187
+
188
+ async def gen():
189
+ # 1) Replay history
190
+ last_id = 0
191
+ history = tasks.get_events(task_id, since_id=0, limit=2000)
192
+ for ev in history:
193
+ last_id = ev["id"]
194
+ yield f"id: {ev['id']}\nevent: {ev['kind']}\ndata: {json.dumps(ev)}\n\n"
195
+
196
+ # 2) Subscribe for live events
197
+ q: asyncio.Queue = asyncio.Queue()
198
+ _active_subscribers.setdefault(task_id, []).append(q)
199
+ try:
200
+ while True:
201
+ if await request.is_disconnected():
202
+ break
203
+ try:
204
+ ev = await asyncio.wait_for(q.get(), timeout=15.0)
205
+ yield f"event: {ev['kind']}\ndata: {json.dumps(ev)}\n\n"
206
+ if ev["kind"] in ("done", "error") and ev.get("data", {}).get("final"):
207
+ break
208
+ except asyncio.TimeoutError:
209
+ # heartbeat
210
+ yield ":keepalive\n\n"
211
+ finally:
212
+ try:
213
+ _active_subscribers.get(task_id, []).remove(q)
214
+ except ValueError:
215
+ pass
216
+
217
+ return StreamingResponse(gen(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
218
+
219
+
220
+ # ----- LLM endpoints -------------------------------------------------------
221
+ @app.post("/api/llm/chat")
222
+ def llm_chat(body: ChatBody):
223
+ router = get_router()
224
+ try:
225
+ text = router.chat(
226
+ body.messages, model=body.model, temperature=body.temperature,
227
+ max_tokens=body.max_tokens, preferred_provider=body.preferred_provider,
228
+ )
229
+ return {"ok": True, "text": text, "telemetry": router.telemetry()}
230
+ except Exception as e:
231
+ return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
232
+
233
+
234
+ @app.post("/api/chat")
235
+ def chat_stream(body: ChatBody):
236
+ """SSE chat stream."""
237
+ router = get_router()
238
+
239
+ def gen():
240
+ for chunk in router.stream(
241
+ body.messages, model=body.model, temperature=body.temperature,
242
+ max_tokens=body.max_tokens, preferred_provider=body.preferred_provider,
243
+ ):
244
+ yield f"data: {json.dumps({'delta': chunk})}\n\n"
245
+ yield "data: [DONE]\n\n"
246
+
247
+ return StreamingResponse(gen(), media_type="text/event-stream",
248
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
249
+
250
+
251
+ # ----- Deploy endpoints ----------------------------------------------------
252
+ @app.post("/api/deploy/huggingface")
253
+ def deploy_hf(body: HFDeployBody):
254
+ src = os.path.abspath(body.source_dir)
255
+ if not os.path.isdir(src):
256
+ raise HTTPException(400, f"source_dir not found: {src}")
257
+ r = deployers.hf_push_space(source_dir=src, repo_id=body.repo_id, commit_message=body.commit_message)
258
+ if r.get("ok"):
259
+ tasks.record_deployment("", "huggingface", r.get("url", ""), "ok")
260
+ else:
261
+ tasks.record_deployment("", "huggingface", "", "failed")
262
+ return r
263
+
264
+
265
+ @app.post("/api/deploy/vercel")
266
+ def deploy_vercel(body: VercelDeployBody):
267
+ src = os.path.abspath(body.source_dir)
268
+ if not os.path.isdir(src):
269
+ raise HTTPException(400, f"source_dir not found: {src}")
270
+ files = deployers.collect_files_for_vercel(src)
271
+ r = deployers.vercel_deploy_via_api(
272
+ project_name=body.project_name, files=files, target=body.target,
273
+ env=body.env, framework=body.framework,
274
+ install_command=body.install_command, build_command=body.build_command,
275
+ )
276
+ if r.get("ok"):
277
+ tasks.record_deployment("", "vercel", r.get("url", ""), "ok")
278
+ return r
279
+
280
+
281
+ @app.post("/api/git/push")
282
+ def git_push(body: GitPushBody):
283
+ repo_dir = os.path.abspath(body.repo_dir)
284
+ if not os.path.isdir(repo_dir):
285
+ raise HTTPException(400, f"repo_dir not found: {repo_dir}")
286
+ return deployers.github_push(
287
+ repo_dir=repo_dir, branch=body.branch,
288
+ commit_message=body.commit_message, remote_url=body.remote_url,
289
+ )
290
+
291
+
292
+ # ---------------------------------------------------------------------------
293
+ # Startup self-check
294
+ # ---------------------------------------------------------------------------
295
+ @app.on_event("startup")
296
+ def startup_check():
297
+ logger.info("AI Developer Agent starting")
298
+ try:
299
+ tasks.init_db()
300
+ info = get_executor().inspect_runtime()
301
+ logger.info("Runtime: %s", info)
302
+ logger.info("Providers: %s", list(get_router().telemetry().keys()))
303
+ except Exception as e:
304
+ logger.warning("Startup check error: %s", e)
305
+
306
+
307
+ # Allow running directly
308
+ if __name__ == "__main__":
309
+ import uvicorn
310
+ port = int(os.getenv("PORT", "7860"))
311
+ uvicorn.run("apps.backend.app:app", host="0.0.0.0", port=port, log_level="info")
backend/browser.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Browser automation via Playwright (runs inside E2B sandbox when available,
3
+ otherwise locally). Provides retry-safe, structured browser actions.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import base64
8
+ import logging
9
+ import os
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ logger = logging.getLogger("browser")
14
+
15
+
16
+ @dataclass
17
+ class BrowserResult:
18
+ ok: bool
19
+ action: str
20
+ url: str = ""
21
+ text: str = ""
22
+ screenshot_b64: str = ""
23
+ error: str = ""
24
+ meta: Dict[str, Any] = field(default_factory=dict)
25
+
26
+
27
+ class BrowserController:
28
+ """Lightweight controller; lazy-initializes Playwright."""
29
+
30
+ def __init__(self) -> None:
31
+ self._playwright = None
32
+ self._browser = None
33
+ self._context = None
34
+ self._page = None
35
+ self._available: Optional[bool] = None
36
+
37
+ @property
38
+ def available(self) -> bool:
39
+ if self._available is None:
40
+ try:
41
+ import playwright # noqa: F401
42
+ from playwright.sync_api import sync_playwright # noqa: F401
43
+ self._available = True
44
+ except Exception as e:
45
+ logger.warning("Playwright not installed: %s", e)
46
+ self._available = False
47
+ return self._available
48
+
49
+ def _ensure(self):
50
+ if self._page is not None:
51
+ return self._page
52
+ from playwright.sync_api import sync_playwright
53
+ self._playwright = sync_playwright().start()
54
+ self._browser = self._playwright.chromium.launch(headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"])
55
+ self._context = self._browser.new_context()
56
+ self._page = self._context.new_page()
57
+ return self._page
58
+
59
+ def navigate(self, url: str, timeout_ms: int = 30000) -> BrowserResult:
60
+ if not self.available:
61
+ return BrowserResult(ok=False, action="navigate", url=url, error="playwright not available")
62
+ try:
63
+ page = self._ensure()
64
+ page.goto(url, timeout=timeout_ms, wait_until="domcontentloaded")
65
+ return BrowserResult(ok=True, action="navigate", url=page.url, text=page.title())
66
+ except Exception as e:
67
+ logger.exception("navigate failed")
68
+ return BrowserResult(ok=False, action="navigate", url=url, error=str(e))
69
+
70
+ def click(self, selector: str, timeout_ms: int = 10000) -> BrowserResult:
71
+ if not self.available:
72
+ return BrowserResult(ok=False, action="click", error="playwright not available")
73
+ try:
74
+ page = self._ensure()
75
+ page.click(selector, timeout=timeout_ms)
76
+ return BrowserResult(ok=True, action="click", meta={"selector": selector})
77
+ except Exception as e:
78
+ return BrowserResult(ok=False, action="click", error=str(e))
79
+
80
+ def type_text(self, selector: str, text: str, timeout_ms: int = 10000) -> BrowserResult:
81
+ if not self.available:
82
+ return BrowserResult(ok=False, action="type", error="playwright not available")
83
+ try:
84
+ page = self._ensure()
85
+ page.fill(selector, text, timeout=timeout_ms)
86
+ return BrowserResult(ok=True, action="type", meta={"selector": selector})
87
+ except Exception as e:
88
+ return BrowserResult(ok=False, action="type", error=str(e))
89
+
90
+ def screenshot(self) -> BrowserResult:
91
+ if not self.available:
92
+ return BrowserResult(ok=False, action="screenshot", error="playwright not available")
93
+ try:
94
+ page = self._ensure()
95
+ png = page.screenshot(full_page=False)
96
+ b64 = base64.b64encode(png).decode("ascii")
97
+ return BrowserResult(ok=True, action="screenshot", url=page.url, screenshot_b64=b64)
98
+ except Exception as e:
99
+ return BrowserResult(ok=False, action="screenshot", error=str(e))
100
+
101
+ def scrape_text(self) -> BrowserResult:
102
+ if not self.available:
103
+ return BrowserResult(ok=False, action="scrape", error="playwright not available")
104
+ try:
105
+ page = self._ensure()
106
+ content = page.evaluate("() => document.body ? document.body.innerText : ''")
107
+ return BrowserResult(ok=True, action="scrape", url=page.url, text=(content or "")[:20000])
108
+ except Exception as e:
109
+ return BrowserResult(ok=False, action="scrape", error=str(e))
110
+
111
+ def close(self):
112
+ try:
113
+ if self._context: self._context.close()
114
+ except Exception: pass
115
+ try:
116
+ if self._browser: self._browser.close()
117
+ except Exception: pass
118
+ try:
119
+ if self._playwright: self._playwright.stop()
120
+ except Exception: pass
121
+ self._context = self._browser = self._page = self._playwright = None
122
+
123
+
124
+ _browser: Optional[BrowserController] = None
125
+
126
+
127
+ def get_browser() -> BrowserController:
128
+ global _browser
129
+ if _browser is None:
130
+ _browser = BrowserController()
131
+ return _browser
132
+
133
+
134
+ def run_browser_action(action: Dict[str, Any]) -> BrowserResult:
135
+ """action: {"action": "navigate"|"click"|"type"|"screenshot"|"scrape", ...}"""
136
+ b = get_browser()
137
+ op = action.get("action", "")
138
+ if op == "navigate":
139
+ return b.navigate(action.get("url", ""))
140
+ if op == "click":
141
+ return b.click(action.get("selector", ""))
142
+ if op == "type":
143
+ return b.type_text(action.get("selector", ""), action.get("text", ""))
144
+ if op == "screenshot":
145
+ return b.screenshot()
146
+ if op == "scrape":
147
+ return b.scrape_text()
148
+ return BrowserResult(ok=False, action=op, error=f"unknown action: {op}")
backend/classifier.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Error classifier - identifies error categories from tracebacks/output.
3
+ Used by repair engine to produce targeted repair plans.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ from dataclasses import dataclass
9
+ from typing import Optional
10
+
11
+
12
+ @dataclass
13
+ class ErrorClass:
14
+ category: str
15
+ detail: str
16
+ suggested_fix: str
17
+
18
+
19
+ RULES = [
20
+ # python module / pip
21
+ (r"ModuleNotFoundError: No module named ['\"]([\w\.\-]+)['\"]", "missing_python_module"),
22
+ (r"ImportError: No module named ([\w\.\-]+)", "missing_python_module"),
23
+ (r"pip(?:3)?: command not found", "missing_pip"),
24
+ # node / npm
25
+ (r"command not found: (npm|node|npx)", "missing_node"),
26
+ (r"npm ERR! code E?ENOENT", "npm_failure"),
27
+ (r"npm ERR! code (E\w+)", "npm_failure"),
28
+ # playwright
29
+ (r"playwright[^\s]*: command not found", "missing_playwright"),
30
+ (r"Executable doesn't exist at .+(chrom|firefox|webkit)", "playwright_browsers_missing"),
31
+ (r"BrowserType\.launch:.*Host system is missing dependencies", "playwright_missing_deps"),
32
+ # git
33
+ (r"fatal: unable to auto-detect email address", "git_identity_missing"),
34
+ (r"Please tell me who you are\.", "git_identity_missing"),
35
+ (r"fatal: not a git repository", "not_a_git_repo"),
36
+ (r"Authentication failed", "git_auth_failed"),
37
+ # python version / build
38
+ (r"greenlet[^\n]*failed to build", "greenlet_build_failure"),
39
+ (r"Could not build wheels for ([\w\-]+)", "python_build_failure"),
40
+ (r"requires Python ['\"][^'\"]+['\"]", "python_version_mismatch"),
41
+ # network
42
+ (r"Could not resolve host", "network_failure"),
43
+ (r"Connection refused", "network_failure"),
44
+ (r"Read timed out", "network_failure"),
45
+ # http
46
+ (r"\b429\b", "rate_limited"),
47
+ (r"\b401\b|\b403\b", "auth_failure"),
48
+ ]
49
+
50
+
51
+ def classify(output: str) -> Optional[ErrorClass]:
52
+ if not output:
53
+ return None
54
+ for pattern, category in RULES:
55
+ m = re.search(pattern, output)
56
+ if m:
57
+ detail = m.group(0)
58
+ return ErrorClass(category=category, detail=detail, suggested_fix=_fix_for(category, m))
59
+ # Generic exception detection
60
+ if "Traceback (most recent call last):" in output:
61
+ return ErrorClass(category="python_exception", detail="Unclassified Python exception", suggested_fix="inspect traceback and retry")
62
+ return None
63
+
64
+
65
+ def _fix_for(category: str, m: re.Match) -> str:
66
+ if category == "missing_python_module":
67
+ return f"pip install {m.group(1)}"
68
+ if category == "missing_pip":
69
+ return "ensure python3-pip is installed"
70
+ if category == "missing_node":
71
+ return "install node + npm"
72
+ if category == "npm_failure":
73
+ return "delete node_modules and reinstall"
74
+ if category == "missing_playwright":
75
+ return "pip install playwright && python -m playwright install"
76
+ if category == "playwright_browsers_missing":
77
+ return "python -m playwright install chromium"
78
+ if category == "playwright_missing_deps":
79
+ return "python -m playwright install-deps chromium"
80
+ if category == "git_identity_missing":
81
+ return "git config user.email and user.name"
82
+ if category == "not_a_git_repo":
83
+ return "git init or cd into repo"
84
+ if category == "git_auth_failed":
85
+ return "verify GITHUB_TOKEN and remote URL"
86
+ if category == "greenlet_build_failure":
87
+ return "pin Python 3.11 and install build essentials"
88
+ if category == "python_build_failure":
89
+ return "install build-essential and retry"
90
+ if category == "python_version_mismatch":
91
+ return "use Python 3.11"
92
+ if category == "network_failure":
93
+ return "retry after backoff"
94
+ if category == "rate_limited":
95
+ return "rotate provider key or wait"
96
+ if category == "auth_failure":
97
+ return "rotate API key"
98
+ return "retry"
backend/deployers.py ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Deployment helpers for HuggingFace Spaces, Vercel, and GitHub.
3
+ All real - no mocks. Each helper returns a structured result dict.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import json
9
+ import logging
10
+ import shutil
11
+ import subprocess
12
+ import tempfile
13
+ import time
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ import httpx
17
+
18
+ logger = logging.getLogger("deployers")
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # GitHub
23
+ # ---------------------------------------------------------------------------
24
+ def github_push(
25
+ repo_dir: str,
26
+ branch: str = "genspark_ai_developer",
27
+ commit_message: str = "AI Developer Agent commit",
28
+ token: Optional[str] = None,
29
+ remote_url: Optional[str] = None,
30
+ ) -> Dict[str, Any]:
31
+ token = token or os.getenv("GITHUB_TOKEN") or os.getenv("GITHUB_PAT")
32
+ if not token:
33
+ return {"ok": False, "error": "GITHUB_TOKEN missing"}
34
+ try:
35
+ env = os.environ.copy()
36
+ env["GIT_TERMINAL_PROMPT"] = "0"
37
+
38
+ def run(cmd: List[str], check: bool = True) -> subprocess.CompletedProcess:
39
+ r = subprocess.run(cmd, cwd=repo_dir, capture_output=True, text=True, env=env, timeout=120)
40
+ if check and r.returncode != 0:
41
+ raise RuntimeError(f"{' '.join(cmd)}: {r.stderr[:500]}")
42
+ return r
43
+
44
+ # ensure identity
45
+ run(["git", "config", "user.email", "ai-developer@genspark.ai"], check=False)
46
+ run(["git", "config", "user.name", "AI Developer Agent"], check=False)
47
+
48
+ # set token URL
49
+ if remote_url:
50
+ authed = remote_url.replace("https://", f"https://x-access-token:{token}@")
51
+ run(["git", "remote", "set-url", "origin", authed], check=False)
52
+
53
+ run(["git", "add", "-A"], check=False)
54
+ # commit may fail if nothing to commit; that's OK
55
+ commit = subprocess.run(["git", "commit", "-m", commit_message], cwd=repo_dir, capture_output=True, text=True, env=env)
56
+ run(["git", "checkout", "-B", branch], check=False)
57
+ push = subprocess.run(["git", "push", "-u", "origin", branch, "--force"], cwd=repo_dir, capture_output=True, text=True, env=env, timeout=180)
58
+ ok = push.returncode == 0
59
+ return {
60
+ "ok": ok,
61
+ "branch": branch,
62
+ "commit_out": commit.stdout + commit.stderr,
63
+ "push_out": push.stdout + push.stderr,
64
+ }
65
+ except Exception as e:
66
+ logger.exception("github_push failed")
67
+ return {"ok": False, "error": str(e)}
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Hugging Face Space
72
+ # ---------------------------------------------------------------------------
73
+ def hf_ensure_space(
74
+ repo_id: str,
75
+ token: Optional[str] = None,
76
+ sdk: str = "docker",
77
+ private: bool = False,
78
+ ) -> Dict[str, Any]:
79
+ """Create the Space if it doesn't exist (idempotent)."""
80
+ token = token or os.getenv("HF_TOKEN")
81
+ if not token:
82
+ return {"ok": False, "error": "HF_TOKEN missing"}
83
+ try:
84
+ headers = {"Authorization": f"Bearer {token}"}
85
+ info = httpx.get(f"https://huggingface.co/api/spaces/{repo_id}", headers=headers, timeout=30.0)
86
+ if info.status_code == 200:
87
+ return {"ok": True, "created": False, "url": f"https://huggingface.co/spaces/{repo_id}"}
88
+ # create
89
+ owner, name = repo_id.split("/", 1)
90
+ payload = {
91
+ "name": name,
92
+ "organization": None if owner == _hf_whoami(token) else owner,
93
+ "type": "space",
94
+ "sdk": sdk,
95
+ "private": private,
96
+ }
97
+ r = httpx.post(
98
+ "https://huggingface.co/api/repos/create",
99
+ headers={**headers, "Content-Type": "application/json"},
100
+ json=payload,
101
+ timeout=30.0,
102
+ )
103
+ if r.status_code >= 400:
104
+ return {"ok": False, "error": f"create failed: {r.status_code} {r.text[:300]}"}
105
+ return {"ok": True, "created": True, "url": f"https://huggingface.co/spaces/{repo_id}"}
106
+ except Exception as e:
107
+ return {"ok": False, "error": str(e)}
108
+
109
+
110
+ def _hf_whoami(token: str) -> str:
111
+ try:
112
+ r = httpx.get("https://huggingface.co/api/whoami-v2", headers={"Authorization": f"Bearer {token}"}, timeout=15)
113
+ if r.status_code == 200:
114
+ return r.json().get("name", "")
115
+ except Exception:
116
+ pass
117
+ return ""
118
+
119
+
120
+ def hf_push_space(
121
+ source_dir: str,
122
+ repo_id: str,
123
+ token: Optional[str] = None,
124
+ commit_message: str = "Update from AI Developer Agent",
125
+ ) -> Dict[str, Any]:
126
+ """Push contents of source_dir to a HuggingFace Space using git."""
127
+ token = token or os.getenv("HF_TOKEN")
128
+ if not token:
129
+ return {"ok": False, "error": "HF_TOKEN missing"}
130
+ try:
131
+ # First ensure space exists
132
+ ensure = hf_ensure_space(repo_id, token=token, sdk="docker")
133
+ if not ensure.get("ok"):
134
+ return {"ok": False, "error": f"ensure_space: {ensure.get('error')}"}
135
+
136
+ tmp = tempfile.mkdtemp(prefix="hfpush_")
137
+ try:
138
+ remote = f"https://user:{token}@huggingface.co/spaces/{repo_id}"
139
+ # Clone (may be empty)
140
+ clone = subprocess.run(["git", "clone", remote, tmp], capture_output=True, text=True, timeout=120)
141
+ if clone.returncode != 0:
142
+ # try init
143
+ subprocess.run(["git", "init"], cwd=tmp, capture_output=True, text=True)
144
+ subprocess.run(["git", "remote", "add", "origin", remote], cwd=tmp, capture_output=True, text=True)
145
+
146
+ # Copy source files into tmp (preserve .git)
147
+ for entry in os.listdir(source_dir):
148
+ if entry == ".git":
149
+ continue
150
+ src = os.path.join(source_dir, entry)
151
+ dst = os.path.join(tmp, entry)
152
+ if os.path.isdir(src):
153
+ if os.path.exists(dst):
154
+ shutil.rmtree(dst, ignore_errors=True)
155
+ shutil.copytree(src, dst)
156
+ else:
157
+ shutil.copy2(src, dst)
158
+
159
+ subprocess.run(["git", "config", "user.email", "ai-developer@genspark.ai"], cwd=tmp, capture_output=True, text=True)
160
+ subprocess.run(["git", "config", "user.name", "AI Developer Agent"], cwd=tmp, capture_output=True, text=True)
161
+ subprocess.run(["git", "lfs", "install"], cwd=tmp, capture_output=True, text=True)
162
+ subprocess.run(["git", "add", "-A"], cwd=tmp, capture_output=True, text=True)
163
+ commit = subprocess.run(["git", "commit", "-m", commit_message], cwd=tmp, capture_output=True, text=True)
164
+ push = subprocess.run(["git", "push", "origin", "main", "--force"], cwd=tmp, capture_output=True, text=True, timeout=300)
165
+ ok = push.returncode == 0
166
+ return {
167
+ "ok": ok,
168
+ "url": f"https://huggingface.co/spaces/{repo_id}",
169
+ "commit_out": (commit.stdout + commit.stderr)[-800:],
170
+ "push_out": (push.stdout + push.stderr)[-800:],
171
+ }
172
+ finally:
173
+ shutil.rmtree(tmp, ignore_errors=True)
174
+ except Exception as e:
175
+ logger.exception("hf_push_space failed")
176
+ return {"ok": False, "error": str(e)}
177
+
178
+
179
+ def hf_space_health(repo_id: str, path: str = "/health", timeout: float = 15.0) -> Dict[str, Any]:
180
+ """Check the live space URL for health."""
181
+ owner, name = repo_id.split("/", 1)
182
+ url = f"https://{owner}-{name}.hf.space{path}"
183
+ try:
184
+ r = httpx.get(url, timeout=timeout)
185
+ return {"ok": r.status_code < 500, "status": r.status_code, "url": url, "body": r.text[:500]}
186
+ except Exception as e:
187
+ return {"ok": False, "url": url, "error": str(e)}
188
+
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # Vercel
192
+ # ---------------------------------------------------------------------------
193
+ def vercel_deploy_via_api(
194
+ project_name: str,
195
+ files: List[Dict[str, Any]],
196
+ token: Optional[str] = None,
197
+ target: str = "production",
198
+ env: Optional[Dict[str, str]] = None,
199
+ framework: Optional[str] = "nextjs",
200
+ install_command: Optional[str] = None,
201
+ build_command: Optional[str] = None,
202
+ ) -> Dict[str, Any]:
203
+ """
204
+ Deploy via Vercel HTTP API.
205
+
206
+ files: list of {"file": "path/in/repo", "data": "file contents"}
207
+ """
208
+ token = token or os.getenv("VERCEL_TOKEN")
209
+ if not token:
210
+ return {"ok": False, "error": "VERCEL_TOKEN missing"}
211
+ headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
212
+ body: Dict[str, Any] = {
213
+ "name": project_name,
214
+ "files": files,
215
+ "target": target,
216
+ "projectSettings": {
217
+ "framework": framework,
218
+ "installCommand": install_command,
219
+ "buildCommand": build_command,
220
+ },
221
+ }
222
+ if env:
223
+ body["env"] = env
224
+ body["build"] = {"env": env}
225
+ try:
226
+ r = httpx.post("https://api.vercel.com/v13/deployments", headers=headers, json=body, timeout=180.0)
227
+ if r.status_code >= 400:
228
+ return {"ok": False, "status": r.status_code, "error": r.text[:1000]}
229
+ data = r.json()
230
+ url = data.get("url") or ""
231
+ full = f"https://{url}" if url and not url.startswith("http") else url
232
+ return {"ok": True, "url": full, "id": data.get("id"), "data": data}
233
+ except Exception as e:
234
+ logger.exception("vercel_deploy_via_api failed")
235
+ return {"ok": False, "error": str(e)}
236
+
237
+
238
+ def vercel_deployment_status(deployment_id: str, token: Optional[str] = None) -> Dict[str, Any]:
239
+ token = token or os.getenv("VERCEL_TOKEN")
240
+ if not token:
241
+ return {"ok": False, "error": "VERCEL_TOKEN missing"}
242
+ try:
243
+ r = httpx.get(
244
+ f"https://api.vercel.com/v13/deployments/{deployment_id}",
245
+ headers={"Authorization": f"Bearer {token}"},
246
+ timeout=30.0,
247
+ )
248
+ if r.status_code >= 400:
249
+ return {"ok": False, "status": r.status_code, "error": r.text[:500]}
250
+ d = r.json()
251
+ return {"ok": True, "state": d.get("readyState"), "url": d.get("url"), "data": d}
252
+ except Exception as e:
253
+ return {"ok": False, "error": str(e)}
254
+
255
+
256
+ def collect_files_for_vercel(root_dir: str) -> List[Dict[str, Any]]:
257
+ """Walk root_dir and produce list of {file, data} for Vercel API.
258
+
259
+ Skips node_modules, .git, .next, .vercel and other build artifacts.
260
+ """
261
+ SKIP_DIRS = {"node_modules", ".git", ".next", ".vercel", "dist", "build", "__pycache__"}
262
+ SKIP_EXTS = {".log"}
263
+ files: List[Dict[str, Any]] = []
264
+ for cur, dirs, fns in os.walk(root_dir):
265
+ dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
266
+ for fn in fns:
267
+ if any(fn.endswith(e) for e in SKIP_EXTS):
268
+ continue
269
+ full = os.path.join(cur, fn)
270
+ rel = os.path.relpath(full, root_dir).replace("\\", "/")
271
+ try:
272
+ with open(full, "r", encoding="utf-8") as f:
273
+ data = f.read()
274
+ except UnicodeDecodeError:
275
+ with open(full, "rb") as f:
276
+ import base64 as _b
277
+ data = _b.b64encode(f.read()).decode("ascii")
278
+ files.append({"file": rel, "data": data, "encoding": "base64"})
279
+ continue
280
+ files.append({"file": rel, "data": data})
281
+ return files
backend/executor.py ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Executor - runs plan actions. Uses E2B sandbox when E2B_API_KEY is set,
3
+ otherwise falls back to local subprocess execution (with strict allowlist).
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import shlex
9
+ import subprocess
10
+ import logging
11
+ import threading
12
+ import time
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, Dict, Generator, List, Optional
15
+
16
+ logger = logging.getLogger("executor")
17
+
18
+ E2B_API_KEY = os.getenv("E2B_API_KEY", "")
19
+
20
+
21
+ @dataclass
22
+ class ExecutionResult:
23
+ ok: bool
24
+ stdout: str = ""
25
+ stderr: str = ""
26
+ exit_code: int = 0
27
+ duration_ms: float = 0.0
28
+ meta: Dict[str, Any] = field(default_factory=dict)
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # E2B sandbox wrapper (with graceful fallback)
33
+ # ---------------------------------------------------------------------------
34
+ class E2BSandbox:
35
+ """Thin wrapper around e2b_code_interpreter. Created lazily."""
36
+
37
+ def __init__(self) -> None:
38
+ self._sbx = None
39
+ self._lock = threading.Lock()
40
+ self._available = False
41
+ if E2B_API_KEY:
42
+ try:
43
+ from e2b_code_interpreter import Sandbox # type: ignore
44
+ self._Sandbox = Sandbox
45
+ self._available = True
46
+ except Exception as e:
47
+ logger.warning("E2B SDK not available: %s", e)
48
+ self._available = False
49
+
50
+ @property
51
+ def available(self) -> bool:
52
+ return self._available
53
+
54
+ def _ensure(self):
55
+ if self._sbx is None:
56
+ self._sbx = self._Sandbox(api_key=E2B_API_KEY)
57
+ return self._sbx
58
+
59
+ def run_shell(self, cmd: str, timeout: float = 120.0) -> ExecutionResult:
60
+ started = time.time()
61
+ with self._lock:
62
+ try:
63
+ sbx = self._ensure()
64
+ # Newer e2b SDKs use sbx.commands.run
65
+ try:
66
+ cmd_result = sbx.commands.run(cmd, timeout=int(timeout))
67
+ stdout = getattr(cmd_result, "stdout", "") or ""
68
+ stderr = getattr(cmd_result, "stderr", "") or ""
69
+ exit_code = getattr(cmd_result, "exit_code", 0) or 0
70
+ except AttributeError:
71
+ # Fallback for legacy SDK
72
+ res = sbx.run_code(f"import subprocess; r=subprocess.run({cmd!r}, shell=True, capture_output=True, text=True, timeout={timeout}); print(r.stdout); print(r.stderr)")
73
+ stdout = "\n".join([str(getattr(r, "text", "")) for r in getattr(res, "logs", {}).get("stdout", []) or []])
74
+ stderr = ""
75
+ exit_code = 0
76
+ ok = exit_code == 0
77
+ return ExecutionResult(
78
+ ok=ok, stdout=stdout, stderr=stderr, exit_code=exit_code,
79
+ duration_ms=(time.time() - started) * 1000,
80
+ meta={"engine": "e2b"},
81
+ )
82
+ except Exception as e:
83
+ logger.exception("E2B run_shell failed")
84
+ return ExecutionResult(
85
+ ok=False, stderr=str(e), exit_code=1,
86
+ duration_ms=(time.time() - started) * 1000,
87
+ meta={"engine": "e2b", "error": True},
88
+ )
89
+
90
+ def run_python(self, code: str, timeout: float = 120.0) -> ExecutionResult:
91
+ started = time.time()
92
+ with self._lock:
93
+ try:
94
+ sbx = self._ensure()
95
+ try:
96
+ res = sbx.run_code(code, timeout=int(timeout))
97
+ stdout_logs = []
98
+ stderr_logs = []
99
+ if hasattr(res, "logs"):
100
+ for entry in getattr(res.logs, "stdout", []) or []:
101
+ stdout_logs.append(str(entry))
102
+ for entry in getattr(res.logs, "stderr", []) or []:
103
+ stderr_logs.append(str(entry))
104
+ return ExecutionResult(
105
+ ok=True,
106
+ stdout="\n".join(stdout_logs),
107
+ stderr="\n".join(stderr_logs),
108
+ exit_code=0,
109
+ duration_ms=(time.time() - started) * 1000,
110
+ meta={"engine": "e2b"},
111
+ )
112
+ except Exception as e:
113
+ return ExecutionResult(
114
+ ok=False, stderr=str(e), exit_code=1,
115
+ duration_ms=(time.time() - started) * 1000,
116
+ meta={"engine": "e2b", "error": True},
117
+ )
118
+ except Exception as e:
119
+ return ExecutionResult(
120
+ ok=False, stderr=str(e), exit_code=1,
121
+ duration_ms=(time.time() - started) * 1000,
122
+ meta={"engine": "e2b", "error": True},
123
+ )
124
+
125
+ def close(self):
126
+ with self._lock:
127
+ try:
128
+ if self._sbx is not None:
129
+ self._sbx.kill()
130
+ except Exception:
131
+ pass
132
+ self._sbx = None
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # Local subprocess fallback - LIMITED commands only
137
+ # ---------------------------------------------------------------------------
138
+ _DISALLOWED_PATTERNS = [
139
+ "rm -rf /",
140
+ ":(){:|:&};:",
141
+ "mkfs",
142
+ "> /dev/sda",
143
+ ]
144
+
145
+
146
+ def _local_run_shell(cmd: str, timeout: float = 120.0) -> ExecutionResult:
147
+ started = time.time()
148
+ if any(p in cmd for p in _DISALLOWED_PATTERNS):
149
+ return ExecutionResult(ok=False, stderr="Disallowed command", exit_code=126,
150
+ duration_ms=(time.time() - started) * 1000)
151
+ try:
152
+ res = subprocess.run(
153
+ cmd, shell=True, capture_output=True, text=True, timeout=timeout,
154
+ )
155
+ return ExecutionResult(
156
+ ok=res.returncode == 0,
157
+ stdout=res.stdout or "",
158
+ stderr=res.stderr or "",
159
+ exit_code=res.returncode,
160
+ duration_ms=(time.time() - started) * 1000,
161
+ meta={"engine": "local"},
162
+ )
163
+ except subprocess.TimeoutExpired as e:
164
+ return ExecutionResult(ok=False, stderr=f"timeout: {e}", exit_code=124,
165
+ duration_ms=(time.time() - started) * 1000,
166
+ meta={"engine": "local"})
167
+ except Exception as e:
168
+ return ExecutionResult(ok=False, stderr=str(e), exit_code=1,
169
+ duration_ms=(time.time() - started) * 1000,
170
+ meta={"engine": "local"})
171
+
172
+
173
+ def _local_run_python(code: str, timeout: float = 120.0) -> ExecutionResult:
174
+ # Run python in subprocess for isolation
175
+ started = time.time()
176
+ try:
177
+ res = subprocess.run(
178
+ ["python3", "-c", code], capture_output=True, text=True, timeout=timeout,
179
+ )
180
+ return ExecutionResult(
181
+ ok=res.returncode == 0,
182
+ stdout=res.stdout or "",
183
+ stderr=res.stderr or "",
184
+ exit_code=res.returncode,
185
+ duration_ms=(time.time() - started) * 1000,
186
+ meta={"engine": "local"},
187
+ )
188
+ except subprocess.TimeoutExpired as e:
189
+ return ExecutionResult(ok=False, stderr=f"timeout: {e}", exit_code=124,
190
+ duration_ms=(time.time() - started) * 1000)
191
+ except Exception as e:
192
+ return ExecutionResult(ok=False, stderr=str(e), exit_code=1,
193
+ duration_ms=(time.time() - started) * 1000)
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # Executor singleton
198
+ # ---------------------------------------------------------------------------
199
+ class Executor:
200
+ def __init__(self) -> None:
201
+ self.sandbox = E2BSandbox() if E2B_API_KEY else None
202
+
203
+ def shell(self, cmd: str, timeout: float = 120.0) -> ExecutionResult:
204
+ if self.sandbox and self.sandbox.available:
205
+ return self.sandbox.run_shell(cmd, timeout=timeout)
206
+ return _local_run_shell(cmd, timeout=timeout)
207
+
208
+ def python(self, code: str, timeout: float = 120.0) -> ExecutionResult:
209
+ if self.sandbox and self.sandbox.available:
210
+ return self.sandbox.run_python(code, timeout=timeout)
211
+ return _local_run_python(code, timeout=timeout)
212
+
213
+ def inspect_runtime(self) -> Dict[str, str]:
214
+ info: Dict[str, str] = {}
215
+ for label, cmd in [
216
+ ("python", "python3 --version"),
217
+ ("node", "node --version"),
218
+ ("npm", "npm --version"),
219
+ ("git", "git --version"),
220
+ ("playwright", "python3 -c 'import playwright; print(playwright.__version__)' 2>/dev/null || echo 'not installed'"),
221
+ ]:
222
+ r = self.shell(cmd, timeout=15)
223
+ info[label] = (r.stdout or r.stderr).strip()[:200]
224
+ return info
225
+
226
+ def close(self):
227
+ if self.sandbox:
228
+ self.sandbox.close()
229
+
230
+
231
+ _executor: Optional[Executor] = None
232
+
233
+
234
+ def get_executor() -> Executor:
235
+ global _executor
236
+ if _executor is None:
237
+ _executor = Executor()
238
+ return _executor
backend/llm_router.py ADDED
@@ -0,0 +1,407 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Multi-Provider LLM Router with key rotation, cooldowns, health tracking,
3
+ and adaptive provider selection.
4
+
5
+ Supported providers:
6
+ - OpenAI (chat completions, OpenAI compatible)
7
+ - Gemini (Google Generative AI)
8
+ - SambaNova (OpenAI-compatible API)
9
+ - GitHub Models (OpenAI-compatible /inference endpoint)
10
+ - OpenRouter (OpenAI-compatible)
11
+
12
+ Environment variables (comma-separated key lists):
13
+ OPENAI_API_KEYS
14
+ GEMINI_API_KEYS
15
+ SAMBANOVA_API_KEYS
16
+ GITHUB_LLM_API_KEYS
17
+ OPENROUTER_API_KEYS
18
+
19
+ Public API:
20
+ router = LLMRouter()
21
+ text = router.chat(messages, model=None, temperature=0.2)
22
+ for chunk in router.stream(messages, model=None): ...
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import os
28
+ import random
29
+ import time
30
+ import threading
31
+ import logging
32
+ from dataclasses import dataclass, field
33
+ from typing import Generator, Iterable, List, Optional, Dict, Any
34
+
35
+ import httpx
36
+
37
+ logger = logging.getLogger("llm_router")
38
+
39
+
40
+ def _split_env(name: str) -> List[str]:
41
+ raw = os.getenv(name, "")
42
+ return [k.strip() for k in raw.split(",") if k.strip()]
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Key registry
47
+ # ---------------------------------------------------------------------------
48
+ @dataclass
49
+ class KeyState:
50
+ key: str
51
+ provider: str
52
+ cooldown_until: float = 0.0
53
+ failures: int = 0
54
+ requests: int = 0
55
+ last_latency_ms: float = 0.0
56
+ last_error: str = ""
57
+
58
+ def healthy(self) -> bool:
59
+ return time.time() >= self.cooldown_until
60
+
61
+
62
+ @dataclass
63
+ class ProviderConfig:
64
+ name: str
65
+ default_model: str
66
+ base_url: str
67
+ api_style: str # "openai" | "gemini"
68
+ # Optional extra headers (e.g. for GitHub Models)
69
+ extra_headers: Dict[str, str] = field(default_factory=dict)
70
+
71
+
72
+ PROVIDER_CONFIGS: Dict[str, ProviderConfig] = {
73
+ "openai": ProviderConfig(
74
+ name="openai",
75
+ default_model="gpt-4o-mini",
76
+ base_url="https://api.openai.com/v1",
77
+ api_style="openai",
78
+ ),
79
+ "gemini": ProviderConfig(
80
+ name="gemini",
81
+ default_model="gemini-1.5-flash-latest",
82
+ base_url="https://generativelanguage.googleapis.com/v1beta",
83
+ api_style="gemini",
84
+ ),
85
+ "sambanova": ProviderConfig(
86
+ name="sambanova",
87
+ default_model="Meta-Llama-3.3-70B-Instruct",
88
+ base_url="https://api.sambanova.ai/v1",
89
+ api_style="openai",
90
+ ),
91
+ "github": ProviderConfig(
92
+ name="github",
93
+ default_model="gpt-4o-mini",
94
+ base_url="https://models.github.ai/inference",
95
+ api_style="openai",
96
+ ),
97
+ "openrouter": ProviderConfig(
98
+ name="openrouter",
99
+ default_model="openai/gpt-4o-mini",
100
+ base_url="https://openrouter.ai/api/v1",
101
+ api_style="openai",
102
+ extra_headers={"HTTP-Referer": "https://github.com/ai-developer-agent", "X-Title": "AI Developer Agent"},
103
+ ),
104
+ }
105
+
106
+
107
+ class LLMRouter:
108
+ """Thread-safe, multi-provider LLM router with rotation + failover."""
109
+
110
+ def __init__(self) -> None:
111
+ self._lock = threading.Lock()
112
+ self._registry: Dict[str, List[KeyState]] = {}
113
+ self._rr_index: Dict[str, int] = {}
114
+ self._provider_priority: List[str] = []
115
+ self._load_keys()
116
+
117
+ # -------------------- key registry --------------------
118
+ def _load_keys(self) -> None:
119
+ mapping = [
120
+ ("openai", "OPENAI_API_KEYS"),
121
+ ("gemini", "GEMINI_API_KEYS"),
122
+ ("sambanova", "SAMBANOVA_API_KEYS"),
123
+ ("github", "GITHUB_LLM_API_KEYS"),
124
+ ("openrouter", "OPENROUTER_API_KEYS"),
125
+ ]
126
+ for provider, env_name in mapping:
127
+ keys = _split_env(env_name)
128
+ if keys:
129
+ self._registry[provider] = [KeyState(k, provider) for k in keys]
130
+ self._rr_index[provider] = 0
131
+ self._provider_priority.append(provider)
132
+
133
+ if not self._registry:
134
+ logger.warning("LLMRouter: no provider keys configured.")
135
+
136
+ def telemetry(self) -> Dict[str, Any]:
137
+ out: Dict[str, Any] = {}
138
+ with self._lock:
139
+ for provider, states in self._registry.items():
140
+ out[provider] = {
141
+ "keys": len(states),
142
+ "healthy_keys": sum(1 for s in states if s.healthy()),
143
+ "total_requests": sum(s.requests for s in states),
144
+ "failures": sum(s.failures for s in states),
145
+ "last_error": next((s.last_error for s in states if s.last_error), ""),
146
+ }
147
+ return out
148
+
149
+ def _pick_key(self, provider: str) -> Optional[KeyState]:
150
+ states = self._registry.get(provider, [])
151
+ if not states:
152
+ return None
153
+ idx = self._rr_index.get(provider, 0)
154
+ n = len(states)
155
+ for i in range(n):
156
+ s = states[(idx + i) % n]
157
+ if s.healthy():
158
+ self._rr_index[provider] = (idx + i + 1) % n
159
+ return s
160
+ return None
161
+
162
+ def _cooldown(self, state: KeyState, seconds: float, error: str = "") -> None:
163
+ with self._lock:
164
+ state.cooldown_until = time.time() + seconds
165
+ state.failures += 1
166
+ state.last_error = error[:300]
167
+
168
+ # -------------------- public API --------------------
169
+ def chat(
170
+ self,
171
+ messages: List[Dict[str, str]],
172
+ model: Optional[str] = None,
173
+ temperature: float = 0.2,
174
+ max_tokens: int = 1500,
175
+ timeout: float = 60.0,
176
+ preferred_provider: Optional[str] = None,
177
+ ) -> str:
178
+ chunks: List[str] = []
179
+ for chunk in self.stream(
180
+ messages,
181
+ model=model,
182
+ temperature=temperature,
183
+ max_tokens=max_tokens,
184
+ timeout=timeout,
185
+ preferred_provider=preferred_provider,
186
+ ):
187
+ chunks.append(chunk)
188
+ return "".join(chunks)
189
+
190
+ def stream(
191
+ self,
192
+ messages: List[Dict[str, str]],
193
+ model: Optional[str] = None,
194
+ temperature: float = 0.2,
195
+ max_tokens: int = 1500,
196
+ timeout: float = 60.0,
197
+ preferred_provider: Optional[str] = None,
198
+ ) -> Generator[str, None, None]:
199
+ """Try providers in priority order, with key rotation per provider."""
200
+ order = list(self._provider_priority)
201
+ if preferred_provider and preferred_provider in order:
202
+ order.remove(preferred_provider)
203
+ order.insert(0, preferred_provider)
204
+
205
+ if not order:
206
+ yield "[LLMRouter] No providers configured. Returning placeholder.\n"
207
+ yield self._offline_placeholder(messages)
208
+ return
209
+
210
+ last_error = "no providers tried"
211
+ for provider in order:
212
+ tried = 0
213
+ max_keys = len(self._registry.get(provider, []))
214
+ while tried < max_keys:
215
+ key_state = self._pick_key(provider)
216
+ if key_state is None:
217
+ break
218
+ tried += 1
219
+ try:
220
+ started = time.time()
221
+ yielded_any = False
222
+ for chunk in self._stream_provider(
223
+ provider,
224
+ key_state,
225
+ messages,
226
+ model=model,
227
+ temperature=temperature,
228
+ max_tokens=max_tokens,
229
+ timeout=timeout,
230
+ ):
231
+ yielded_any = True
232
+ yield chunk
233
+ if yielded_any:
234
+ with self._lock:
235
+ key_state.requests += 1
236
+ key_state.last_latency_ms = (time.time() - started) * 1000
237
+ return
238
+ else:
239
+ last_error = f"{provider}: empty response"
240
+ self._cooldown(key_state, 15, last_error)
241
+ except httpx.HTTPStatusError as e:
242
+ code = e.response.status_code if e.response else 0
243
+ body = ""
244
+ try:
245
+ body = e.response.text[:300] if e.response else ""
246
+ except Exception:
247
+ body = ""
248
+ last_error = f"{provider}/{code}: {body}"
249
+ logger.warning(last_error)
250
+ if code == 429:
251
+ self._cooldown(key_state, 60, last_error)
252
+ elif 500 <= code < 600:
253
+ self._cooldown(key_state, 30, last_error)
254
+ elif code in (401, 403):
255
+ # bad key – cool it down for a long time
256
+ self._cooldown(key_state, 3600, last_error)
257
+ else:
258
+ self._cooldown(key_state, 10, last_error)
259
+ except Exception as e:
260
+ last_error = f"{provider} error: {e}"
261
+ logger.exception("Provider error")
262
+ self._cooldown(key_state, 20, last_error)
263
+
264
+ yield f"[LLMRouter] All providers failed. Last error: {last_error}\n"
265
+ yield self._offline_placeholder(messages)
266
+
267
+ def _offline_placeholder(self, messages: List[Dict[str, str]]) -> str:
268
+ last_user = next((m["content"] for m in reversed(messages) if m.get("role") == "user"), "")
269
+ return (
270
+ "OFFLINE_PLAN_FALLBACK\n"
271
+ "I could not reach any LLM provider. Producing a heuristic response.\n"
272
+ f"User intent: {last_user[:200]}\n"
273
+ )
274
+
275
+ # -------------------- provider impls --------------------
276
+ def _stream_provider(
277
+ self,
278
+ provider: str,
279
+ key_state: KeyState,
280
+ messages: List[Dict[str, str]],
281
+ model: Optional[str],
282
+ temperature: float,
283
+ max_tokens: int,
284
+ timeout: float,
285
+ ) -> Generator[str, None, None]:
286
+ cfg = PROVIDER_CONFIGS[provider]
287
+ if cfg.api_style == "openai":
288
+ yield from self._stream_openai_compatible(cfg, key_state, messages, model, temperature, max_tokens, timeout)
289
+ elif cfg.api_style == "gemini":
290
+ yield from self._stream_gemini(cfg, key_state, messages, model, temperature, max_tokens, timeout)
291
+ else:
292
+ raise RuntimeError(f"Unknown api_style: {cfg.api_style}")
293
+
294
+ def _stream_openai_compatible(
295
+ self,
296
+ cfg: ProviderConfig,
297
+ key_state: KeyState,
298
+ messages: List[Dict[str, str]],
299
+ model: Optional[str],
300
+ temperature: float,
301
+ max_tokens: int,
302
+ timeout: float,
303
+ ) -> Generator[str, None, None]:
304
+ url = f"{cfg.base_url}/chat/completions"
305
+ headers = {
306
+ "Authorization": f"Bearer {key_state.key}",
307
+ "Content-Type": "application/json",
308
+ }
309
+ headers.update(cfg.extra_headers)
310
+ body = {
311
+ "model": model or cfg.default_model,
312
+ "messages": messages,
313
+ "temperature": temperature,
314
+ "max_tokens": max_tokens,
315
+ "stream": True,
316
+ }
317
+ with httpx.Client(timeout=timeout) as client:
318
+ with client.stream("POST", url, json=body, headers=headers) as r:
319
+ if r.status_code >= 400:
320
+ text = r.read().decode("utf-8", "ignore")
321
+ raise httpx.HTTPStatusError(text[:300], request=r.request, response=r)
322
+ for line in r.iter_lines():
323
+ if not line:
324
+ continue
325
+ if isinstance(line, bytes):
326
+ line = line.decode("utf-8", "ignore")
327
+ if line.startswith("data:"):
328
+ line = line[5:].strip()
329
+ if not line or line == "[DONE]":
330
+ continue
331
+ try:
332
+ obj = json.loads(line)
333
+ delta = obj.get("choices", [{}])[0].get("delta", {})
334
+ content = delta.get("content") or ""
335
+ if content:
336
+ yield content
337
+ except Exception:
338
+ continue
339
+
340
+ def _stream_gemini(
341
+ self,
342
+ cfg: ProviderConfig,
343
+ key_state: KeyState,
344
+ messages: List[Dict[str, str]],
345
+ model: Optional[str],
346
+ temperature: float,
347
+ max_tokens: int,
348
+ timeout: float,
349
+ ) -> Generator[str, None, None]:
350
+ model_name = model or cfg.default_model
351
+ # Use streamGenerateContent
352
+ url = f"{cfg.base_url}/models/{model_name}:streamGenerateContent?alt=sse&key={key_state.key}"
353
+ contents = []
354
+ sys_prompt = ""
355
+ for m in messages:
356
+ role = m.get("role", "user")
357
+ content = m.get("content", "")
358
+ if role == "system":
359
+ sys_prompt += content + "\n"
360
+ continue
361
+ mapped = "user" if role == "user" else "model"
362
+ contents.append({"role": mapped, "parts": [{"text": content}]})
363
+ body: Dict[str, Any] = {
364
+ "contents": contents,
365
+ "generationConfig": {
366
+ "temperature": temperature,
367
+ "maxOutputTokens": max_tokens,
368
+ },
369
+ }
370
+ if sys_prompt:
371
+ body["systemInstruction"] = {"parts": [{"text": sys_prompt.strip()}]}
372
+ headers = {"Content-Type": "application/json"}
373
+ with httpx.Client(timeout=timeout) as client:
374
+ with client.stream("POST", url, json=body, headers=headers) as r:
375
+ if r.status_code >= 400:
376
+ text = r.read().decode("utf-8", "ignore")
377
+ raise httpx.HTTPStatusError(text[:300], request=r.request, response=r)
378
+ for line in r.iter_lines():
379
+ if not line:
380
+ continue
381
+ if isinstance(line, bytes):
382
+ line = line.decode("utf-8", "ignore")
383
+ if line.startswith("data:"):
384
+ line = line[5:].strip()
385
+ if not line or line == "[DONE]":
386
+ continue
387
+ try:
388
+ obj = json.loads(line)
389
+ for cand in obj.get("candidates", []):
390
+ parts = cand.get("content", {}).get("parts", [])
391
+ for p in parts:
392
+ t = p.get("text")
393
+ if t:
394
+ yield t
395
+ except Exception:
396
+ continue
397
+
398
+
399
+ # Singleton getter
400
+ _router_singleton: Optional[LLMRouter] = None
401
+
402
+
403
+ def get_router() -> LLMRouter:
404
+ global _router_singleton
405
+ if _router_singleton is None:
406
+ _router_singleton = LLMRouter()
407
+ return _router_singleton
backend/planner.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Autonomous task planner.
3
+
4
+ Produces structured action plans (list of dicts):
5
+ {"type": "shell"|"python"|"browser"|"git"|"deploy"|"note", ...}
6
+
7
+ Strategy:
8
+ 1. Try LLM-based planning with strict JSON output.
9
+ 2. If LLM fails or returns invalid JSON, use heuristic fallback.
10
+ 3. Always produces non-empty plan.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import logging
16
+ import re
17
+ from typing import Any, Dict, List
18
+
19
+ from .llm_router import get_router
20
+
21
+ logger = logging.getLogger("planner")
22
+
23
+ PLANNER_SYSTEM = (
24
+ "You are an autonomous AI Developer Agent's planner. "
25
+ "Decompose a user task into a JSON list of concrete actions. "
26
+ "Each action is an object with a 'type' field and supporting fields. "
27
+ "Supported types: shell (cmd), python (code), browser (action,url,...), "
28
+ "git (op,args), deploy (target), note (msg). "
29
+ "Return ONLY a JSON array, no commentary. Keep plan under 12 steps."
30
+ )
31
+
32
+
33
+ def plan_task(title: str, description: str, context: Dict[str, Any] | None = None) -> List[Dict[str, Any]]:
34
+ """Generate a concrete action plan."""
35
+ router = get_router()
36
+ user_prompt = (
37
+ f"TASK TITLE: {title}\n"
38
+ f"DESCRIPTION:\n{description}\n\n"
39
+ f"CONTEXT:\n{json.dumps(context or {}, indent=2)[:2000]}\n\n"
40
+ "Output a JSON array of action objects. Example:\n"
41
+ '[{"type":"shell","cmd":"echo hi"},{"type":"note","msg":"done"}]'
42
+ )
43
+ messages = [
44
+ {"role": "system", "content": PLANNER_SYSTEM},
45
+ {"role": "user", "content": user_prompt},
46
+ ]
47
+ try:
48
+ raw = router.chat(messages, temperature=0.1, max_tokens=1200, timeout=45.0)
49
+ except Exception as e:
50
+ logger.warning("Planner LLM call failed: %s", e)
51
+ raw = ""
52
+
53
+ plan = _parse_plan_json(raw)
54
+ if plan:
55
+ return plan
56
+ logger.info("Planner falling back to heuristic plan")
57
+ return heuristic_plan(title, description)
58
+
59
+
60
+ def _parse_plan_json(raw: str) -> List[Dict[str, Any]]:
61
+ if not raw:
62
+ return []
63
+ # Try direct
64
+ try:
65
+ obj = json.loads(raw)
66
+ if isinstance(obj, list):
67
+ return [a for a in obj if isinstance(a, dict) and "type" in a]
68
+ except Exception:
69
+ pass
70
+ # Find first JSON array in text
71
+ m = re.search(r"\[[\s\S]*\]", raw)
72
+ if m:
73
+ try:
74
+ obj = json.loads(m.group(0))
75
+ if isinstance(obj, list):
76
+ return [a for a in obj if isinstance(a, dict) and "type" in a]
77
+ except Exception:
78
+ return []
79
+ return []
80
+
81
+
82
+ def heuristic_plan(title: str, description: str) -> List[Dict[str, Any]]:
83
+ """Always-valid fallback plan."""
84
+ text = (title + "\n" + description).lower()
85
+ plan: List[Dict[str, Any]] = []
86
+
87
+ if any(k in text for k in ["deploy", "deployment", "huggingface", "hf space", "vercel"]):
88
+ plan.append({"type": "note", "msg": "Deployment task detected"})
89
+ if "vercel" in text:
90
+ plan.append({"type": "deploy", "target": "vercel"})
91
+ if "huggingface" in text or "hf" in text:
92
+ plan.append({"type": "deploy", "target": "huggingface"})
93
+ return plan
94
+
95
+ if any(k in text for k in ["git", "github", "commit", "push", "pr ", "pull request"]):
96
+ plan.append({"type": "git", "op": "status"})
97
+ plan.append({"type": "note", "msg": "GitHub task detected"})
98
+ return plan
99
+
100
+ if any(k in text for k in ["browser", "scrape", "navigate", "click", "open url", "http://", "https://"]):
101
+ url_match = re.search(r"https?://[\w\.\-/?=&%#]+", description)
102
+ url = url_match.group(0) if url_match else "https://example.com"
103
+ plan.append({"type": "browser", "action": "navigate", "url": url})
104
+ plan.append({"type": "browser", "action": "screenshot"})
105
+ return plan
106
+
107
+ if any(k in text for k in ["run python", "python script", "execute python"]):
108
+ plan.append({"type": "python", "code": "print('Hello from AI Developer Agent')"})
109
+ return plan
110
+
111
+ if any(k in text for k in ["install", "pip ", "npm "]):
112
+ # Try to extract a package name
113
+ m = re.search(r"(?:install|add)\s+([\w\.\-]+)", text)
114
+ pkg = m.group(1) if m else ""
115
+ if "npm" in text:
116
+ plan.append({"type": "shell", "cmd": f"npm install {pkg}".strip()})
117
+ else:
118
+ plan.append({"type": "shell", "cmd": f"pip install {pkg}".strip()})
119
+ return plan
120
+
121
+ # Generic fallback: echo the task back as an inspection action.
122
+ plan.append({"type": "note", "msg": f"Plan-fallback for: {title}"})
123
+ plan.append({"type": "shell", "cmd": "uname -a && python3 --version && node --version 2>/dev/null || true"})
124
+ return plan
125
+
126
+
127
+ def repair_plan(error_category: str, detail: str = "") -> List[Dict[str, Any]]:
128
+ from .classifier import ErrorClass
129
+ from .repair import repair_actions
130
+ return repair_actions(ErrorClass(category=error_category, detail=detail, suggested_fix=""))
backend/repair.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Self-repair engine: converts an ErrorClass into actionable shell commands.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ from typing import List
7
+ from .classifier import ErrorClass
8
+
9
+
10
+ def repair_actions(err: ErrorClass) -> List[dict]:
11
+ """Return list of structured actions (type=shell|git|python)."""
12
+ actions: List[dict] = []
13
+ c = err.category
14
+
15
+ if c == "missing_python_module":
16
+ # err.detail might be 'ModuleNotFoundError: No module named "x"'
17
+ import re
18
+ m = re.search(r"['\"]([\w\.\-]+)['\"]", err.detail)
19
+ pkg = m.group(1) if m else err.detail.split()[-1].strip("'\"")
20
+ actions.append({"type": "shell", "cmd": f"pip install --no-cache-dir {pkg}"})
21
+
22
+ elif c == "missing_pip":
23
+ actions.append({"type": "shell", "cmd": "python -m ensurepip --upgrade || apt-get install -y python3-pip"})
24
+
25
+ elif c == "missing_node":
26
+ actions.append({"type": "shell", "cmd": "apt-get update && apt-get install -y nodejs npm"})
27
+
28
+ elif c == "npm_failure":
29
+ actions.append({"type": "shell", "cmd": "rm -rf node_modules package-lock.json && npm install"})
30
+
31
+ elif c == "missing_playwright":
32
+ actions.append({"type": "shell", "cmd": "pip install playwright && python -m playwright install --with-deps chromium"})
33
+
34
+ elif c == "playwright_browsers_missing":
35
+ actions.append({"type": "shell", "cmd": "python -m playwright install chromium"})
36
+
37
+ elif c == "playwright_missing_deps":
38
+ actions.append({"type": "shell", "cmd": "python -m playwright install-deps chromium"})
39
+
40
+ elif c == "git_identity_missing":
41
+ actions.append({"type": "shell", "cmd": "git config --global user.email 'ai-developer@genspark.ai' && git config --global user.name 'AI Developer Agent'"})
42
+
43
+ elif c == "not_a_git_repo":
44
+ actions.append({"type": "shell", "cmd": "git init"})
45
+
46
+ elif c == "git_auth_failed":
47
+ actions.append({"type": "note", "msg": "ensure GITHUB_TOKEN is set and remote uses token URL"})
48
+
49
+ elif c == "greenlet_build_failure":
50
+ actions.append({"type": "shell", "cmd": "pip install --upgrade pip setuptools wheel && pip install greenlet --only-binary :all:"})
51
+
52
+ elif c == "python_build_failure":
53
+ actions.append({"type": "shell", "cmd": "apt-get install -y build-essential python3-dev && pip install --upgrade pip setuptools wheel"})
54
+
55
+ elif c == "python_version_mismatch":
56
+ actions.append({"type": "note", "msg": "Need Python 3.11 - check Dockerfile base image"})
57
+
58
+ elif c == "network_failure":
59
+ actions.append({"type": "sleep", "seconds": 5})
60
+
61
+ elif c == "rate_limited":
62
+ actions.append({"type": "sleep", "seconds": 30})
63
+
64
+ elif c == "auth_failure":
65
+ actions.append({"type": "note", "msg": "rotate API key"})
66
+
67
+ elif c == "python_exception":
68
+ actions.append({"type": "note", "msg": "no automatic repair; will retry once"})
69
+
70
+ return actions
backend/requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.0
2
+ uvicorn[standard]==0.30.6
3
+ pydantic==2.9.2
4
+ httpx==0.27.2
5
+ python-multipart==0.0.9
6
+ sse-starlette==2.1.3
7
+ e2b-code-interpreter==1.0.4
8
+ playwright==1.47.0
9
+ greenlet==3.0.3
backend/retry.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Retry wrapper - executes a callable with exponential backoff and
3
+ optional classification-based repair callback.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import time
8
+ import logging
9
+ from typing import Callable, Optional, Any
10
+
11
+ from .classifier import classify
12
+
13
+ logger = logging.getLogger("retry")
14
+
15
+
16
+ def retry_call(
17
+ fn: Callable[[], Any],
18
+ max_attempts: int = 3,
19
+ base_delay: float = 1.5,
20
+ on_error: Optional[Callable[[Exception, int], None]] = None,
21
+ repair_cb: Optional[Callable[[str], None]] = None,
22
+ ) -> Any:
23
+ last_exc: Optional[Exception] = None
24
+ for attempt in range(1, max_attempts + 1):
25
+ try:
26
+ return fn()
27
+ except Exception as e:
28
+ last_exc = e
29
+ logger.warning("retry attempt %s failed: %s", attempt, e)
30
+ if on_error:
31
+ try:
32
+ on_error(e, attempt)
33
+ except Exception:
34
+ pass
35
+ if repair_cb:
36
+ try:
37
+ err_class = classify(str(e))
38
+ if err_class:
39
+ repair_cb(err_class.category)
40
+ except Exception:
41
+ pass
42
+ if attempt < max_attempts:
43
+ time.sleep(base_delay * (2 ** (attempt - 1)))
44
+ assert last_exc is not None
45
+ raise last_exc
backend/tasks.py ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SQLite task persistence.
3
+
4
+ Tables:
5
+ tasks(id, title, description, state, created_at, updated_at, payload_json)
6
+ task_events(id, task_id, ts, kind, message)
7
+ retries(id, task_id, attempt, error, ts)
8
+ deployments(id, task_id, target, url, status, ts)
9
+ sandboxes(id, task_id, sandbox_id, status, ts)
10
+ provider_usage(id, provider, ts, ok, latency_ms, error)
11
+ checkpoints(id, task_id, step_index, state_json, ts)
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import sqlite3
18
+ import threading
19
+ import time
20
+ import uuid
21
+ from typing import Any, Dict, List, Optional
22
+
23
+ DB_PATH = os.getenv("TASKS_DB_PATH", os.path.join(os.path.dirname(__file__), "tasks.db"))
24
+ _LOCK = threading.RLock()
25
+
26
+ SCHEMA = """
27
+ CREATE TABLE IF NOT EXISTS tasks (
28
+ id TEXT PRIMARY KEY,
29
+ title TEXT,
30
+ description TEXT,
31
+ state TEXT,
32
+ created_at REAL,
33
+ updated_at REAL,
34
+ payload_json TEXT
35
+ );
36
+ CREATE TABLE IF NOT EXISTS task_events (
37
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
38
+ task_id TEXT,
39
+ ts REAL,
40
+ kind TEXT,
41
+ message TEXT
42
+ );
43
+ CREATE INDEX IF NOT EXISTS idx_task_events_task ON task_events(task_id);
44
+ CREATE TABLE IF NOT EXISTS retries (
45
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
46
+ task_id TEXT,
47
+ attempt INTEGER,
48
+ error TEXT,
49
+ ts REAL
50
+ );
51
+ CREATE TABLE IF NOT EXISTS deployments (
52
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
53
+ task_id TEXT,
54
+ target TEXT,
55
+ url TEXT,
56
+ status TEXT,
57
+ ts REAL
58
+ );
59
+ CREATE TABLE IF NOT EXISTS sandboxes (
60
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
61
+ task_id TEXT,
62
+ sandbox_id TEXT,
63
+ status TEXT,
64
+ ts REAL
65
+ );
66
+ CREATE TABLE IF NOT EXISTS provider_usage (
67
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
68
+ provider TEXT,
69
+ ts REAL,
70
+ ok INTEGER,
71
+ latency_ms REAL,
72
+ error TEXT
73
+ );
74
+ CREATE TABLE IF NOT EXISTS checkpoints (
75
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
76
+ task_id TEXT,
77
+ step_index INTEGER,
78
+ state_json TEXT,
79
+ ts REAL
80
+ );
81
+ """
82
+
83
+ # Valid task states
84
+ TASK_STATES = [
85
+ "queued", "planning", "thinking", "executing",
86
+ "repairing", "retrying", "deploying", "completed", "failed",
87
+ ]
88
+
89
+
90
+ def _conn() -> sqlite3.Connection:
91
+ c = sqlite3.connect(DB_PATH, check_same_thread=False, timeout=30.0)
92
+ c.row_factory = sqlite3.Row
93
+ c.execute("PRAGMA journal_mode=WAL")
94
+ return c
95
+
96
+
97
+ def init_db() -> None:
98
+ with _LOCK:
99
+ c = _conn()
100
+ try:
101
+ c.executescript(SCHEMA)
102
+ c.commit()
103
+ finally:
104
+ c.close()
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Task CRUD
109
+ # ---------------------------------------------------------------------------
110
+ def create_task(title: str, description: str, payload: Optional[Dict[str, Any]] = None) -> str:
111
+ task_id = uuid.uuid4().hex[:12]
112
+ now = time.time()
113
+ with _LOCK:
114
+ c = _conn()
115
+ try:
116
+ c.execute(
117
+ "INSERT INTO tasks(id, title, description, state, created_at, updated_at, payload_json) VALUES (?,?,?,?,?,?,?)",
118
+ (task_id, title, description, "queued", now, now, json.dumps(payload or {})),
119
+ )
120
+ c.commit()
121
+ finally:
122
+ c.close()
123
+ log_event(task_id, "create", f"Task created: {title}")
124
+ return task_id
125
+
126
+
127
+ def update_state(task_id: str, state: str) -> None:
128
+ with _LOCK:
129
+ c = _conn()
130
+ try:
131
+ c.execute("UPDATE tasks SET state=?, updated_at=? WHERE id=?", (state, time.time(), task_id))
132
+ c.commit()
133
+ finally:
134
+ c.close()
135
+ log_event(task_id, "state", state)
136
+
137
+
138
+ def get_task(task_id: str) -> Optional[Dict[str, Any]]:
139
+ with _LOCK:
140
+ c = _conn()
141
+ try:
142
+ row = c.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
143
+ return dict(row) if row else None
144
+ finally:
145
+ c.close()
146
+
147
+
148
+ def list_tasks(limit: int = 50) -> List[Dict[str, Any]]:
149
+ with _LOCK:
150
+ c = _conn()
151
+ try:
152
+ rows = c.execute(
153
+ "SELECT * FROM tasks ORDER BY updated_at DESC LIMIT ?", (limit,)
154
+ ).fetchall()
155
+ return [dict(r) for r in rows]
156
+ finally:
157
+ c.close()
158
+
159
+
160
+ def log_event(task_id: str, kind: str, message: str) -> None:
161
+ with _LOCK:
162
+ c = _conn()
163
+ try:
164
+ c.execute(
165
+ "INSERT INTO task_events(task_id, ts, kind, message) VALUES (?,?,?,?)",
166
+ (task_id, time.time(), kind, message[:8000]),
167
+ )
168
+ c.commit()
169
+ finally:
170
+ c.close()
171
+
172
+
173
+ def get_events(task_id: str, since_id: int = 0, limit: int = 1000) -> List[Dict[str, Any]]:
174
+ with _LOCK:
175
+ c = _conn()
176
+ try:
177
+ rows = c.execute(
178
+ "SELECT * FROM task_events WHERE task_id=? AND id>? ORDER BY id ASC LIMIT ?",
179
+ (task_id, since_id, limit),
180
+ ).fetchall()
181
+ return [dict(r) for r in rows]
182
+ finally:
183
+ c.close()
184
+
185
+
186
+ def record_retry(task_id: str, attempt: int, error: str) -> None:
187
+ with _LOCK:
188
+ c = _conn()
189
+ try:
190
+ c.execute(
191
+ "INSERT INTO retries(task_id, attempt, error, ts) VALUES (?,?,?,?)",
192
+ (task_id, attempt, error[:4000], time.time()),
193
+ )
194
+ c.commit()
195
+ finally:
196
+ c.close()
197
+
198
+
199
+ def record_deployment(task_id: str, target: str, url: str, status: str) -> None:
200
+ with _LOCK:
201
+ c = _conn()
202
+ try:
203
+ c.execute(
204
+ "INSERT INTO deployments(task_id, target, url, status, ts) VALUES (?,?,?,?,?)",
205
+ (task_id, target, url, status, time.time()),
206
+ )
207
+ c.commit()
208
+ finally:
209
+ c.close()
210
+
211
+
212
+ def save_checkpoint(task_id: str, step_index: int, state: Dict[str, Any]) -> None:
213
+ with _LOCK:
214
+ c = _conn()
215
+ try:
216
+ c.execute(
217
+ "INSERT INTO checkpoints(task_id, step_index, state_json, ts) VALUES (?,?,?,?)",
218
+ (task_id, step_index, json.dumps(state)[:64000], time.time()),
219
+ )
220
+ c.commit()
221
+ finally:
222
+ c.close()
223
+
224
+
225
+ def latest_checkpoint(task_id: str) -> Optional[Dict[str, Any]]:
226
+ with _LOCK:
227
+ c = _conn()
228
+ try:
229
+ row = c.execute(
230
+ "SELECT * FROM checkpoints WHERE task_id=? ORDER BY id DESC LIMIT 1", (task_id,)
231
+ ).fetchone()
232
+ if not row:
233
+ return None
234
+ d = dict(row)
235
+ d["state"] = json.loads(d["state_json"] or "{}")
236
+ return d
237
+ finally:
238
+ c.close()
239
+
240
+
241
+ def record_provider_usage(provider: str, ok: bool, latency_ms: float, error: str = "") -> None:
242
+ with _LOCK:
243
+ c = _conn()
244
+ try:
245
+ c.execute(
246
+ "INSERT INTO provider_usage(provider, ts, ok, latency_ms, error) VALUES (?,?,?,?,?)",
247
+ (provider, time.time(), 1 if ok else 0, latency_ms, error[:1000]),
248
+ )
249
+ c.commit()
250
+ finally:
251
+ c.close()
252
+
253
+
254
+ init_db()