feat(self-improve): add apply-to-branch endpoint and UI action buttons

This commit is contained in:
Carl Niklas Rydberg 2026-02-14 21:48:45 +01:00
parent c5b2776978
commit 6b75c0b596
3 changed files with 170 additions and 1 deletions

141
app.py
View file

@ -323,6 +323,24 @@ class AssistantSelfImproveResponse(BaseModel):
apply_block_reason: Optional[str] = None 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 --------- # --------- helpers ---------
def now_iso() -> str: def now_iso() -> str:
return datetime.now(timezone.utc).isoformat() 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 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( def build_self_improve_prompt(
payload: AssistantSelfImprovePayload, payload: AssistantSelfImprovePayload,
feedback_rows: List[Dict[str, Any]], 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) @app.post("/assistant/draft", response_model=AssistantDraftResponse)
async def assistant_draft(payload: AssistantDraftPayload, x_admin_api_key: Optional[str] = Header(default=None)): async def assistant_draft(payload: AssistantDraftPayload, x_admin_api_key: Optional[str] = Header(default=None)):
check_admin_api_key(x_admin_api_key) check_admin_api_key(x_admin_api_key)

View file

@ -237,15 +237,42 @@ async function runSelfImprove() {
include_blocked_actions: true, include_blocked_actions: true,
apply: false, 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}`; 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) => `
<div><strong>${p.proposal_id}: ${p.title}</strong></div> <div><strong>${p.proposal_id}: ${p.title}</strong></div>
<div>${p.problem}</div> <div>${p.problem}</div>
<div class="meta">risk=${p.risk} | auto_apply_safe=${p.auto_apply_safe}</div> <div class="meta">risk=${p.risk} | auto_apply_safe=${p.auto_apply_safe}</div>
<div class="meta">files=${(p.files || []).join(", ") || "-"}</div> <div class="meta">files=${(p.files || []).join(", ") || "-"}</div>
<div class="meta">tests=${(p.tests || []).join(" | ") || "-"}</div> <div class="meta">tests=${(p.tests || []).join(" | ") || "-"}</div>
<div style="margin-top:6px">${p.change || ""}</div> <div style="margin-top:6px">${p.change || ""}</div>
<div style="margin-top:6px"><button class="apply-proposal" data-proposal-id="${p.proposal_id}">Apply as branch</button></div>
`); `);
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) { } catch (e) {
summary.textContent = `Error: ${String(e)}`; summary.textContent = `Error: ${String(e)}`;
} }

View file

@ -84,6 +84,7 @@
<div class="controls"> <div class="controls">
<input id="improveObjective" type="text" placeholder="Objective" /> <input id="improveObjective" type="text" placeholder="Objective" />
<input id="improveMax" type="number" min="1" max="20" value="5" style="width:90px" /> <input id="improveMax" type="number" min="1" max="20" value="5" style="width:90px" />
<label><input id="improveDryRun" type="checkbox" /> Dry-run apply</label>
<button id="runImprove">Generate Proposals</button> <button id="runImprove">Generate Proposals</button>
</div> </div>
</div> </div>