388 lines
14 KiB
Python
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()))
|