From 6b75c0b5967c33f794d297d9729650c50267b6aa Mon Sep 17 00:00:00 2001 From: Carl Niklas Rydberg Date: Sat, 14 Feb 2026 21:48:45 +0100 Subject: [PATCH] feat(self-improve): add apply-to-branch endpoint and UI action buttons --- app.py | 141 +++++++++++++++++++++++++++++++++++++++++++++++ ui/assets/app.js | 29 +++++++++- ui/index.html | 1 + 3 files changed, 170 insertions(+), 1 deletion(-) diff --git a/app.py b/app.py index f752fc2..cbd2f01 100644 --- a/app.py +++ b/app.py @@ -323,6 +323,24 @@ class AssistantSelfImproveResponse(BaseModel): apply_block_reason: Optional[str] = None +class AssistantSelfImproveApplyPayload(BaseModel): + objective: Optional[str] = None + release_name: Optional[str] = None + proposal: AssistantSelfImproveProposal + dry_run: bool = False + + +class AssistantSelfImproveApplyResponse(BaseModel): + applied: bool + dry_run: bool + repo_dir: str + branch_name: str + proposal_file: str + commit: Optional[str] = None + commands: List[str] = Field(default_factory=list) + detail: Optional[str] = None + + # --------- helpers --------- def now_iso() -> str: return datetime.now(timezone.utc).isoformat() @@ -1115,6 +1133,24 @@ def _append_chat_turn(session_id: str, role: str, content: str) -> None: ASSISTANT_CHAT_SESSIONS[session_id] = turns +def _slugify(text: str) -> str: + t = re.sub(r"[^a-zA-Z0-9]+", "-", (text or "").strip().lower()).strip("-") + return t[:60] or "proposal" + + +async def _run_local_cmd(args: List[str], cwd: Path) -> Dict[str, Any]: + proc = await asyncio.create_subprocess_exec( + *args, + cwd=str(cwd), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + out = stdout.decode("utf-8", errors="replace") + err = stderr.decode("utf-8", errors="replace") + return {"code": proc.returncode, "stdout": out, "stderr": err} + + def build_self_improve_prompt( payload: AssistantSelfImprovePayload, feedback_rows: List[Dict[str, Any]], @@ -2550,6 +2586,111 @@ async def assistant_self_improve( ) +@app.post("/assistant/self-improve/apply", response_model=AssistantSelfImproveApplyResponse) +async def assistant_self_improve_apply( + payload: AssistantSelfImproveApplyPayload, + x_admin_api_key: Optional[str] = Header(default=None), +): + check_admin_api_key(x_admin_api_key) + repo_dir = Path(__file__).resolve().parent + proposal = payload.proposal + + branch_slug = _slugify(f"{proposal.proposal_id}-{proposal.title}") + branch_name = f"assistant/{branch_slug}" + proposals_dir = repo_dir / "proposals" + proposals_dir.mkdir(parents=True, exist_ok=True) + proposal_file = proposals_dir / f"{branch_slug}.md" + commands: List[str] = [ + f"git -C {repo_dir} rev-parse --is-inside-work-tree", + f"git -C {repo_dir} checkout -B {branch_name}", + f"write {proposal_file}", + f"git -C {repo_dir} add {proposal_file}", + f"git -C {repo_dir} commit -m 'chore(self-improve): apply {proposal.proposal_id} {proposal.title}'", + ] + + md = "\n".join( + [ + f"# {proposal.proposal_id}: {proposal.title}", + "", + f"- Objective: {payload.objective or ''}", + f"- Release: {payload.release_name or ''}", + f"- Risk: {proposal.risk}", + f"- Auto apply safe: {proposal.auto_apply_safe}", + "", + "## Problem", + proposal.problem, + "", + "## Change", + proposal.change, + "", + "## Files", + *(f"- `{f}`" for f in (proposal.files or [])), + "", + "## Tests", + *(f"- {t}" for t in (proposal.tests or [])), + "", + f"_Generated at {now_iso()}_", + "", + ] + ) + + if payload.dry_run: + return AssistantSelfImproveApplyResponse( + applied=False, + dry_run=True, + repo_dir=str(repo_dir), + branch_name=branch_name, + proposal_file=str(proposal_file.relative_to(repo_dir)), + commit=None, + commands=commands, + detail="Dry-run only. No branch or commit created.", + ) + + chk = await _run_local_cmd(["git", "rev-parse", "--is-inside-work-tree"], repo_dir) + if chk["code"] != 0: + raise HTTPException(status_code=500, detail={"message": "Not a git repository", "stderr": chk["stderr"]}) + + co = await _run_local_cmd(["git", "checkout", "-B", branch_name], repo_dir) + if co["code"] != 0: + raise HTTPException(status_code=500, detail={"message": "Failed to create/switch branch", "stderr": co["stderr"]}) + + proposal_file.write_text(md, encoding="utf-8") + add = await _run_local_cmd(["git", "add", str(proposal_file.relative_to(repo_dir))], repo_dir) + if add["code"] != 0: + raise HTTPException(status_code=500, detail={"message": "Failed to add proposal file", "stderr": add["stderr"]}) + + commit_msg = f"chore(self-improve): apply {proposal.proposal_id} {proposal.title}" + cm = await _run_local_cmd(["git", "commit", "-m", commit_msg], repo_dir) + if cm["code"] != 0: + # Common case: nothing to commit. still return branch + file info. + detail = (cm["stderr"] or cm["stdout"] or "").strip() + head = await _run_local_cmd(["git", "rev-parse", "HEAD"], repo_dir) + commit = head["stdout"].strip() if head["code"] == 0 else None + return AssistantSelfImproveApplyResponse( + applied=True, + dry_run=False, + repo_dir=str(repo_dir), + branch_name=branch_name, + proposal_file=str(proposal_file.relative_to(repo_dir)), + commit=commit, + commands=commands, + detail=detail or "No new commit created (possibly no diff).", + ) + + head = await _run_local_cmd(["git", "rev-parse", "HEAD"], repo_dir) + commit = head["stdout"].strip() if head["code"] == 0 else None + return AssistantSelfImproveApplyResponse( + applied=True, + dry_run=False, + repo_dir=str(repo_dir), + branch_name=branch_name, + proposal_file=str(proposal_file.relative_to(repo_dir)), + commit=commit, + commands=commands, + detail="Branch and proposal commit created.", + ) + + @app.post("/assistant/draft", response_model=AssistantDraftResponse) async def assistant_draft(payload: AssistantDraftPayload, x_admin_api_key: Optional[str] = Header(default=None)): check_admin_api_key(x_admin_api_key) diff --git a/ui/assets/app.js b/ui/assets/app.js index 8c945ad..61e8792 100644 --- a/ui/assets/app.js +++ b/ui/assets/app.js @@ -237,15 +237,42 @@ async function runSelfImprove() { include_blocked_actions: true, apply: false, }); + window.selfImproveLast = data.proposals || []; summary.textContent = `${data.summary || ""}\n\nsignals: feedback=${data.signals?.feedback_rows ?? 0}, blocked_actions=${data.signals?.blocked_action_rows ?? 0}`; - renderRows(list, data.proposals || [], (p) => ` + renderRows(list, data.proposals || [], (p, idx) => `
${p.proposal_id}: ${p.title}
${p.problem}
risk=${p.risk} | auto_apply_safe=${p.auto_apply_safe}
files=${(p.files || []).join(", ") || "-"}
tests=${(p.tests || []).join(" | ") || "-"}
${p.change || ""}
+
`); + document.querySelectorAll(".apply-proposal").forEach((btn) => { + btn.addEventListener("click", async () => { + const proposalId = btn.getAttribute("data-proposal-id"); + const found = (window.selfImproveLast || []).find((x) => x.proposal_id === proposalId); + if (!found) return; + const dryRun = document.getElementById("improveDryRun").checked; + summary.textContent = `Applying ${proposalId}...`; + try { + const applied = await apiPost("/assistant/self-improve/apply", { + objective, + release_name: cfg.releaseName || null, + proposal: found, + dry_run: dryRun, + }); + summary.textContent = + `apply result: applied=${applied.applied} dry_run=${applied.dry_run}\n` + + `branch=${applied.branch_name}\n` + + `proposal_file=${applied.proposal_file}\n` + + `commit=${applied.commit || "-"}\n` + + `${applied.detail || ""}`; + } catch (e) { + summary.textContent = `Apply error: ${String(e)}`; + } + }); + }); } catch (e) { summary.textContent = `Error: ${String(e)}`; } diff --git a/ui/index.html b/ui/index.html index 0e5c607..330030e 100644 --- a/ui/index.html +++ b/ui/index.html @@ -84,6 +84,7 @@
+