diff --git a/.env.example b/.env.example index 6cc4aa5..9a13814 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,9 @@ ALLOWED_ORIGINS=https://mitai.jinkendo.de # ── Pfade ─────────────────────────────────────────────────────── PHOTOS_DIR=/app/photos ENVIRONMENT=production + +# ── Gitea API (lokal, für scripts/gitea/gitea_api.py – niemals committen) ── +GITEA_BASE_URL=http://192.168.2.144:3000 +GITEA_OWNER=Lars +GITEA_REPO=mitai-jinkendo +GITEA_TOKEN= diff --git a/.gitignore b/.gitignore index 653a6a6..97d2d19 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,7 @@ tmp/ #.claude Konfiguration .claude/ + +# Cursor MCP mit Secrets (Example: .cursor/mcp.json.example) +.cursor/mcp.json .claude/settings.local.jsonfrontend/package-lock.json diff --git a/docs/issues/REVIEW_OPEN_ISSUES_2026-04-04.md b/docs/issues/REVIEW_OPEN_ISSUES_2026-04-04.md new file mode 100644 index 0000000..7232f69 --- /dev/null +++ b/docs/issues/REVIEW_OPEN_ISSUES_2026-04-04.md @@ -0,0 +1,269 @@ +# Gitea offene Issues – Review-Entwurf (Stand Code 2026-04-04) + +> **Zweck:** Du kannst diese Datei **lesen, anpassen, abstreichen**. Nach Freigabe: Kommentare / Schließungen / Konsolidierungen in Gitea umsetzen (manuell oder per `scripts/gitea/gitea_api.py`). +> +> **Sortierung:** Nach **`created_at` aufsteigend** (älteste Issues zuerst). Duplikat-Issues mit gleichem Inhalt sind am Ende des Abschnitts „Duplikate“ gebündelt. +> +> **Legende `Vorschlag`:** +> - `OFFEN` – noch sinnvoll, weiterverfolgen +> - `TEILWEISE` – Teil umgesetzt, Beschreibung/AC anpassen +> - `PRÜFEN` – kurzer manueller Test nötig +> - `DUPLIKAT` – mit anderem Issue zusammenführen +> - `DONE?` – wirkt im Repo erledigt, ggf. schließen nach deiner Bestätigung + +--- + +## Kurz-Übersicht (28 eindeutige Issue-Nummern, zeitlich älteste zuerst) + +| # | Titel (gekürzt) | Vorschlag | Notiz | +|---|-----------------|-----------|--------| +| 14 | Icon Picker Trainingstypen | OFFEN | Nur Freitext-Feld `icon` | +| 15 | Quality-Filter KI & Charts | TEILWEISE | Registry/Charts Confidence, kein durchgängiges Produkt | +| 21 | Universeller CSV-Parser + Mapping | OFFEN | Modul-spezifische Parser | +| 25 | Ziele-System Goals | DONE? | GoalsPage, Router, Focus Areas | +| 26 | Charts erweitern | TEILWEISE | Phase-0c API + History/NutritionCharts | +| 27 | Korrelationen & Insights | TEILWEISE | C-Charts + offene Data-Layer-TODOs | +| 29 | Abilities-Matrix UI | TEILWEISE | Admin/ProfileBuilder, UX offen | +| 30 | Responsive UI Sidebar | OFFEN | Weiterhin Bottom-Nav-fokussiert | +| 32 | version.py + `/api/version` | OFFEN | `version.py` ja, dedizierter Endpoint nein | +| 33 | main.py Hardcoded Version | OFFEN | FastAPI `3.0.0`, Root `v9c-dev` | +| 34 | External Volumes Doku | PRÜFEN | Gegen Compose abgleichen | +| 35 | `subscriptions` Tabelle | PRÜFEN | Schema prüfen | +| 36 | BUG Trainingstyp ISE | PRÜFEN | Logs nötig | +| 37 | Feature Enforcement Activity CSV | OFFEN | Import ohne vorgeschalteten Check | +| 38 | Feature Enforcement Nutrition CSV UI | DONE? / TEILWEISE | Backend-Check da | +| 39 | Usage-Badges Dashboard/Assistent | TEILWEISE | v. a. Gewicht im Dashboard | +| 40 | Logout Header | DONE? | `App.jsx` LogOut-Button | +| 42 | Enhanced Debug UI | DUPLIKAT | Mit #43 zusammenführen | +| 43 | Enhanced Debug UI | DUPLIKAT | Mit #42 zusammenführen | +| 45 | Prompt-Optimierer | OFFEN | Backlog | +| 46 | Prompt-Ersteller | OFFEN | Backlog | +| 47 | Wertetabelle Optimierung | OFFEN | UX-Feinschliff | +| 49 | Prompt-Zuordnung Verlauf | OFFEN | Kein klarer Treffer | +| 54 | Placeholder Registry … | DUPLIKAT | Wie #55 | +| 55 | Placeholder Registry … | OFFEN / TEILWEISE | `validate_all` existiert | +| 56–58 | Body Cluster Restarbeiten | DUPLIKAT | Dreimal gleicher Inhalt → ein Issue | + +--- + +## Details je Issue (älteste zuerst) + +### #14 – [FEAT-001] Icon Picker für Trainingstypen + +**Code-Stand:** `AdminTrainingTypesPage.jsx` nutzt ein **Textfeld** `icon` (frei, z. B. Emoji). Kein dedizierter Icon-Picker (Palette, Vorschau, Kategorien). + +**Vorschlag:** `OFFEN` – Issue beibehalten; optional präzisieren: „Emoji-Picker oder vordefinierte Icon-Liste statt Freitext“. + +**Kommentar-Entwurf für Gitea:** +> Stand Backend/Frontend: `icon` wird als String in `training_types` gespeichert, Eingabe ist Freitext. Icon-Picker-UX steht noch aus. + +--- + +### #15 – [FEAT-002] Quality-Filter für KI-Auswertungen & Charts + +**Code-Stand:** Charts/Data-Layer nutzen **Confidence** u. a.; Placeholder-Registry hat viele `quality_filter_policy` / Evidence-Felder (teilweise `UNRESOLVED`/`TO_VERIFY`). Ein **einheitlicher** „Quality-Filter“-Mechanismus über alle KI-Auswertungen ist nicht eindeutig als fertiges Feature erkennbar. + +**Vorschlag:** `TEILWEISE` – Issue-Text auf konkrete Lücken schärfen (welche Prompts/Charts, welche Schwellen, SSoT Registry?). + +**Kommentar-Entwurf:** +> Teilaspekte existieren (z. B. Confidence in Chart-Endpoints, Registry-Felder). Offen: durchgängige KI-/Chart-Quality-Pipeline und Abgleich mit Issue-Zielbild. + +--- + +### #21 – [FEATURE] Universeller CSV-Parser mit lernbarem Feldmapping + +**Code-Stand:** Modulspezifische CSV-Imports (Activity, Nutrition, Vitals, Sleep, …) mit jeweils eigenem Parser; **lernbares Mapping** stark bei **Activity** über `activity_type_mappings`. Kein **ein** generischer CSV-Engine wie im Issue beschrieben. + +**Vorschlag:** `OFFEN` – oder Scope reduzieren („pro Modul konsolidieren“). + +--- + +### #25 – [FEAT] Ziele-System (Goals) v9e Kernfeature + +**Code-Stand:** `goals`-Router, `GoalsPage`, Focus Areas, Migrationen – laut Projektstand **weitgehend implementiert**. + +**Vorschlag:** `DONE?` nach deiner Abnahme – Issue-Body auf verbleibende Teilziele kürzen oder schließen. + +**Kommentar-Entwurf:** +> Backend/Frontend Goals + Focus Areas sind im Repo vorhanden. Bitte verbleibende Wünsche als neue Sub-Issues oder AC hier abhaken und schließen. + +--- + +### #26 – [FEAT] Charts & Visualisierungen erweitern + +**Code-Stand:** `backend/routers/charts.py` (Phase 0c), viele `api.get…Chart` in `api.js`; `History.jsx` + `NutritionCharts` / `RecoveryCharts` nutzen Chart-Daten. + +**Vorschlag:** `TEILWEISE` – Issue auf konkrete fehlende Chart-Typen/UI-Verdrahtung schärfen (falls noch offen). + +--- + +### #27 – [FEAT] Korrelationen & Insights erweitern + +**Code-Stand:** Chart-Endpunkte C1–C4 u. a.; Data-Layer `correlations.py` mit TODO-Stellen in Teilen. + +**Vorschlag:** `TEILWEISE` – Liste fehlender Korrelationen/Insights vs. Code ergänzen. + +--- + +### #29 – [FEAT] Abilities-Matrix UI (v9f) + +**Code-Stand:** Training Types mit `abilities` JSONB, `AdminTrainingProfiles`, `ProfileBuilder` – vollständige „5D Matrix“-UX unklar ohne Produktvorgabe. + +**Vorschlag:** `TEILWEISE` – AC mit aktuellen Screenshots/Flows abgleichen. + +--- + +### #30 – [FEAT] Responsive UI – Desktop Sidebar + 2-spaltig + +**Code-Stand:** Weiterhin stark **Mobile-first** (z. B. `bottom-nav` in `App.jsx`); keine ausgebaute Desktop-Sidebar wie im klassischen Admin-Dashboard. + +**Vorschlag:** `OFFEN`. + +--- + +### #32 – Version-System (`version.py` + `/api/version`) + +**Code-Stand:** `backend/version.py` existiert mit `APP_VERSION`. **`GET /api/version`** im Backend **nicht** gefunden (Suche nach Route); Root liefert u. a. `"version": "v9c-dev"`. + +**Vorschlag:** `OFFEN` für #32 – `/api/version` implementieren oder Issue anpassen („nur version.py ohne Endpoint“). + +--- + +### #33 – main.py hardcoded Version entfernen + +**Code-Stand:** `main.py`: `FastAPI(..., version="3.0.0")`; Root-JSON noch `v9c-dev`. + +**Vorschlag:** `OFFEN` – auf `version.py` vereinheitlichen (inkl. FastAPI-`version`-Feld und Health-Payload). + +--- + +### #34 – External Volumes dokumentieren (Legacy bodytrack_*) + +**Vorschlag:** `PRÜFEN` – gegen aktuelle `docker-compose`/Deploy-Doku im Repo halten; dann schließen oder Aktualisierung kommentieren. + +--- + +### #35 – Deprecated Tabelle `subscriptions` entfernen + +**Code-Stand:** Im Migrations-Ordner **kein** aktueller Treffer auf `subscriptions` (Stichprobe); Membership-System nutzt andere Tabellen. + +**Vorschlag:** `PRÜFEN` – einmal `schema.sql` / DB prüfen, ob Tabelle noch existiert. Wenn weg: Issue schließen mit Verweis auf Migration. + +--- + +### #36 – BUG-009: Trainingstyp-Erstellung → Internal Server Error + +**Code-Stand:** `TrainingTypeCreate` enthält `abilities` / `profile` (JSONB). ISE oft durch DB-Constraint, NULL/JSON oder fehlende Spalte – **ohne Laufzeit-Log nicht verifiziert**. + +**Vorschlag:** `PRÜFEN` – in Gitea Notiz: aktueller Request-Body + Stacktrace aus `docker logs`; wenn behoben: schließen. + +--- + +### #37 – Feature-Enforcement Activity CSV-Import + +**Code-Stand:** `create_activity` nutzt `check_feature_access` für `activity_entries`. **`import_activity_csv`** startet **ohne** vorgeschalteten Limit-Check (im gelesenen Abschnitt nur `get_pid` + Parse) – von Issue #37 noch **nicht** erfüllt. + +**Vorschlag:** `OFFEN` – hoch priorisieren; analog Nutrition: ein Check vor Bulk-Import + Zählung. + +--- + +### #38 – Feature-Enforcement Nutrition CSV-Import UI + +**Code-Stand:** `import_nutrition_csv` ruft **`check_feature_access`** für `nutrition_entries` auf (inkl. Logging). + +**Vorschlag:** `TEILWEISE` / `DONE?` – falls UI-Feedback gewünscht, im Issue auf konkrete UI-Lücken eingehen (Banner, Disable Button). + +--- + +### #39 – Usage-Badges im Dashboard-Assistenten + +**Code-Stand:** `Dashboard.jsx` nutzt `getFeatureUsage()` für **Gewichts-Widget** (Limit/Lock). Unklar ob „Assistent“-Modus = gesamtes Dashboard oder separater Guide. + +**Vorschlag:** `TEILWEISE` – Issue präzisieren, welche Kacheln/Bereiche Badges brauchen. + +--- + +### #40 – Logout-Button im App-Header (neben Avatar) + +**Code-Stand:** `App.jsx` – Header mit **`LogOut` neben Avatar** (Umsetzung vorhanden). + +**Vorschlag:** `DONE?` – nach kurzem Klicktest **schließen**. + +**Kommentar-Entwurf:** +> In `App.jsx` ist ein Logout-Button im Header umgesetzt. Bitte in target Umgebung verifizieren und schließen. + +--- + +### #42 / #43 – Enhanced Debug / Prompt Analysis UI (Issue #28 Phase C) + +**Code-Stand:** `Analysis.jsx` mit Expert-Modus, Platzhalter-Gruppierung – kann Teile von Phase C abdecken. + +**Vorschlag:** `DUPLIKAT` – **ein** Issue behalten; anderes schließen mit Verweis. Inhalte zusammenführen. + +--- + +### #45 – KI Prompt-Optimierer + +**Vorschlag:** `OFFEN` – Backlog, nicht im aktuellen Code sichtbar. + +--- + +### #46 – KI Prompt-Ersteller + +**Vorschlag:** `OFFEN` – wie #45. + +--- + +### #47 – Wertetabelle Optimierung + +**Code-Stand:** Wertetabelle in `Analysis.jsx` / Metadata – viele Punkte eher UX/Performance. + +**Vorschlag:** `OFFEN` – konkrete UI-Schmerzpunkte in Sub-Tasks splitten. + +--- + +### #49 – Prompt-Zuordnung zu Verlaufsseiten + +**Code-Stand:** Kein eindeutiger Treffer zu „History page prompt assignment“ in kurzer Suche. + +**Vorschlag:** `OFFEN` – kurz präzisieren (Welche Seite, welches Datenmodell). + +--- + +### #54 / #55 – Placeholder Registry UNRESOLVED & TO_VERIFY + +**Code-Stand:** `placeholder_registry_export.py` liefert **`validation_report` über `registry.validate_all()`** (nicht mehr leer aus dem frühen Issue-Text für Body-Cluster „{}“-Teil). Evidence `TO_VERIFY`/`UNRESOLVED` existieren weiter in Registrations. + +**Vorschlag:** `#54` und `#55` **zusammenlegen** (gleiches Thema, Titel nur Encoding-Unterschied). Ein Issue offen lassen, Metadaten-Audit fortsetzen. + +--- + +### #56 / #57 / #58 – Body Cluster Restarbeiten & Metadaten-Verifizierung + +**Inhalt:** identische bzw. nahezu identische Langbeschreibung (Metadaten, Layer 2b, Nutrition confidence_logic, Validation Report). + +**Vorschlag:** `DUPLIKAT` – **eines** behalten (z. B. niedrigste Nummer oder neueste #58 mit aktualisiertem Stand), andere **schließen** mit Verweis „Duplicate of #X“. Validation-Teil: Code hat bereits `validate_all` – Issue-Text Abschnitt „leeres validation_report“ **aktualisieren**. + +**Kommentar-Entwurf:** +> Dreimal dasselbe Issue. Vorschlag: #56/#57 schließen, Tracking nur in #XX. `validation_report` wird aus `registry.validate_all()` befüllt; verbleibende Arbeit: TO_VERIFY-Felder Layer 2b + Nutrition confidence_logic laut Checkliste. + +--- + +## Nachbearbeitung in Gitea (Checkliste für dich) + +- [ ] Duplikate schließen und verlinken (#42/#43, #54/#55, #56–#58). +- [ ] „DONE?“-Issues manuell testen (`#25`, `#38`, `#40`). +- [ ] `#37` umsetzen oder Kommentar „noch offen“ bestätigen. +- [ ] `#32`–`#33` Versionierung planen (ein gemeinsames Mini-Epic). +- [ ] Kommentare aus diesem Dokument kopieren/anpassen. +- [ ] Optional: Labels in Gitea setzen (`duplicate`, `blocked`, `needs-retest`). + +--- + +## Technischer Hinweis (Audit / Security) + +Aus dem Code-Audit 2026-04-04: kritische Punkte (`get_pid` / `X-Profile-Id`, `/api/profiles` ohne Admin) sind **nicht** 1:1 als Gitea-Issues in dieser offenen Liste sichtbar – ggf. **separate** Security-Issues aus `.claude/docs/audit/20260404_code_audit/gitea/` anlegen, falls noch nicht vorhanden. + +--- + +*Erzeugt aus Gitea API (28 offene Issues, sortiert nach `created_at`) und statischer Code-Analyse im Workspace. Kein Laufzeit-Test auf dem Pi.* diff --git a/scripts/gitea/MCP_SETUP.md b/scripts/gitea/MCP_SETUP.md new file mode 100644 index 0000000..dda4927 --- /dev/null +++ b/scripts/gitea/MCP_SETUP.md @@ -0,0 +1,64 @@ +# Gitea MCP-Server für Cursor + +Damit der Agent **strukturierte Tools** nutzen kann (`gitea_list_issues`, `gitea_close_issue`, …), registrierst du diesen MCP-S **lokal** in Cursor. + +## 1. Abhängigkeit + +```powershell +pip install -r scripts/gitea/requirements-mcp.txt +``` + +Oder: `pip install "mcp>=1.2.0"` + +## 2. Secrets + +Wie beim CLI: **`GITEA_*` in der Repo-Root `.env`** (wird von `gitea_lib` geladen), **oder** dieselben Variablen in der MCP-`env` (siehe unten). + +**Niemals** Tokens in Git committen. + +## 3. Cursor MCP konfigurieren + +**Variante A – UI:** Einstellungen → **MCP** / Tools → Server hinzufügen → Typ „Command“: + +- **Command:** `python` (oder voller Pfad zu `python.exe`) +- **Args:** vollständiger Pfad zu `mcp_server_gitea.py`, z. B. + `C:\Dev\mitai-jinkendo\scripts\gitea\mcp_server_gitea.py` +- **Working directory (optional):** `C:\Dev\mitai-jinkendo\scripts\gitea` +- **Env:** nur nötig, wenn du **keine** `.env` im Repo nutzt: + +```text +GITEA_BASE_URL=http://192.168.2.144:3000 +GITEA_OWNER=Lars +GITEA_REPO=mitai-jinkendo +GITEA_TOKEN=… +``` + +**Variante B – JSON:** Datei `~/.cursor/mcp.json` (Benutzer) oder projektbezogen laut Cursor-Doku. Beispielinhalt siehe **`.cursor/mcp.json.example`** im Repo (Platzhalter, ohne echtes Token). + +Cursor nach Änderung **vollständig neu starten**. + +## 4. Netzwerk + +Die Gitea-URL muss von deinem Rechner erreichbar sein (z. B. `192.168.2.144:3000` im LAN). + +## 5. Repo-Zugriff + +- **API:** Tool `gitea_get_repo_file` (Dateiinhalt / Metadaten). +- **Git (lokal):** unverändert `git pull` / Agent liest Workspace-Dateien – dafür brauchst du kein MCP. + +## Bereitgestellte Tools (Kurzüberblick) + +| Tool | Zweck | +|------|--------| +| `gitea_list_issues` | Issues listen, optional alle Seiten | +| `gitea_get_issue` | Ein Issue mit Body | +| `gitea_comment_issue` | Kommentar | +| `gitea_create_issue` | Neu anlegen | +| `gitea_close_issue` / `gitea_reopen_issue` | Status | +| `gitea_get_repo_file` | Datei remote via API | + +## Issue-Triage durch den Agent + +Sinnvoller Ablauf: Issues listen → je Issue **Code/Commits prüfen** → bei eindeutig erledigt: kurzer Kommentar + **close**; bei teilweise: Kommentar mit Checkboxen; bei unklar: nur Kommentar, **nicht** schließen. + +Autonom alles schließen ist fehleranfällig; klare Regeln oder manuelle Freigabe für `close` empfohlen. diff --git a/scripts/gitea/README.md b/scripts/gitea/README.md new file mode 100644 index 0000000..bfa8853 --- /dev/null +++ b/scripts/gitea/README.md @@ -0,0 +1,68 @@ +# Gitea API – lokales CLI + +Dient dazu, **Issues** auf deiner Gitea-Instanz zu lesen und anzulegen – mit den in **`.env`** gesetzten Variablen (nicht committen). + +## Umgebungsvariablen (Root `.env`) + +| Variable | Beispiel | +|----------|----------| +| `GITEA_BASE_URL` | `http://192.168.2.144:3000` | +| `GITEA_TOKEN` | Personal Access Token (nur Scope **repo** + **issue** nötig) | +| `GITEA_OWNER` | `Lars` | +| `GITEA_REPO` | `mitai-jinkendo` | + +## Voraussetzung + +Python 3.10+ (nur Standardbibliothek). + +## Aufruf (im Repo-Root) + +```powershell +# Issues auflisten (offen) +python scripts/gitea/gitea_api.py issues list + +# Issues mit State +python scripts/gitea/gitea_api.py issues list --state all + +# Ein Issue lesen +python scripts/gitea/gitea_api.py issues get 42 + +# Issue anlegen (Titel + Body aus Datei oder direkt) +python scripts/gitea/gitea_api.py issues create --title "Fix: …" --body "…" + +python scripts/gitea/gitea_api.py issues create --title "Fix: …" --body-file path/to/body.md + +# Kommentar +python scripts/gitea/gitea_api.py issues comment 42 --body "…" + +# Schließen / wieder öffnen +python scripts/gitea/gitea_api.py issues close 42 +python scripts/gitea/gitea_api.py issues reopen 42 + +# Alle Issues (alle Seiten, Vorsicht bei großen Repos) +python scripts/gitea/gitea_api.py issues list --all-pages --state open + +# Markdown-Datei (z. B. Audit-Template) als Issue-Body +python scripts/gitea/gitea_api.py issues create --title "…" --body-file .claude/docs/audit/.../gitea/TEMPLATE_P0-....md +``` + +## Repository-Inhalt (read-only) + +```powershell +# Datei über Gitea-API (bei Dateien: Text-Inhalt; bei Verzeichnissen: JSON-Listing) +python scripts/gitea/gitea_api.py repo file README.md + +python scripts/gitea/gitea_api.py repo file backend/main.py --ref develop + +# Clone/Push: normales `git remote` – Token nicht dauerhaft in der Remote-URL; SSH oder Credential Helper. +``` + +## Sicherheit + +- **Niemals** `GITEA_TOKEN` ins Git oder in Issues/Pastebins. +- Token, das in Chat oder Logs gelandet ist, in Gitea **widerrufen** und **neu erzeugen**. +- Cursor-Agenten können das CLI über das Terminal nutzen, wenn `.env` gesetzt und Netzwerk zu `GITEA_BASE_URL` erreichbar ist. + +## MCP (Tools direkt im Agent) + +Siehe [`MCP_SETUP.md`](./MCP_SETUP.md) und [`../.cursor/mcp.json.example`](../../.cursor/mcp.json.example). diff --git a/scripts/gitea/gitea_api.py b/scripts/gitea/gitea_api.py new file mode 100644 index 0000000..b85d21b --- /dev/null +++ b/scripts/gitea/gitea_api.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +""" +Minimal Gitea API client. Reads GITEA_* from environment or .env in repo root. +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from gitea_lib import ( + issues_comment, + issues_create, + issues_get, + issues_list_all, + issues_list_page, + issues_patch, + load_dotenv, + repo_file_content, + repo_root, + require_config, +) + + +def cmd_issues_list(args: argparse.Namespace, base: str, token: str, owner: str, repo: str) -> None: + if args.all_pages: + items = issues_list_all( + base, token, owner, repo, state=args.state, limit=args.limit + ) + else: + _, items = issues_list_page( + base, + token, + owner, + repo, + state=args.state, + page=args.page, + limit=args.limit, + ) + for it in items: + num = it.get("number") + title = it.get("title") + st = it.get("state") + print(f"#{num} [{st}] {title}") + + +def cmd_issues_get(args: argparse.Namespace, base: str, token: str, owner: str, repo: str) -> None: + status, payload = issues_get(base, token, owner, repo, args.number) + print(json.dumps(payload, indent=2, ensure_ascii=False)) + if status >= 400: + sys.exit(1) + + +def cmd_issues_create(args: argparse.Namespace, base: str, token: str, owner: str, repo: str) -> None: + body = args.body or "" + if args.body_file: + body = Path(args.body_file).read_text(encoding="utf-8") + status, payload = issues_create( + base, + token, + owner, + repo, + title=args.title, + body=body, + labels=args.labels or [], + ) + print(json.dumps(payload, indent=2, ensure_ascii=False)) + if status >= 400: + sys.exit(1) + + +def cmd_issues_comment(args: argparse.Namespace, base: str, token: str, owner: str, repo: str) -> None: + status, payload = issues_comment( + base, token, owner, repo, args.number, args.body + ) + print(json.dumps(payload, indent=2, ensure_ascii=False)) + if status >= 400: + sys.exit(1) + + +def cmd_issues_close(args: argparse.Namespace, base: str, token: str, owner: str, repo: str) -> None: + status, payload = issues_patch( + base, token, owner, repo, args.number, {"state": "closed"} + ) + print(json.dumps(payload, indent=2, ensure_ascii=False)) + if status >= 400: + sys.exit(1) + + +def cmd_issues_reopen(args: argparse.Namespace, base: str, token: str, owner: str, repo: str) -> None: + status, payload = issues_patch( + base, token, owner, repo, args.number, {"state": "open"} + ) + print(json.dumps(payload, indent=2, ensure_ascii=False)) + if status >= 400: + sys.exit(1) + + +def cmd_repo_contents(args: argparse.Namespace, base: str, token: str, owner: str, repo: str) -> None: + status, payload = repo_file_content( + base, token, owner, repo, args.path, ref=args.ref or "" + ) + if status >= 400: + print(json.dumps(payload, indent=2, ensure_ascii=False)) + sys.exit(1) + if isinstance(payload, dict) and payload.get("encoding") == "text": + print(payload.get("content", "")) + else: + print(json.dumps(payload, indent=2, ensure_ascii=False)) + + +def main() -> None: + if hasattr(sys.stdout, "reconfigure"): + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + root = repo_root() + load_dotenv(root) + + parser = argparse.ArgumentParser(description="Gitea API helper") + sub = parser.add_subparsers(dest="domain", required=True) + + p_issues = sub.add_parser("issues", help="Issues") + i_sub = p_issues.add_subparsers(dest="issues_cmd", required=True) + + p_il = i_sub.add_parser("list", help="List issues") + p_il.add_argument("--state", default="open", choices=["open", "closed", "all"]) + p_il.add_argument("--limit", type=int, default=50) + p_il.add_argument("--page", type=int, default=1) + p_il.add_argument( + "--all-pages", + action="store_true", + help="Alle Seiten abfragen (Vorsicht bei sehr vielen Issues)", + ) + p_il.set_defaults(_handler=cmd_issues_list) + + p_ig = i_sub.add_parser("get", help="Get one issue") + p_ig.add_argument("number", type=int) + p_ig.set_defaults(_handler=cmd_issues_get) + + p_ic = i_sub.add_parser("create", help="Create issue") + p_ic.add_argument("--title", required=True) + p_ic.add_argument("--body", default="") + p_ic.add_argument("--body-file") + p_ic.add_argument("--labels", nargs="*", default=[]) + p_ic.set_defaults(_handler=cmd_issues_create) + + p_co = i_sub.add_parser("comment", help="Add comment") + p_co.add_argument("number", type=int) + p_co.add_argument("--body", required=True) + p_co.set_defaults(_handler=cmd_issues_comment) + + p_cl = i_sub.add_parser("close", help="Close issue") + p_cl.add_argument("number", type=int) + p_cl.set_defaults(_handler=cmd_issues_close) + + p_ro = i_sub.add_parser("reopen", help="Reopen issue") + p_ro.add_argument("number", type=int) + p_ro.set_defaults(_handler=cmd_issues_reopen) + + p_repo = sub.add_parser("repo", help="Repository (API)") + r_sub = p_repo.add_subparsers(dest="repo_cmd", required=True) + + p_rc = r_sub.add_parser("file", help="Get file or directory metadata/content") + p_rc.add_argument("path") + p_rc.add_argument("--ref", default="", help="branch/tag/commit") + p_rc.set_defaults(_handler=cmd_repo_contents) + + args = parser.parse_args() + try: + base, token, owner, reponame = require_config() + except RuntimeError as e: + sys.stderr.write(str(e) + "\n") + sys.exit(1) + + handler = args._handler + handler(args, base, token, owner, reponame) + + +if __name__ == "__main__": + main() diff --git a/scripts/gitea/gitea_lib.py b/scripts/gitea/gitea_lib.py new file mode 100644 index 0000000..8210fce --- /dev/null +++ b/scripts/gitea/gitea_lib.py @@ -0,0 +1,226 @@ +""" +Shared Gitea REST helpers (stdlib). Used by gitea_api.py CLI and mcp_server_gitea.py. +""" +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + + +def load_dotenv(repo_root: Path) -> None: + env_path = repo_root / ".env" + if not env_path.is_file(): + return + for line in env_path.read_text(encoding="utf-8", errors="replace").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + k, _, v = line.partition("=") + k, v = k.strip(), v.strip().strip('"').strip("'") + if k and k not in os.environ: + os.environ[k] = v + + +def repo_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def get_config() -> tuple[str, str, str, str]: + base = os.getenv("GITEA_BASE_URL", "").rstrip("/") + token = os.getenv("GITEA_TOKEN", "") + owner = os.getenv("GITEA_OWNER", "") + reponame = os.getenv("GITEA_REPO", "") + return base, token, owner, reponame + + +def require_config() -> tuple[str, str, str, str]: + base, token, owner, reponame = get_config() + missing = [n for n, v in ( + ("GITEA_BASE_URL", base), + ("GITEA_TOKEN", token), + ("GITEA_OWNER", owner), + ("GITEA_REPO", reponame), + ) if not v] + if missing: + raise RuntimeError( + "Fehlende Umgebungsvariablen: " + ", ".join(missing) + + " — setze sie in .env im Repo-Root oder in der MCP-env." + ) + return base, token, owner, reponame + + +def request_json( + method: str, + url: str, + token: str, + data: dict | None = None, +) -> tuple[int, Any]: + body = None if data is None else json.dumps(data).encode("utf-8") + req = urllib.request.Request(url, data=body, method=method) + req.add_header("Authorization", f"token {token}") + req.add_header("Accept", "application/json") + if body is not None: + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req, timeout=120) as resp: + raw = resp.read().decode("utf-8", errors="replace") + status = resp.status + except urllib.error.HTTPError as e: + raw = e.read().decode("utf-8", errors="replace") + try: + return e.code, json.loads(raw) if raw else {} + except json.JSONDecodeError: + return e.code, {"message": raw or str(e)} + if not raw: + return status, {} + try: + return status, json.loads(raw) + except json.JSONDecodeError: + return status, raw + + +def issues_list_page( + base: str, + token: str, + owner: str, + repo: str, + *, + state: str = "open", + page: int = 1, + limit: int = 50, +) -> tuple[int, list]: + if state == "all": + open_st, open_i = issues_list_page( + base, token, owner, repo, state="open", page=page, limit=limit + ) + closed_st, closed_i = issues_list_page( + base, token, owner, repo, state="closed", page=page, limit=limit + ) + merged = (open_i or []) + (closed_i or []) + st = max(open_st, closed_st) if open_st >= 400 or closed_st >= 400 else 200 + return st, merged[:limit] + q = f"?state={state}&page={page}&limit={limit}" + url = f"{base}/api/v1/repos/{owner}/{repo}/issues{q}" + status, payload = request_json("GET", url, token) + if status >= 400: + return status, [] + if not isinstance(payload, list): + return status, [] + return status, payload + + +def issues_list_all( + base: str, + token: str, + owner: str, + repo: str, + *, + state: str = "open", + limit: int = 50, +) -> list[dict]: + if state == "all": + o = issues_list_all( + base, token, owner, repo, state="open", limit=limit + ) + c = issues_list_all( + base, token, owner, repo, state="closed", limit=limit + ) + return o + c + out: list[dict] = [] + page = 1 + while True: + _, batch = issues_list_page( + base, token, owner, repo, state=state, page=page, limit=limit + ) + if not batch: + break + out.extend(batch) + if len(batch) < limit: + break + page += 1 + return out + + +def issues_get( + base: str, token: str, owner: str, repo: str, number: int +) -> tuple[int, Any]: + url = f"{base}/api/v1/repos/{owner}/{repo}/issues/{number}" + return request_json("GET", url, token) + + +def issues_create( + base: str, + token: str, + owner: str, + repo: str, + *, + title: str, + body: str = "", + labels: list[str] | None = None, +) -> tuple[int, Any]: + url = f"{base}/api/v1/repos/{owner}/{repo}/issues" + return request_json( + "POST", + url, + token, + {"title": title, "body": body, "labels": labels or []}, + ) + + +def issues_comment( + base: str, + token: str, + owner: str, + repo: str, + number: int, + body: str, +) -> tuple[int, Any]: + url = f"{base}/api/v1/repos/{owner}/{repo}/issues/{number}/comments" + return request_json("POST", url, token, {"body": body}) + + +def issues_patch( + base: str, + token: str, + owner: str, + repo: str, + number: int, + fields: dict, +) -> tuple[int, Any]: + """Gitea: PATCH issue (state, title, body, …).""" + url = f"{base}/api/v1/repos/{owner}/{repo}/issues/{number}" + return request_json("PATCH", url, token, fields) + + +def repo_file_content( + base: str, + token: str, + owner: str, + repo: str, + path: str, + ref: str = "", +) -> tuple[int, Any]: + from urllib.parse import quote + from base64 import b64decode + + p = quote(path, safe="/") + r = f"?ref={ref}" if ref else "" + url = f"{base}/api/v1/repos/{owner}/{repo}/contents/{p}{r}" + st, payload = request_json("GET", url, token) + if st >= 400: + return st, payload + if isinstance(payload, dict) and payload.get("type") == "file" and payload.get( + "content" + ): + try: + text = b64decode(payload["content"]).decode("utf-8", errors="replace") + return st, {"path": path, "encoding": "text", "content": text} + except Exception: + return st, payload + return st, payload diff --git a/scripts/gitea/mcp_server_gitea.py b/scripts/gitea/mcp_server_gitea.py new file mode 100644 index 0000000..3d43785 --- /dev/null +++ b/scripts/gitea/mcp_server_gitea.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +""" +MCP-Server für Gitea (Issues + Datei-Inhalt via API). + +Cursor: in den MCP-Einstellungen dieses Skript starten (siehe MCP_SETUP.md). +Transport: stdio (Standard FastMCP). + +Abhängigkeit: pip install "mcp>=1.2.0" (siehe requirements-mcp.txt) +""" +from __future__ import annotations + +import json +import sys + +from gitea_lib import ( + issues_comment, + issues_create, + issues_get, + issues_list_all, + issues_list_page, + issues_patch, + load_dotenv, + repo_file_content, + repo_root, + require_config, +) + +from mcp.server.fastmcp import FastMCP # noqa: E402 + +mcp = FastMCP( + "mitai-gitea", + instructions=( + "Gitea-Tools für das Repo aus GITEA_OWNER/GITEA_REPO. " + "Schließe Issues nur nach klarer Code-Verifikation; sonst Kommentar mit offenen Punkten." + ), +) + + +def _cfg(): + load_dotenv(repo_root()) + return require_config() + + +def _json(obj) -> str: + return json.dumps(obj, indent=2, ensure_ascii=False) + + +@mcp.tool() +def gitea_list_issues( + state: str = "open", + limit_per_page: int = 50, + fetch_all_pages: bool = False, +) -> str: + """Listet Issues. state: open | closed | all. fetch_all_pages=true holt alle Seiten (kann langsam sein).""" + base, token, owner, repo = _cfg() + if fetch_all_pages: + items = issues_list_all( + base, token, owner, repo, state=state, limit=limit_per_page + ) + return _json( + [{"number": i.get("number"), "title": i.get("title"), "state": i.get("state")} for i in items] + ) + _, items = issues_list_page( + base, token, owner, repo, state=state, page=1, limit=limit_per_page + ) + return _json( + [{"number": i.get("number"), "title": i.get("title"), "state": i.get("state")} for i in items] + ) + + +@mcp.tool() +def gitea_get_issue(issue_number: int) -> str: + """Holt ein Issue inkl. Body, Labels, State (JSON).""" + base, token, owner, repo = _cfg() + st, payload = issues_get(base, token, owner, repo, issue_number) + return _json({"http_status": st, "issue": payload}) + + +@mcp.tool() +def gitea_create_issue(title: str, body: str = "", labels: str = "") -> str: + """Legt ein Issue an. labels: kommagetrennte Namen, z.B. \"bug,backend\".""" + base, token, owner, repo = _cfg() + lab = [x.strip() for x in labels.split(",") if x.strip()] + st, payload = issues_create( + base, token, owner, repo, title=title, body=body, labels=lab + ) + return _json({"http_status": st, "result": payload}) + + +@mcp.tool() +def gitea_comment_issue(issue_number: int, body: str) -> str: + """Kommentar an ein Issue anhängen.""" + base, token, owner, repo = _cfg() + st, payload = issues_comment(base, token, owner, repo, issue_number, body) + return _json({"http_status": st, "result": payload}) + + +@mcp.tool() +def gitea_close_issue(issue_number: int) -> str: + """Issue schließen (state=closed).""" + base, token, owner, repo = _cfg() + st, payload = issues_patch( + base, token, owner, repo, issue_number, {"state": "closed"} + ) + return _json({"http_status": st, "result": payload}) + + +@mcp.tool() +def gitea_reopen_issue(issue_number: int) -> str: + """Geschlossenes Issue wieder öffnen.""" + base, token, owner, repo = _cfg() + st, payload = issues_patch( + base, token, owner, repo, issue_number, {"state": "open"} + ) + return _json({"http_status": st, "result": payload}) + + +@mcp.tool() +def gitea_get_repo_file(path: str, git_ref: str = "") -> str: + """Liest eine Datei aus dem Repo über die Gitea-API (Standard: Default-Branch).""" + base, token, owner, repo = _cfg() + st, payload = repo_file_content(base, token, owner, repo, path, ref=git_ref) + return _json({"http_status": st, "payload": payload}) + + +if __name__ == "__main__": + mcp.run() diff --git a/scripts/gitea/requirements-mcp.txt b/scripts/gitea/requirements-mcp.txt new file mode 100644 index 0000000..d516823 --- /dev/null +++ b/scripts/gitea/requirements-mcp.txt @@ -0,0 +1,2 @@ +# Nur für MCP-Server (nicht im Backend-Container nötig) +mcp>=1.2.0 diff --git a/tests/phase3_e2e_test.sql b/tests/phase3_e2e_test.sql new file mode 100644 index 0000000..4a4f3a4 --- /dev/null +++ b/tests/phase3_e2e_test.sql @@ -0,0 +1,94 @@ +-- Phase 3 E2E Test: Branching Workflow +-- Test workflow: body analysis → logic (if relevanz = "decrease") → then/else paths + +-- 1. Cleanup (use slug for lookup) +DELETE FROM workflow_executions WHERE workflow_id IN ( + SELECT id FROM workflow_definitions WHERE slug = 'phase3-e2e-branching' +); +DELETE FROM workflow_definitions WHERE slug = 'phase3-e2e-branching'; + +-- 2. Create test workflow +INSERT INTO workflow_definitions (name, slug, description, graph, active) +VALUES ( + 'Phase 3 E2E Test - Branching', + 'phase3-e2e-branching', + 'Test workflow with logic node and conditional branching', + '{ + "nodes": [ + { + "id": "start", + "type": "start", + "position": {"x": 100, "y": 100} + }, + { + "id": "body_analysis", + "type": "analysis", + "prompt_slug": "pipeline_body", + "question_augmentations": [ + { + "id": "q1", + "type": "relevanz", + "question": "Hat sich die Fettmasse relevant verändert?", + "answer_spectrum": ["increase", "stable", "decrease"], + "reasoning_required": true + } + ], + "position": {"x": 100, "y": 200} + }, + { + "id": "logic_check", + "type": "logic", + "condition": { + "expression": { + "operator": "eq", + "ref": "body_analysis.relevanz", + "value": "decrease" + }, + "then_path": "e3", + "else_path": "e4" + }, + "fallback": { + "strategy": "default_path" + }, + "position": {"x": 100, "y": 300} + }, + { + "id": "decrease_path", + "type": "analysis", + "prompt_slug": "pipeline_body", + "position": {"x": 50, "y": 400} + }, + { + "id": "not_decrease_path", + "type": "analysis", + "prompt_slug": "pipeline_body", + "position": {"x": 150, "y": 400} + }, + { + "id": "end", + "type": "end", + "position": {"x": 100, "y": 500} + } + ], + "edges": [ + {"id": "e1", "from": "start", "to": "body_analysis"}, + {"id": "e2", "from": "body_analysis", "to": "logic_check"}, + {"id": "e3", "from": "logic_check", "to": "decrease_path", "label": "then"}, + {"id": "e4", "from": "logic_check", "to": "not_decrease_path", "label": "else"}, + {"id": "e5", "from": "decrease_path", "to": "end"}, + {"id": "e6", "from": "not_decrease_path", "to": "end"} + ] + }', + true +); + +-- 3. Verify workflow was created +SELECT + id, + name, + slug, + active, + jsonb_array_length(graph->'nodes') as node_count, + jsonb_array_length(graph->'edges') as edge_count +FROM workflow_definitions +WHERE slug = 'phase3-e2e-branching'; diff --git a/tests/phase4_e2e_test.sql b/tests/phase4_e2e_test.sql new file mode 100644 index 0000000..924686f --- /dev/null +++ b/tests/phase4_e2e_test.sql @@ -0,0 +1,123 @@ +-- Phase 4 E2E Test: Branching + Join Workflow +-- Test workflow mit 2 parallelen Pfaden, die wieder zusammengeführt werden + +-- 1. Insert workflow definition +INSERT INTO workflow_definitions ( + id, + name, + description, + graph, + active, + created_by +) VALUES ( + 'phase4-join-test', + 'Phase 4 Join Test Workflow', + 'Test workflow with branching and join node', + '{ + "nodes": [ + { + "id": "start", + "type": "start", + "position": {"x": 100, "y": 100} + }, + { + "id": "body_analysis", + "type": "analysis", + "prompt_slug": "body", + "position": {"x": 100, "y": 200}, + "question_augmentations": [ + { + "id": "relevanz", + "type": "relevanz", + "question": "Ist eine Gewichtsveränderung relevant?", + "answer_spectrum": ["ja", "nein", "unklar"] + } + ] + }, + { + "id": "decision_logic", + "type": "logic", + "position": {"x": 100, "y": 300}, + "condition": { + "type": "if", + "expression": { + "operator": "eq", + "ref": "body_analysis.relevanz", + "value": "ja" + } + }, + "fallback": { + "strategy": "default_path" + } + }, + { + "id": "path_a_nutrition", + "type": "analysis", + "prompt_slug": "nutrition", + "position": {"x": 50, "y": 400}, + "question_augmentations": [ + { + "id": "prioritaet", + "type": "prioritaet", + "question": "Wie wichtig ist Ernährungsoptimierung?", + "answer_spectrum": ["hoch", "mittel", "niedrig", "unklar"] + } + ] + }, + { + "id": "path_b_activity", + "type": "analysis", + "prompt_slug": "activity", + "position": {"x": 150, "y": 400}, + "question_augmentations": [ + { + "id": "prioritaet", + "type": "prioritaet", + "question": "Wie wichtig ist Trainingsoptimierung?", + "answer_spectrum": ["hoch", "mittel", "niedrig", "unklar"] + } + ] + }, + { + "id": "join_consolidation", + "type": "join", + "position": {"x": 100, "y": 500}, + "join_strategy": "best_effort", + "skip_handling": "ignore_skipped" + }, + { + "id": "end", + "type": "end", + "position": {"x": 100, "y": 600} + } + ], + "edges": [ + {"id": "e1", "from_node": "start", "to_node": "body_analysis"}, + {"id": "e2", "from_node": "body_analysis", "to_node": "decision_logic"}, + {"id": "e3", "from_node": "decision_logic", "to_node": "path_a_nutrition", "label": "then"}, + {"id": "e4", "from_node": "decision_logic", "to_node": "path_b_activity", "label": "else"}, + {"id": "e5", "from_node": "path_a_nutrition", "to_node": "join_consolidation"}, + {"id": "e6", "from_node": "path_b_activity", "to_node": "join_consolidation"}, + {"id": "e7", "from_node": "join_consolidation", "to_node": "end"} + ] + }'::jsonb, + true, + 'test-user' +); + +-- 2. Verify workflow was created +SELECT + id, + name, + active, + jsonb_array_length(graph->'nodes') as node_count, + jsonb_array_length(graph->'edges') as edge_count +FROM workflow_definitions +WHERE id = 'phase4-join-test'; + +-- Expected result: +-- id: phase4-join-test +-- name: Phase 4 Join Test Workflow +-- active: true +-- node_count: 7 +-- edge_count: 7 diff --git a/tests/test_join_deployed.py b/tests/test_join_deployed.py new file mode 100644 index 0000000..19a8833 --- /dev/null +++ b/tests/test_join_deployed.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Quick test for deployed join_evaluator.py""" + +from join_evaluator import evaluate_join_node +from workflow_models import ( + WorkflowNode, WorkflowGraph, WorkflowEdge, + JoinStrategy, NodeStatus, NodeExecutionState +) + +# Minimal test: 2 paths → join +join_node = WorkflowNode( + id="test_join", + type="join", + join_strategy=JoinStrategy.BEST_EFFORT +) + +graph = WorkflowGraph( + nodes=[ + WorkflowNode(id="path_a", type="analysis"), + WorkflowNode(id="path_b", type="analysis"), + join_node + ], + edges=[ + WorkflowEdge(id="e1", from_node="path_a", to_node="test_join"), + WorkflowEdge(id="e2", from_node="path_b", to_node="test_join") + ] +) + +context = { + "node_results": { + "path_a": NodeExecutionState( + node_id="path_a", + status=NodeStatus.EXECUTED, + analysis_core="Analysis from path A" + ), + "path_b": NodeExecutionState( + node_id="path_b", + status=NodeStatus.EXECUTED, + analysis_core="Analysis from path B" + ) + } +} + +# Execute +result = evaluate_join_node(join_node, graph, context) + +# Verify +print(f"✅ Ready: {result.ready}") +print(f"✅ Consolidated cores: {len(result.consolidated_analysis_core)}") +print(f"✅ Executed paths: {result.metadata.get('executed_paths')}") +print(f"✅ Strategy: {result.metadata.get('join_strategy')}") + +assert result.ready is True +assert len(result.consolidated_analysis_core) == 2 +assert "path_a" in result.consolidated_analysis_core +assert "path_b" in result.consolidated_analysis_core + +print("\n🎉 Phase 4 Join Evaluator: DEPLOYED AND WORKING!") diff --git a/tests/test_join_integration.py b/tests/test_join_integration.py new file mode 100644 index 0000000..a89d03f --- /dev/null +++ b/tests/test_join_integration.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Integration test: Full workflow with join node""" + +import asyncio +import sys +from workflow_executor import execute_node +from workflow_models import ( + WorkflowNode, WorkflowGraph, WorkflowEdge, + JoinStrategy, NodeStatus, NodeExecutionState +) + +async def test_join_node_integration(): + """Test execute_join_node via execute_node dispatcher""" + + # Setup: 2 executed paths + path_a_state = NodeExecutionState( + node_id="path_a", + status=NodeStatus.EXECUTED, + analysis_core="Path A completed successfully" + ) + + path_b_state = NodeExecutionState( + node_id="path_b", + status=NodeStatus.EXECUTED, + analysis_core="Path B completed successfully" + ) + + # Join node + join_node = WorkflowNode( + id="join", + type="join", + join_strategy=JoinStrategy.WAIT_ALL + ) + + # Graph + graph = WorkflowGraph( + nodes=[ + WorkflowNode(id="path_a", type="analysis"), + WorkflowNode(id="path_b", type="analysis"), + join_node + ], + edges=[ + WorkflowEdge(id="e1", from_node="path_a", to_node="join"), + WorkflowEdge(id="e2", from_node="path_b", to_node="join") + ] + ) + + # Context with previous node results + context = { + "variables": {}, + "profile_id": "test-profile", + "node_results": { + "path_a": path_a_state, + "path_b": path_b_state + }, + "active_edges": {} + } + + # Execute join node via dispatcher + async def mock_llm(prompt, model): + return "Mock LLM response" + + result = await execute_node( + node=join_node, + context=context, + catalog={}, + graph=graph, + openrouter_call_func=mock_llm + ) + + # Verify + print(f"✅ Node executed: {result.node_id}") + print(f"✅ Status: {result.status.value}") + print(f"✅ Analysis core exists: {result.analysis_core is not None}") + + assert result.node_id == "join" + assert result.status == NodeStatus.EXECUTED + assert result.analysis_core is not None + + # Check consolidated data + import json + consolidated = json.loads(result.analysis_core) + print(f"✅ Consolidated paths: {len(consolidated)}") + assert len(consolidated) == 2 + assert "path_a" in consolidated + assert "path_b" in consolidated + + print(f"✅ Path A analysis: {consolidated['path_a'][:50]}...") + print(f"✅ Path B analysis: {consolidated['path_b'][:50]}...") + + print("\n🎉 Integration Test: JOIN NODE WORKING IN WORKFLOW EXECUTOR!") + print(" - Both paths consolidated successfully") + print(" - Analysis cores merged correctly") + print(" - Join strategy executed properly") + return True + +if __name__ == "__main__": + try: + success = asyncio.run(test_join_node_integration()) + sys.exit(0 if success else 1) + except Exception as e: + print(f"❌ Test failed: {e}") + import traceback + traceback.print_exc() + sys.exit(1)