7fa1fb9e99
Full-file replacement confirmed failing at all model sizes (tested up to codestral:22b on 619-line file). Model hallucinates a new short file instead of reproducing the original with changes applied. New approach: model outputs only the unified diff (10-20 lines), applied programmatically via `patch`. Works at any file size, uses ~154 tokens vs ~700+ previously, and succeeds on first attempt. - Add `patch` binary to Dockerfile (apt install) - Replace `_extract_code` with `_extract_diff` - Add `_apply_diff` using subprocess patch - Rewrite `_CODER_SYSTEM` to ask for diff format - Rewrite `_build_prompt` to return plain string (no prefill dict) - Rewrite `_ask_coder` to drop prefill assistant message - Reduce num_predict 16384 → 4096 (diff output is short) - Update `run_coding_loop` inner loop to apply diff then syntax-check result - Update `_write_handoff` to show last diff instead of last full-file attempt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
644 lines
25 KiB
Python
644 lines
25 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 _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 _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_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."
|
|
)
|
|
|
|
|
|
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 = STACKS_DIR / job["target_path"]
|
|
if not target.exists():
|
|
job["status"] = "failed"
|
|
job["error"] = f"target file not found: {job['target_path']}"
|
|
job["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
logger.error(f"[{job_id[:8]}] {job['error']}")
|
|
return
|
|
|
|
await _warm_coder_model()
|
|
|
|
original = target.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
|
|
|
|
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:
|
|
response = await _ask_coder(_build_prompt(job, original, 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
|
|
diff_text = _extract_diff(response)
|
|
last_diff = diff_text
|
|
|
|
patched, patch_error = _apply_diff(original, diff_text)
|
|
if patch_error:
|
|
last_error = patch_error
|
|
logger.warning(f"[{job_id[:8]}] attempt {attempt} patch error: {patch_error[:120]}")
|
|
continue
|
|
|
|
syntax_error = _syntax_check(patched, Path(job["target_path"]).name)
|
|
if syntax_error is None:
|
|
sandbox_file.write_text(patched)
|
|
job["status"] = "pending_review"
|
|
job["diff"] = diff_text
|
|
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]}] patch OK on attempt {attempt} — pending review")
|
|
_write_last_run()
|
|
return
|
|
|
|
last_error = syntax_error
|
|
logger.warning(f"[{job_id[:8]}] attempt {attempt} syntax error: {syntax_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 & 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> · <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> · {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,
|
|
"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 = STACKS_DIR / job["target_path"]
|
|
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}
|