"""Patch (2026-10-12, user request): batch confirmation of required sections and batch run + auto-approval of gates G1..G5. Approvals are recorded under the name typed by the user, note "일괄 실행 자동 승인"; only PASS gates are approved and the batch stops at the first FAIL / error / blocker.""" from pathlib import Path APP = Path(__file__).resolve().parents[2] def patch(rel, reps): p = APP / rel s = p.read_text(encoding="utf-8") for a, b in reps: assert s.count(a) == 1, (rel, a[:70]) s = s.replace(a, b) p.write_text(s, encoding="utf-8") patch("web/app.py", [ ("""ACTIVE = {} # job id -> thread of this server process""", """ACTIVE = {} # job id -> thread of this server process BATCH = {} # pier name -> batch thread of this server process AUTO_NOTE = "일괄 실행 자동 승인\""""), (""" return redirect(request.form.get("next") or url_for("pier", name=name)) # ---------------- gates""", """ return redirect(request.form.get("next") or url_for("pier", name=name)) def unconfirmed(p): \"\"\"Required sections (all gates) that exist and are not confirmed, without duplicates.\"\"\" out = [] for g in gate_ids(): for r in p.gate_requirements(g): if r["exists"] and not r["confirmed"] and (r["file"], r["key"]) not in out: out.append((r["file"], r["key"])) return out @app.post("/pier//confirm_all") def confirm_all(name): p = pier_or_404(name) by = request.form.get("by", "").strip() if not by: flash("확정자 이름을 입력하세요.", "fail") return redirect(url_for("pier", name=name)) need = unconfirmed(p) for f, k in need: p.confirm(f, k, by) flash(f"필요한 묶음 {len(need)}개를 일괄 확정했습니다 ({by}).", "ok") return redirect(url_for("pier", name=name)) # ---------------- gates"""), ("""@app.post("/pier//gate//run") def gate_run(name, gate): p = pier_or_404(name) if gate not in gate_ids(): abort(404)""", """def batch_state(p): \"\"\"State of the batch run of this pier (실행 중 only while its thread lives in this server process).\"\"\" b = store.rjson(p.run_dir / "batch.json") if b and b["state"] == "실행 중": t = BATCH.get(p.name) if b.get("pid") != os.getpid() or not (t and t.is_alive()): b = dict(b, state="중단됨") return b def _batch(p, by, gates_todo): bp = p.run_dir / "batch.json" log = [] def put(state): store.wjson(bp, {"state": state, "by": by, "pid": os.getpid(), "at": store.now(), "log": log}) put("실행 중") for g in gates_todo: b = gate_blockers(p, g) if b: log.append(f"{g} 실행 불가: " + ", ".join(b)) return put("중단") jid = uuid.uuid4().hex[:12] store.wjson(p.run_dir / f"{g}_job.json", {"id": jid, "pid": os.getpid(), "state": "대기", "at": store.now(), "log": []}) ACTIVE[jid] = threading.current_thread() log.append(f"{g} 실행 시작") put("실행 중") _gate_job(p, g, jid) st = p.gate_status(g) rep, job = st["report"] or {}, st["job"] or {} if job.get("state") != "완료" or rep.get("job_id") != jid or rep.get("verdict") != "PASS": log.append(f"{g} {rep.get('verdict') or job.get('state')} — 승인하지 않고 멈춤 (결과를 확인하세요)") return put("중단") p.set_approval(g, "승인", by, AUTO_NOTE) s = rep["summary"] log.append(f"{g} PASS {s['passed']}/{s['total']} (계획 FAIL {len(rep['planned_fails'])}) → 승인") put("실행 중") put("완료") def anything_running(p): if (batch_state(p) or {}).get("state") == "실행 중": return True return any(job_state(p.gate_status(g)["job"]) in ("대기", "실행 중") for g in gate_ids()) @app.post("/pier//gates/run_all") def gates_run_all(name): p = pier_or_404(name) by = request.form.get("by", "").strip() if not by: flash("판정자 이름을 입력하세요.", "fail") return redirect(url_for("gates", name=name)) if anything_running(p): flash("이미 실행 중인 작업이 있습니다.", "fail") return redirect(url_for("gates", name=name)) todo = [g for g in gate_ids() if not p.gate_status(g)["approved"]] if not todo: flash("모든 게이트가 이미 승인되어 있습니다.", "ok") return redirect(url_for("gates", name=name)) b = gate_blockers(p, todo[0]) if b: flash(f"{todo[0]} 실행 불가: " + ", ".join(b), "fail") return redirect(url_for("gates", name=name)) store.wjson(p.run_dir / "batch.json", {"state": "실행 중", "by": by, "pid": os.getpid(), "at": store.now(), "log": ["일괄 실행 대기"]}) t = threading.Thread(target=_batch, args=(p, by, todo), daemon=True) BATCH[p.name] = t t.start() flash(f"{todo[0]}~{todo[-1]} 연속 실행을 시작했습니다. PASS인 게이트만 '{by}' 이름으로 자동 승인합니다.", "ok") return redirect(url_for("gates", name=name)) @app.post("/pier//gate//run") def gate_run(name, gate): p = pier_or_404(name) if gate not in gate_ids(): abort(404) if (batch_state(p) or {}).get("state") == "실행 중": flash("일괄 실행 중에는 개별 실행을 할 수 없습니다.", "fail") return redirect(url_for("gates", name=name))"""), (""" for r in rows: r["state"] = job_state(r["job"]) running = any(r["state"] in ("대기", "실행 중") for r in rows)""", """ for r in rows: r["state"] = job_state(r["job"]) batch = batch_state(p) running = any(r["state"] in ("대기", "실행 중") for r in rows) or (batch or {}).get("state") == "실행 중\""""), ]) patch("web/app.py", [ (""" return render_template("gates.html", p=p, rows=rows, running=running)""", """ return render_template("gates.html", p=p, rows=rows, running=running, batch=batch, todo=[g for g in gate_ids() if not p.gate_status(g)["approved"]])"""), (""" return render_template("pier.html", p=p, sections=secs, gates=gates,""", """ return render_template("pier.html", p=p, sections=secs, gates=gates, need=unconfirmed(p),"""), ]) patch("web/templates/pier.html", [ ("""

3. 게이트

""", """
{% if need %}확정자 게이트 G1~G5에 필요한 묶음 중 미확정인 것만 확정합니다. {% else %}게이트에 필요한 묶음이 모두 확정되어 있습니다.{% endif %}

3. 게이트

"""), ]) patch("web/templates/gates.html", [ ("""

검토보고서 (초안)

""", """

연속 실행·자동 승인

{% if batch %}

최근 일괄 실행: {{ batch.state }} · 판정자 {{ batch.by }} · {{ batch.at }}

    {% for m in batch.log %}
  • {{ m }}
  • {% endfor %}
{% endif %} {% if todo %}
판정자

PASS인 게이트만 승인합니다. FAIL·오류·실행 조건 미충족이면 그 게이트에서 멈추고 승인하지 않습니다. 승인 기록의 의견란에 '일괄 실행 자동 승인'이 남습니다. 결과는 아래 게이트별 표에서 확인하세요.

{% else %}

모든 게이트가 승인되어 있습니다.

{% endif %}

검토보고서 (초안)

"""), ]) print("patched")