EmberClone/scripts/phase2_features.py

388 lines
14 KiB
Python

#!/usr/bin/env python3
"""
Phase-2 feature loop. Adds features one at a time to the running EmberClone.
Each feature:
1. Generate needed files via Gemma
2. tsc check (compile errors → re-prompt with errors as context)
3. Playwright screenshot of relevant route after change
4. Commit + push
5. Log to GENERATION_LOG.md
Designed for continuous overnight execution. Each Feature is one "batch".
"""
from __future__ import annotations
import asyncio
import datetime
import json
import subprocess
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
import httpx
ROOT = Path(__file__).resolve().parent.parent
LOG = ROOT / "GENERATION_LOG.md"
PHASE2_STATE = ROOT / ".phase2-state.json"
VLLM_URL = "http://127.0.0.1:8000/v1/chat/completions"
MODEL = "gemma-4-31b"
MAX_RETRIES = 3
def log(msg: str, level: str = "INFO") -> None:
ts = datetime.datetime.now().strftime("%H:%M:%S")
with LOG.open("a") as f:
f.write(f"- `{ts}` **{level}** {msg}\n")
print(f"[{ts} {level}] {msg}", flush=True)
def log_section(title: str) -> None:
ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with LOG.open("a") as f:
f.write(f"\n## {title} ({ts})\n\n")
print(f"\n=== {title} ===", flush=True)
def git(*args: str) -> tuple[int, str]:
r = subprocess.run(
["git", "-c", "user.email=dennis.paradzinski@it.financeflow.de",
"-c", "user.name=Dennis (via Claude+Gemma)", *args],
cwd=ROOT, capture_output=True, text=True,
)
return r.returncode, (r.stdout + r.stderr).strip()
async def gemma(prompt: str, max_tokens: int = 6000) -> str | None:
async with httpx.AsyncClient(timeout=600) as client:
try:
r = await client.post(VLLM_URL, json={
"model": MODEL,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": max_tokens, "temperature": 0.2,
})
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"]
except Exception as e:
log(f"Gemma-Call-Fehler: {type(e).__name__}: {e}", level="ERROR")
return None
def strip_codefence(text: str) -> str:
t = text.strip()
if t.startswith("```"):
lines = t.split("\n")
if lines[0].startswith("```"):
lines = lines[1:]
if lines and lines[-1].startswith("```"):
lines = lines[:-1]
t = "\n".join(lines)
return t.strip()
def load_existing(paths: list[str], max_each: int = 4000) -> str:
out = ""
for p in paths:
fp = ROOT / p
if fp.exists():
content = fp.read_text()[:max_each]
out += f"\n\n### `{p}` (bereits existent, als Referenz):\n```{fp.suffix.lstrip('.')}\n{content}\n```"
return out
def tsc_check() -> tuple[bool, str]:
"""Run tsc --noEmit on api. Returns (ok, error_output)."""
r = subprocess.run(
["pnpm", "--filter", "api", "exec", "tsc", "--noEmit", "-p", "tsconfig.json"],
cwd=ROOT, capture_output=True, text=True, timeout=120,
)
return r.returncode == 0, (r.stdout + r.stderr).strip()
@dataclass
class FileGen:
path: str
purpose: str
refs: list[str] = field(default_factory=list)
extra: str = ""
@dataclass
class Feature:
name: str
description: str
files: list[FileGen]
after_commit_routes: list[str] = field(default_factory=list) # routes to screenshot+verify
# ---------- Feature definitions ----------
FEATURES: list[Feature] = [
Feature(
name="customers-crud",
description="Customers-CRUD: API-Routes + Web-Page mit List + Create-Form + Delete",
files=[
FileGen(
path="apps/api/src/routes/customers.ts",
purpose=(
"Fastify-Plugin für CRUD /api/customers. GET / (list, only active by default), "
"GET /:id, POST / (create, name required), PATCH /:id, DELETE /:id (soft-delete via active=false). "
"Alle Routes brauchen Auth (request.jwtVerify()). Verwende drizzle db.select/insert/update."
),
refs=["apps/api/src/db/schema.ts", "apps/api/src/db/index.ts", "apps/api/src/routes/time-entries.ts"],
),
FileGen(
path="apps/web/src/pages/Customers.tsx",
purpose=(
"Customers-Page mit TanStack-Query Liste + Inline-Create-Form (nur 'name' Feld) + Delete-Button pro Eintrag. "
"Tailwind, layout wie TimeEntries.tsx (Header → Form-Card → Table). "
"Verwende api.listCustomers() / api.createCustomer({name}) / api.deleteCustomer(id)."
),
refs=["apps/web/src/pages/TimeEntries.tsx", "apps/web/src/lib/api.ts"],
),
],
after_commit_routes=["/customers"],
),
Feature(
name="projects-crud",
description="Projects-CRUD: API + Web-Page mit Customer-Picker",
files=[
FileGen(
path="apps/api/src/routes/projects.ts",
purpose=(
"Fastify-Plugin /api/projects. CRUD wie customers.ts. Felder: name, customerId (FK), active. "
"GET / optional ?customerId=X filter. Auth required."
),
refs=["apps/api/src/routes/customers.ts", "apps/api/src/db/schema.ts"],
),
FileGen(
path="apps/web/src/pages/Projects.tsx",
purpose=(
"Projects-Page. Liste + Create-Form mit name (text) + customerId (select dropdown, lädt via api.listCustomers()). "
"Layout konsistent mit Customers.tsx und TimeEntries.tsx."
),
refs=["apps/web/src/pages/Customers.tsx"],
),
],
after_commit_routes=["/projects"],
),
Feature(
name="api-client-extensions",
description="Erweitere lib/api.ts um Customer + Project Endpoints + Logout fixes",
files=[
FileGen(
path="apps/web/src/lib/api.ts",
purpose=(
"ERWEITERTE Version der bestehenden api.ts. Behalte alle bestehenden Funktionen (login, logout, getMe, listTimeEntries, "
"createTimeEntry, deleteTimeEntry). Füge hinzu: "
"listCustomers(), createCustomer({name}), deleteCustomer(id), "
"listProjects(opts?), createProject({name, customerId}), deleteProject(id). "
"Verwende type imports aus '@emberclone/shared' (CustomerInsert, ProjectInsert)."
),
refs=["apps/web/src/lib/api.ts"],
),
],
after_commit_routes=["/"],
),
Feature(
name="router-with-new-pages",
description="Erweitere App.tsx Routes um /customers, /projects + Navigation",
files=[
FileGen(
path="apps/web/src/components/Nav.tsx",
purpose=(
"Top-Nav-Bar React-Component. Links: Dashboard /, TimeEntries /time-entries, Customers /customers, Projects /projects. "
"Verwende TanStack-Router Link. Active-State styling per useLocation. Logout-Button rechts. "
"Tailwind, weißer Hintergrund, border-bottom, Container-zentriert."
),
refs=["apps/web/src/lib/api.ts"],
),
FileGen(
path="apps/web/src/App.tsx",
purpose=(
"ERWEITERTE Router-Setup. Behalte bestehende Routes (/, /login, /time-entries). "
"Füge hinzu: /customers (Customers component), /projects (Projects component), beide mit Auth-Check. "
"Root-Route rendert <Nav /> + <Outlet /> (außer auf /login). "
"Import Nav von ./components/Nav. Verwende addChildren()."
),
refs=["apps/web/src/App.tsx", "apps/web/src/pages/Customers.tsx", "apps/web/src/pages/Projects.tsx"],
),
],
after_commit_routes=["/", "/customers", "/projects"],
),
Feature(
name="dashboard-stats",
description="Dashboard mit echten Statistiken statt Placeholder",
files=[
FileGen(
path="apps/web/src/pages/Dashboard.tsx",
purpose=(
"ÜBERARBEITETER Dashboard. Drei Karten oben: "
"(1) 'Heute' — Gesamtstunden + Anzahl Einträge heute, "
"(2) 'Diese Woche' — Gesamtstunden + Tagesdurchschnitt, "
"(3) 'Aktive Projekte' — Anzahl Projekte mit Time-Entries diese Woche. "
"Darunter 'Letzte 5 Einträge' als Liste. "
"Verwende api.listTimeEntries() mit from-Parameter (heutiger Tag, Wochenanfang). "
"Tailwind Grid (md:grid-cols-3), Cards mit Icon + grosse Zahl + Label."
),
refs=["apps/web/src/pages/Dashboard.tsx", "apps/web/src/lib/api.ts"],
),
],
after_commit_routes=["/"],
),
Feature(
name="active-timer-widget",
description="Aktiver Timer (start/stop) im Header sichtbar",
files=[
FileGen(
path="apps/api/src/routes/time-entries.ts",
purpose=(
"ERWEITERTE time-entries-Routes. Behalte bestehende CRUD. "
"Neu: GET /api/time-entries/running → liefert den aktuell laufenden Eintrag (endTime IS NULL) für den User oder 404. "
"POST /api/time-entries/start (body: {description, projectId?}) → erstellt Eintrag mit startTime=now, endTime=null. "
"POST /api/time-entries/:id/stop → setzt endTime=now."
),
refs=["apps/api/src/routes/time-entries.ts"],
),
FileGen(
path="apps/web/src/components/ActiveTimer.tsx",
purpose=(
"Live-Timer Widget. useQuery({queryKey:['running-entry'], queryFn: api.getRunningTimeEntry, refetchInterval: 30000}). "
"Wenn running: zeigt elapsed time (mm:ss live aktualisiert mit useEffect+setInterval), Description, Stop-Button. "
"Wenn nicht: kleiner Start-Button der ein Description-Inline-Input zeigt + 'Start' setzt. "
"Kompakt für Nav-Bar Embedding (h-10 inline-flex)."
),
refs=["apps/web/src/lib/api.ts"],
),
],
after_commit_routes=["/"],
),
]
def load_state() -> dict:
if PHASE2_STATE.exists():
return json.loads(PHASE2_STATE.read_text())
return {"completed_features": [], "current_feature": None, "started_at": datetime.datetime.now().isoformat()}
def save_state(state: dict) -> None:
PHASE2_STATE.write_text(json.dumps(state, indent=2))
async def generate_file(fg: FileGen) -> tuple[bool, str]:
path = ROOT / fg.path
path.parent.mkdir(parents=True, exist_ok=True)
refs_ctx = load_existing(fg.refs)
log(f" Generating {fg.path} ({fg.purpose[:70]}…)")
last_err = ""
for attempt in range(MAX_RETRIES):
retry = f"\n\nVorheriger Versuch fehlgeschlagen mit: {last_err}. Bitte korrigieren." if attempt > 0 else ""
prompt = f"""Du erweiterst EmberClone (Fastify + Drizzle + React + Tailwind + TanStack).
{refs_ctx}
**Aufgabe:** Generiere `{fg.path}`.
**Zweck:** {fg.purpose}
{fg.extra}
ANTWORTE NUR MIT DEM DATEI-INHALT. Kein Code-Fence (```), keine Erklärung. Direkt der TypeScript/TSX/CSS-Code.{retry}
"""
t0 = time.time()
resp = await gemma(prompt)
dt = time.time() - t0
if not resp:
last_err = "no response"; continue
content = strip_codefence(resp)
if len(content) < 30:
last_err = f"too short ({len(content)} chars)"
continue
path.write_text(content)
log(f" wrote {len(content)} chars in {dt:.1f}s (attempt {attempt+1})")
return True, ""
return False, last_err
async def run_feature(feature: Feature) -> bool:
log_section(f"Feature: {feature.name}")
log(f"Description: {feature.description}")
log(f"Files: {len(feature.files)}")
all_ok = True
for fg in feature.files:
ok, err = await generate_file(fg)
if not ok:
log(f" FAILED {fg.path}: {err}", level="ERROR")
all_ok = False
# tsc check after all files generated
log("Running tsc --noEmit on api…")
ok, errors = tsc_check()
if not ok:
# only show first 1500 chars of errors
log(f" tsc errors:\n{errors[:1500]}", level="WARN")
else:
log(" tsc clean ✓")
# commit regardless (even partial)
rc, msg = git("add", "-A")
rc, msg = git("commit", "-q", "-m",
f"feat({feature.name}): {feature.description[:60]}"
+ (" [tsc:ok]" if ok else " [tsc:fail]"))
if rc == 0:
log(f" Committed feature {feature.name}")
rc, msg = git("push", "-q", "origin", "main")
log(f" Pushed: rc={rc}")
else:
log(f" Nothing to commit or commit failed: {msg}", level="WARN")
return all_ok and ok
async def main() -> int:
log_section(f"🚀 Phase-2 Codegen-Run gestartet")
log(f"Features im Backlog: {len(FEATURES)}")
state = load_state()
log(f"Bereits abgeschlossen: {len(state.get('completed_features', []))}")
for feature in FEATURES:
if feature.name in state.get("completed_features", []):
log(f"⏭ Skip {feature.name} (already done)")
continue
state["current_feature"] = feature.name
save_state(state)
try:
success = await run_feature(feature)
if success:
state.setdefault("completed_features", []).append(feature.name)
save_state(state)
log(f"✅ Feature {feature.name} OK")
else:
log(f"⚠️ Feature {feature.name} partial — moving on", level="WARN")
# mark as attempted to avoid infinite loop
state.setdefault("attempted_features", []).append(feature.name)
save_state(state)
except Exception as e:
log(f"❌ Feature {feature.name} crashed: {e}", level="ERROR")
state.setdefault("attempted_features", []).append(feature.name)
save_state(state)
log_section("Phase-2 Run beendet")
log(f"OK: {len(state.get('completed_features', []))}, "
f"Attempted: {len(state.get('attempted_features', []))}, "
f"Total: {len(FEATURES)}")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))