Files
qyburn-coder/main.py
T
admin 0c3e88ac2f feat: add create mode for new files
Auto-detects create vs edit based on whether target_path exists.
Edit jobs use unified diff/patch (existing behaviour).
Create jobs ask the model to output the complete new file, validated
by file type (_validate_output handles .py compile, .html tag check,
generic non-empty check).

- _resolve_target: handles /opt/sites/ and /opt/stacks/ paths
- _CREATOR_SYSTEM + _build_create_prompt + _ask_creator: full-file output
- _validate_output: replaces direct _syntax_check calls in the loop
- run_coding_loop: branches on mode, stores mode on job dict
- approve: mkdir parents for new paths, skip backup if no original
- submit_task: initialise mode field as None

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 12:39:24 +00:00

712 lines
27 KiB
Python

import difflib
import json
import logging
import os
import subprocess
import tempfile
import uuid
from datetime import datetime, timezone
from pathlib import Path
import httpx
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from pydantic import BaseModel
logging.basicConfig(level=logging.INFO, format="%(asctime)s [qyburn] %(message)s")
logger = logging.getLogger("qyburn")
HODOR_URL = os.getenv("HODOR_URL", "http://hodor-gateway:8200")
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://172.27.40.20:11434")
RAVEN_URL = os.getenv("RAVEN_URL", "http://raven-notify:8400/notify")
CODER_MODEL = os.getenv("CODER_MODEL", "qwen2.5-coder:7b")
STACKS_DIR = Path(os.getenv("STACKS_DIR", "/opt/stacks"))
SITES_DIR = Path(os.getenv("SITES_DIR", "/opt/sites"))
AGENT_OS_DIR = Path(os.getenv("AGENT_OS_DIR", "/opt/agent-os"))
SANDBOX_DIR = STACKS_DIR / "qyburn-coder" / "sandbox"
JOBS_FILE = STACKS_DIR / "qyburn-coder" / "jobs.json"
MAX_ATTEMPTS = 3
def _resolve_target(target_path: str) -> Path:
if target_path.startswith("/opt/"):
return Path(target_path)
return STACKS_DIR / target_path
def _save_jobs():
try:
JOBS_FILE.write_text(json.dumps(jobs, indent=2))
except Exception as e:
logger.warning(f"failed to persist jobs: {e}")
def _load_jobs():
try:
data = json.loads(JOBS_FILE.read_text())
jobs.update(data)
logger.info(f"loaded {len(data)} job(s) from {JOBS_FILE}")
except FileNotFoundError:
pass
except Exception as e:
logger.warning(f"failed to load jobs from disk: {e}")
jobs: dict[str, dict] = {}
_load_jobs()
app = FastAPI(title="qyburn-coder", version="0.1.0")
# ---------------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------------
class TaskRequest(BaseModel):
description: str
target_path: str
constraints: str | None = None
# ---------------------------------------------------------------------------
# Coding loop helpers
# ---------------------------------------------------------------------------
def _extract_diff(response: str) -> str:
lines = response.strip().splitlines()
if lines and lines[0].strip().startswith("```"):
lines = lines[1:]
if lines and lines[-1].strip() == "```":
lines = lines[:-1]
return "\n".join(lines)
def _apply_diff(original: str, diff_text: str) -> tuple[str | None, str | None]:
"""Apply a unified diff to original. Returns (patched_content, error_or_None)."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
f.write(original)
tmp_path = f.name
try:
result = subprocess.run(
["patch", "--no-backup-if-mismatch", tmp_path],
input=diff_text,
capture_output=True, text=True, timeout=30,
)
if result.returncode != 0:
return None, f"patch failed:\n{(result.stderr or result.stdout).strip()[:400]}"
return Path(tmp_path).read_text(), None
finally:
Path(tmp_path).unlink(missing_ok=True)
Path(tmp_path + ".orig").unlink(missing_ok=True)
def _syntax_check(code: str, filename: str) -> str | None:
with tempfile.NamedTemporaryFile(suffix=".py", mode="w", delete=False) as f:
f.write(code)
tmp = f.name
try:
result = subprocess.run(
["python", "-m", "py_compile", tmp],
capture_output=True, text=True, timeout=10,
)
if result.returncode != 0:
return result.stderr.replace(tmp, filename)
return None
finally:
Path(tmp).unlink(missing_ok=True)
def _validate_output(content: str, filename: str) -> str | None:
"""Returns error string or None if valid. Handles .py, .html, and generic files."""
if not content.strip():
return "model returned empty output"
ext = Path(filename).suffix.lower()
if ext == ".py":
return _syntax_check(content, filename)
if ext in (".html", ".htm"):
low = content.lower()
if "<html" not in low and "<!doctype" not in low:
return "output does not appear to be valid HTML (missing <html> or <!DOCTYPE>)"
return None
return None
def _make_diff(original: str, modified: str, filename: str) -> str:
a = original.splitlines(keepends=True)
b = modified.splitlines(keepends=True)
return "".join(difflib.unified_diff(a, b, fromfile=f"a/{filename}", tofile=f"b/{filename}")) or "(no changes)"
def _build_create_prompt(job: dict, attempt: int, last_error: str | None) -> str:
lines = [f"Task: {job['description']}"]
if job.get("constraints"):
lines.append(f"Constraints: {job['constraints']}")
if attempt > 1 and last_error:
lines.append(f"\nPrevious attempt failed:\n{last_error}")
lines.append("Fix the issue in the output.")
lines += [
"",
f"Create the file: {job['target_path']}",
"",
"Output the complete file content:",
]
return "\n".join(lines)
def _build_prompt(job: dict, original: str, attempt: int, last_error: str | None) -> str:
lines = [f"Task: {job['description']}"]
if job.get("constraints"):
lines.append(f"Constraints: {job['constraints']}")
if attempt > 1 and last_error:
lines.append(f"\nPrevious attempt failed:\n{last_error}")
lines.append("Fix the diff so it applies cleanly.")
lines += [
"",
f"File to modify ({job['target_path']}):",
"---",
original,
"---",
"",
"Output the unified diff:",
]
return "\n".join(lines)
async def _notify_raven(title: str, message: str, level: str = "warning"):
try:
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.post(RAVEN_URL, json={"title": title, "message": message, "level": level})
logger.info(f"Raven notified: {r.status_code}")
except Exception as e:
logger.warning(f"Raven notification failed: {e}")
async def _warm_coder_model():
try:
async with httpx.AsyncClient(timeout=120.0) as client:
logger.info(f"warming coder model {CODER_MODEL}...")
await client.post(f"{OLLAMA_URL}/api/chat", json={
"model": CODER_MODEL,
"messages": [{"role": "user", "content": "ready"}],
"stream": False,
})
logger.info("coder model warm")
except Exception as e:
logger.warning(f"model warm-up failed (continuing anyway): {e}")
_CODER_SYSTEM = (
"You are a Python code editor. "
"When given a task and a file, output ONLY a unified diff showing the changes needed. "
"Use standard unified diff format with --- and +++ headers and @@ hunk markers. "
"No markdown fences, no explanations, no comments. "
"Output only the diff lines: ---, +++, @@, context lines (space prefix), "
"+ added lines, - removed lines."
)
_CREATOR_SYSTEM = (
"You are a code and file generator. "
"When given a task, output ONLY the complete new file as raw text. "
"No markdown fences, no explanations, no preamble. "
"Output only the file content itself, starting with the very first character of the file."
)
async def _ask_creator(task_prompt: str) -> str:
async with httpx.AsyncClient(timeout=300.0) as client:
r = await client.post(f"{OLLAMA_URL}/api/chat", json={
"model": CODER_MODEL,
"messages": [
{"role": "system", "content": _CREATOR_SYSTEM},
{"role": "user", "content": task_prompt},
],
"stream": False,
"options": {"temperature": 0.1, "num_predict": 8192},
})
r.raise_for_status()
return r.json()["message"]["content"]
async def _ask_coder(task_prompt: str) -> str:
async with httpx.AsyncClient(timeout=300.0) as client:
r = await client.post(f"{OLLAMA_URL}/api/chat", json={
"model": CODER_MODEL,
"messages": [
{"role": "system", "content": _CODER_SYSTEM},
{"role": "user", "content": task_prompt},
],
"stream": False,
"options": {"temperature": 0.1, "num_predict": 4096},
})
r.raise_for_status()
return r.json()["message"]["content"]
def _write_handoff(job: dict, original: str, last_diff: str):
out_dir = SITES_DIR / "qyburn"
out_dir.mkdir(parents=True, exist_ok=True)
content = (
f"# Qyburn Escalation Handoff\n\n"
f"**Job:** {job['job_id']}\n"
f"**Task:** {job['description']}\n"
f"**Target:** {job['target_path']}\n"
f"**Attempts:** {job['attempts']}\n"
f"**Last error:** {job.get('error', 'unknown')}\n\n"
f"---\n\n"
f"## Last Diff Attempt\n\n```diff\n{last_diff}\n```\n\n"
f"## Instructions\n\n"
f"The diff above failed to apply cleanly or produced a syntax error.\n"
f"Edit the target file directly at `{job['target_path']}`, then POST the corrected complete file to `/handoff/{job['job_id']}`.\n"
)
(out_dir / "handoff.md").write_text(content)
logger.info(f"[{job['job_id'][:8]}] handoff written → /opt/sites/qyburn/handoff.md")
def _write_last_run():
log_dir = AGENT_OS_DIR / "logs" / "qyburn-coder"
log_dir.mkdir(parents=True, exist_ok=True)
counts: dict[str, int] = {}
for j in jobs.values():
counts[j["status"]] = counts.get(j["status"], 0) + 1
(log_dir / "last-run.json").write_text(json.dumps({
"agent": "qyburn-coder",
"timestamp": datetime.now(timezone.utc).isoformat(),
"status": "running",
"result": f"{len(jobs)} jobs — " + ", ".join(f"{v} {k}" for k, v in counts.items()),
}, indent=2))
_save_jobs()
# ---------------------------------------------------------------------------
# Coding loop (background task)
# ---------------------------------------------------------------------------
async def run_coding_loop(job_id: str):
job = jobs[job_id]
job["status"] = "running"
job["updated_at"] = datetime.now(timezone.utc).isoformat()
target = _resolve_target(job["target_path"])
mode = "edit" if target.exists() else "create"
job["mode"] = mode
job["updated_at"] = datetime.now(timezone.utc).isoformat()
if mode == "edit":
original = target.read_text()
else:
original = ""
logger.info(f"[{job_id[:8]}] target does not exist — create mode")
await _warm_coder_model()
sandbox_dir = SANDBOX_DIR / job_id
sandbox_dir.mkdir(parents=True, exist_ok=True)
sandbox_file = sandbox_dir / Path(job["target_path"]).name
last_diff = ""
last_error = None
for attempt in range(1, MAX_ATTEMPTS + 1):
if job.get("cancelled"):
job["status"] = "cancelled"
job["updated_at"] = datetime.now(timezone.utc).isoformat()
logger.info(f"[{job_id[:8]}] cancelled between attempts")
_write_last_run()
return
job["attempts"] = attempt
job["updated_at"] = datetime.now(timezone.utc).isoformat()
logger.info(f"[{job_id[:8]}] attempt {attempt}/{MAX_ATTEMPTS}")
try:
if mode == "edit":
response = await _ask_coder(_build_prompt(job, original, attempt, last_error))
else:
response = await _ask_creator(_build_create_prompt(job, attempt, last_error))
except Exception as e:
last_error = f"Ollama error: {e}"
logger.warning(f"[{job_id[:8]}] attempt {attempt} — LLM error: {e}")
continue
job["tokens_estimated"] = job.get("tokens_estimated", 0) + len(response) // 4
raw = _extract_diff(response)
last_diff = raw
if mode == "edit":
content, patch_error = _apply_diff(original, raw)
if patch_error:
last_error = patch_error
logger.warning(f"[{job_id[:8]}] attempt {attempt} patch error: {patch_error[:120]}")
continue
else:
content = raw
error = _validate_output(content, Path(job["target_path"]).name)
if error is None:
sandbox_file.write_text(content)
job["status"] = "pending_review"
job["diff"] = raw if mode == "edit" else f"[NEW FILE — {Path(job['target_path']).name}]\n\n{content}"
job["sandbox_file"] = str(sandbox_file)
job["original"] = original
job["error"] = None
job["updated_at"] = datetime.now(timezone.utc).isoformat()
logger.info(f"[{job_id[:8]}] {mode} OK on attempt {attempt} — pending review")
_write_last_run()
return
last_error = error
logger.warning(f"[{job_id[:8]}] attempt {attempt} error: {error[:120]}")
_write_handoff(job, original, last_diff)
job["status"] = "escalated"
job["error"] = last_error
job["updated_at"] = datetime.now(timezone.utc).isoformat()
logger.info(f"[{job_id[:8]}] escalated after {MAX_ATTEMPTS} attempts")
await _notify_raven(
title="Qyburn handoff ready",
message=(
f"Job {job_id[:8]} escalated after {MAX_ATTEMPTS} attempts.\n"
f"Task: {job['description']}\n"
f"Target: {job['target_path']}\n"
f"Last error: {str(last_error)[:200]}\n"
f"Read handoff: /opt/sites/qyburn/handoff.md"
),
level="warning",
)
_write_last_run()
# ---------------------------------------------------------------------------
# Status page
# ---------------------------------------------------------------------------
_STATUS_COLORS = {
"queued": "#8b949e",
"running": "#58a6ff",
"pending_review": "#f0883e",
"applied": "#56d364",
"escalated": "#ff7b72",
"failed": "#ff7b72",
"cancelled": "#8b949e",
"rejected": "#8b949e",
}
_BTN = '<form method="post" action="/{action}/{job_id}" style="display:inline"><button class="btn btn-{cls}" type="submit">{label}</button></form>'
def _buttons(*specs) -> str:
return '<div class="btn-row">' + "".join(
f'<form method="post" action="/{action}/{jid}" style="display:inline">'
f'<button class="btn btn-{cls}" type="submit">{label}</button></form>'
for action, jid, cls, label in specs
) + "</div>"
def _render_page() -> str:
timestamp = datetime.now(timezone.utc).isoformat()
active = any(j["status"] in ("queued", "running") for j in jobs.values())
refresh = '<meta http-equiv="refresh" content="4">' if active else ""
cards = ""
for job_id, job in sorted(jobs.items(), key=lambda x: x[1]["created_at"], reverse=True):
color = _STATUS_COLORS.get(job["status"], "#8b949e")
badge = f'<span style="color:{color};font-weight:bold">{job["status"]}</span>'
extra = ""
status = job["status"]
if status == "pending_review" and job.get("diff"):
stack_dir = str(Path(job["target_path"]).parent)
extra = (
f'<pre class="diff">{job["diff"]}</pre>'
+ _buttons(
("approve", job_id, "approve", "Approve &amp; Apply"),
("reject", job_id, "danger", "Reject"),
("retry", job_id, "secondary", "Retry"),
)
+ f'<p class="dim hint">After approving, use the <code>docker_rebuild</code> tool '
f'or run: <code>cd /opt/stacks/{stack_dir} && docker compose up -d --build</code></p>'
)
elif status in ("queued", "running"):
extra = _buttons(("cancel", job_id, "danger", "Cancel"))
elif status in ("escalated", "rejected", "failed"):
extra = _buttons(("retry", job_id, "secondary", "Retry"))
if status == "escalated":
extra += ('<p class="dim" style="margin-top:0.5rem">Handoff written to '
'<code>/opt/sites/qyburn/handoff.md</code> — paste to Claude Code.</p>')
if job.get("error"):
extra += f'<pre class="error">{job["error"]}</pre>'
elif status == "applied":
stack_dir = str(Path(job["target_path"]).parent)
extra = (
f'<p class="dim success">Applied. Use the <code>docker_rebuild</code> tool '
f'or run: <code>cd /opt/stacks/{stack_dir} && docker compose up -d --build</code></p>'
)
elif job.get("error"):
extra = f'<pre class="error">{job["error"]}</pre>'
cards += f"""
<div class="card">
<div class="row">
<span class="job-id">{job_id[:8]}</span>
{badge}
<span class="dim">attempt {job.get('attempts', 0)}/{MAX_ATTEMPTS}</span>
</div>
<p class="desc">{job['description']}</p>
<p class="dim">Target: <code>{job['target_path']}</code> &nbsp;·&nbsp; <span data-utc="{job['created_at']}"></span></p>
{extra}
</div>"""
body = cards if cards else '<p class="dim">No jobs yet. POST to /task to submit one.</p>'
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Qyburn — Coder Agent</title>
{refresh}
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: monospace; background: #0d1117; color: #c9d1d9; padding: 2rem; }}
h1 {{ color: #58a6ff; margin-bottom: 0.25rem; }}
.sub {{ color: #8b949e; font-size: 0.9rem; margin: 0.4rem 0 0.25rem; }}
.meta {{ color: #8b949e; font-size: 0.85rem; margin-bottom: 2rem; }}
.card {{ background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 1.25rem; margin-bottom: 1rem; }}
.row {{ display: flex; gap: 1rem; align-items: center; margin-bottom: 0.5rem; }}
.job-id {{ color: #79c0ff; font-weight: bold; }}
.desc {{ margin: 0.4rem 0; }}
.dim {{ color: #8b949e; font-size: 0.85rem; margin: 0.3rem 0; }}
.hint {{ margin-top: 0.5rem; }}
.success {{ color: #56d364 !important; }}
pre.diff {{ background: #0d1117; border: 1px solid #30363d; border-radius: 4px; padding: 1rem; font-size: 0.78rem; overflow: auto; max-height: 380px; white-space: pre; margin: 0.75rem 0; }}
pre.error {{ background: #2d0a0a; border: 1px solid #ff7b72; border-radius: 4px; padding: 0.75rem; font-size: 0.8rem; margin: 0.75rem 0; color: #ff7b72; white-space: pre-wrap; }}
.btn-row {{ display: flex; gap: 0.5rem; margin-top: 0.75rem; flex-wrap: wrap; }}
.btn {{ border: none; border-radius: 6px; padding: 0.4rem 1rem; font-family: monospace; font-size: 0.88rem; cursor: pointer; }}
.btn-approve {{ background: #238636; color: #fff; }}
.btn-approve:hover {{ background: #2ea043; }}
.btn-danger {{ background: #6e2020; color: #ff7b72; border: 1px solid #ff7b72; }}
.btn-danger:hover {{ background: #8a2a2a; }}
.btn-secondary {{ background: #21262d; color: #c9d1d9; border: 1px solid #30363d; }}
.btn-secondary:hover {{ background: #30363d; }}
code {{ background: #21262d; padding: 0.1rem 0.35rem; border-radius: 3px; font-size: 0.82rem; }}
</style>
</head>
<body>
<h1>Qyburn — Coder Agent</h1>
<p class="sub">Local LLM coding loop · {CODER_MODEL} · sandbox review before apply</p>
<p class="meta">Updated <span data-utc="{timestamp}"></span> &nbsp;·&nbsp; {len(jobs)} job(s)</p>
{body}
<script>
document.querySelectorAll('[data-utc]').forEach(el => {{
const d = new Date(el.dataset.utc);
el.textContent = d.toLocaleString(undefined, {{year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}});
}});
</script>
</body>
</html>"""
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.get("/health")
def health():
return {"status": "ok", "agent": "qyburn-coder", "model": CODER_MODEL, "hodor_url": HODOR_URL, "jobs": len(jobs)}
@app.get("/", response_class=HTMLResponse)
def index():
return _render_page()
@app.post("/task")
async def submit_task(req: TaskRequest, background_tasks: BackgroundTasks):
job_id = str(uuid.uuid4())
now = datetime.now(timezone.utc).isoformat()
jobs[job_id] = {
"job_id": job_id, "status": "queued",
"description": req.description, "target_path": req.target_path,
"constraints": req.constraints, "attempts": 0,
"created_at": now, "updated_at": now,
"cancelled": False, "error": None, "diff": None,
"mode": None, "sandbox_file": None, "original": None,
"tokens_estimated": 0,
}
background_tasks.add_task(run_coding_loop, job_id)
logger.info(f"[{job_id[:8]}] queued: {req.description[:80]}")
_write_last_run()
return {"job_id": job_id, "status": "queued"}
@app.get("/status/{job_id}")
def get_status(job_id: str):
job = jobs.get(job_id)
if not job:
raise HTTPException(status_code=404, detail="job not found")
return {k: v for k, v in job.items() if k not in ("original", "sandbox_file", "cancelled")}
@app.get("/jobs")
def list_jobs():
return [
{
"job_id": j["job_id"],
"description": j["description"],
"status": j["status"],
"attempts": j.get("attempts", 0),
"tokens_estimated": j.get("tokens_estimated", 0),
"created_at": j["created_at"],
"updated_at": j["updated_at"],
}
for j in sorted(jobs.values(), key=lambda x: x["created_at"], reverse=True)
]
@app.get("/metrics")
def get_metrics():
total = len(jobs)
total_tokens = sum(j.get("tokens_estimated", 0) for j in jobs.values())
by_status: dict[str, int] = {}
for j in jobs.values():
by_status[j["status"]] = by_status.get(j["status"], 0) + 1
return {
"total_jobs": total,
"total_tokens_estimated": total_tokens,
"avg_tokens_per_job": total_tokens // total if total else 0,
"jobs_by_status": by_status,
}
@app.post("/approve/{job_id}")
async def approve_job(job_id: str, request: Request):
job = jobs.get(job_id)
if not job:
raise HTTPException(status_code=404, detail="job not found")
if job["status"] != "pending_review":
raise HTTPException(status_code=400, detail=f"job is '{job['status']}', expected 'pending_review'")
sandbox_file = Path(job["sandbox_file"])
if not sandbox_file.exists():
raise HTTPException(status_code=500, detail="sandbox file missing")
target = _resolve_target(job["target_path"])
target.parent.mkdir(parents=True, exist_ok=True)
if job.get("original"):
backup = target.with_suffix(target.suffix + ".qyburn-bak")
backup.write_text(job["original"])
target.write_text(sandbox_file.read_text())
job["status"] = "applied"
job["updated_at"] = datetime.now(timezone.utc).isoformat()
logger.info(f"[{job_id[:8]}] applied → {target} (backup: {backup.name})")
_write_last_run()
if "text/html" in request.headers.get("accept", ""):
return RedirectResponse(url="/", status_code=303)
return {"status": "applied", "target": str(target), "backup": str(backup)}
@app.post("/reject/{job_id}")
async def reject_job(job_id: str, request: Request):
job = jobs.get(job_id)
if not job:
raise HTTPException(status_code=404, detail="job not found")
if job["status"] != "pending_review":
raise HTTPException(status_code=400, detail=f"job is '{job['status']}', expected 'pending_review'")
job["status"] = "rejected"
job["diff"] = None
job["updated_at"] = datetime.now(timezone.utc).isoformat()
logger.info(f"[{job_id[:8]}] rejected")
_write_last_run()
if "text/html" in request.headers.get("accept", ""):
return RedirectResponse(url="/", status_code=303)
return {"status": "rejected", "job_id": job_id}
@app.post("/cancel/{job_id}")
async def cancel_job(job_id: str, request: Request):
job = jobs.get(job_id)
if not job:
raise HTTPException(status_code=404, detail="job not found")
if job["status"] not in ("queued", "running"):
raise HTTPException(status_code=400, detail=f"job is '{job['status']}', can only cancel queued/running")
job["cancelled"] = True
job["updated_at"] = datetime.now(timezone.utc).isoformat()
logger.info(f"[{job_id[:8]}] cancel requested")
_write_last_run()
if "text/html" in request.headers.get("accept", ""):
return RedirectResponse(url="/", status_code=303)
return {"status": "cancelling", "job_id": job_id}
@app.post("/retry/{job_id}")
async def retry_job(job_id: str, request: Request, background_tasks: BackgroundTasks):
job = jobs.get(job_id)
if not job:
raise HTTPException(status_code=404, detail="job not found")
if job["status"] not in ("pending_review", "rejected", "escalated", "failed", "cancelled"):
raise HTTPException(status_code=400, detail=f"job is '{job['status']}', cannot retry")
job["status"] = "queued"
job["attempts"] = 0
job["cancelled"] = False
job["error"] = None
job["diff"] = None
job["sandbox_file"] = None
job["original"] = None
job["updated_at"] = datetime.now(timezone.utc).isoformat()
background_tasks.add_task(run_coding_loop, job_id)
logger.info(f"[{job_id[:8]}] retrying")
_write_last_run()
if "text/html" in request.headers.get("accept", ""):
return RedirectResponse(url="/", status_code=303)
return {"status": "queued", "job_id": job_id}
@app.post("/rebuild/{stack_name}")
async def rebuild_stack(stack_name: str):
"""Run docker compose up -d --build for a named stack in /opt/stacks/."""
if not stack_name or "/" in stack_name or ".." in stack_name:
raise HTTPException(status_code=400, detail="invalid stack name")
stack_path = STACKS_DIR / stack_name
if not stack_path.is_dir():
raise HTTPException(status_code=404, detail=f"stack '{stack_name}' not found in /opt/stacks/")
compose_file = stack_path / "docker-compose.yml"
if not compose_file.exists():
raise HTTPException(status_code=404, detail=f"no docker-compose.yml in {stack_name}")
logger.info(f"rebuilding {stack_name}")
result = subprocess.run(
["docker", "compose", "up", "-d", "--build"],
cwd=str(stack_path),
capture_output=True, text=True, timeout=300,
)
success = result.returncode == 0
logger.info(f"rebuild {stack_name}: {'ok' if success else 'failed'}")
return {
"stack": stack_name,
"success": success,
"stdout": result.stdout,
"stderr": result.stderr,
}
@app.post("/handoff/{job_id}")
async def receive_handoff(job_id: str, request: Request):
job = jobs.get(job_id)
if not job:
raise HTTPException(status_code=404, detail="job not found")
body = await request.body()
code = body.decode()
error = _syntax_check(code, Path(job["target_path"]).name)
if error:
raise HTTPException(status_code=400, detail=f"syntax error in submitted code: {error}")
original = job.get("original") or (STACKS_DIR / job["target_path"]).read_text()
sandbox_dir = SANDBOX_DIR / job_id
sandbox_dir.mkdir(parents=True, exist_ok=True)
sandbox_file = sandbox_dir / Path(job["target_path"]).name
sandbox_file.write_text(code)
job["status"] = "pending_review"
job["diff"] = _make_diff(original, code, Path(job["target_path"]).name)
job["sandbox_file"] = str(sandbox_file)
job["original"] = original
job["error"] = None
job["updated_at"] = datetime.now(timezone.utc).isoformat()
logger.info(f"[{job_id[:8]}] handoff received — pending review")
_write_last_run()
return {"status": "pending_review", "job_id": job_id}