diff --git a/.gitignore b/.gitignore index 7d263c1..d2607f9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist !.env.example *.log *.tsbuildinfo +.pnpm-store/ diff --git a/README.md b/README.md index e79c766..9f37bb4 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,3 @@ Neue Anbieter kommen als Connector unter `packages/connector-*` hinzu (`docs/con ## Tests `cd apps/api && pnpm test` – Integrationstests gegen eine **separate** Datenbank `kundencenter_test` (wird bei jedem Lauf neu erstellt). Abgedeckt: Login, Sperre, CSRF, 2FA-Pflicht, Sitzungen, Kundenanlage, Mandantentrennung, Rechte, Audit-Kette. - -## Interne Werkzeuge -`ops/night-agent.mjs` und `docs/lizenzsystem-erweiterung/` sind Werkzeuge für die Entwicklung bei FlessingLabs und für -den Betrieb einer eigenen Installation nicht nötig. diff --git a/docs/lizenzsystem-erweiterung/README.md b/docs/lizenzsystem-erweiterung/README.md deleted file mode 100644 index 62e770b..0000000 --- a/docs/lizenzsystem-erweiterung/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# Lizenzsystem-Erweiterung: Edition (Schlüssel-Präfix) und festes Ablaufdatum - -**Zweck:** Das Kundencenter soll Familytool-Stufen verkaufen (Premium, Unlimited, Lifetime, Trial). Das Familytool erkennt die Stufe am **Präfix des Lizenzschlüssels** (`PREMIUM-…`, `TRIAL-…`, `LIFETIME…`, sonst Unlimited). Das Lizenzsystem konnte bisher nur Schlüssel ohne Präfix erzeugen, jede Lizenz wäre also automatisch Unlimited gewesen. - -**Wo einspielen:** auf der Instanz, gegen die das Kundencenter und das Familytool laufen, also **licensing.flessinglabs.com** (nicht auf dem lokalen Dienst dieses Servers). Ohne den Patch verweigert das Kundencenter Bestellungen mit Edition (das Produkt bleibt Entwurf, eine Bestellung würde nie eine falsche Lizenz anlegen). - -Getestet an einer Kopie des Codes mit eigener Testdatenbank auf einem separaten Port: Präfix für alle drei Editionen, ungültiges Präfix → 422, festes Ablaufdatum, unveränderte Anlage ohne Präfix, `validate` mit Präfix-Schlüssel, Erkennung über `GET /`. - -## Ablauf (ca. 10 Minuten, kurze Unterbrechung nur beim Neustart) - -1. **Sichern** (DB und Code des Lizenzsystems auf dem Zielserver): - ```bash - mysqldump --single-transaction > licensing-vor-erweiterung.sql - cp -a /backend/app app-vor-erweiterung - ``` -2. **Vorab prüfen: startet der Dienst nach einem Neustart korrekt?** (Wir hatten am 26.09. auf dem lokalen Dienst einen Ausfall, weil das venv neuer war als der laufende Prozess. Siehe LICENSING-11.) - ```bash - /backend/venv/bin/pip list | grep -iE "bcrypt|passlib|slowapi|starlette" # bcrypt muss < 5 sein (z. B. 4.0.1) - grep -n "def validate_license" -A3 /backend/app/routers/licenses.py # Parameter muss "request: Request" heißen - ``` - Wenn `bcrypt` ≥ 5 ist oder die Schema-Parameter noch `request` heißen: **erst das beheben**, sonst fallen Login und `validate` beim Neustart aus. -3. **Datenbank erweitern** (Schlüssel werden mit Präfix länger als 36 Zeichen): - ```sql - ALTER TABLE licenses MODIFY license_key VARCHAR(64) NOT NULL; - ``` -4. **Code anpassen** (erst Trockenlauf, dann anwenden): - ```bash - python3 apply_erweiterung.py /backend # zeigt nur, was geändert würde - python3 apply_erweiterung.py /backend --apply # legt Sicherungen *.bak- an - ``` - Weicht der Code ab, bricht das Skript ohne Änderung ab und nennt die Stelle. -5. **Dienst neu starten** (z. B. `systemctl restart licensing-backend`) und prüfen: - ```bash - curl -s https://licensing.flessinglabs.com/api/ # muss "features":["key_prefix","explicit_expiry"] enthalten - ``` - Danach kurz testen: Login im Admin-Panel, eine Lizenz prüfen (`validate`) und im Familytool die Lizenzprüfung. -6. **Im Kundencenter**: Verbindungen → „Jetzt abgleichen“. Unter „Fähigkeiten“ erscheinen `license.key_prefix` und `license.expiry`. Danach lassen sich die Familytool-Produkte aktivieren. - -## Zurückrollen -Dienst stoppen, `*.bak-` zurückkopieren, Dienst starten. Die Spalte darf auf 64 Zeichen bleiben. Bereits mit Präfix angelegte Lizenzen bleiben gültig. - -## API-Änderungen im Detail -- `POST /licenses/` Body: neues optionales Feld `key_prefix` (`PREMIUM` | `TRIAL` | `LIFETIME`). Antwort enthält den Schlüssel `-`. -- `POST /licenses/`: ein angegebenes `expires_at` (Ortszeit wie bisher) wird verwendet; ohne Angabe gilt die bisherige Berechnung aus `duration_type`. -- `GET /`: Feld `features` (Liste). -- Nicht geändert: `validate`, `register`, Shop, Login, alle bestehenden Felder. - -## Bekannte Grenzen -- Ein Upgrade von Premium auf Unlimited bedeutet einen **neuen Schlüssel** (das Präfix steckt im Schlüssel). Das Kundencenter legt dann eine neue Lizenz an; die alte wird gesperrt. Eine spätere Lösung mit einem Feld `edition` im Lizenzsystem (statt Präfix) ist möglich. -- Das Ablaufdatum wird (wie im Lizenzsystem üblich) als Ortszeit gespeichert. diff --git a/docs/lizenzsystem-erweiterung/apply_erweiterung.py b/docs/lizenzsystem-erweiterung/apply_erweiterung.py deleted file mode 100644 index d96947f..0000000 --- a/docs/lizenzsystem-erweiterung/apply_erweiterung.py +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env python3 -""" -Erweiterung des Lizenzsystems um "Edition" (Schlüssel-Präfix) und festes Ablaufdatum – für das Kundencenter. - -Was der Patch tut (alles abwärtskompatibel, bestehende Clients ändern sich nicht): - 1. POST /licenses/ akzeptiert optional key_prefix: "PREMIUM" | "TRIAL" | "LIFETIME" - -> Schlüssel lautet dann "-" (das Familytool erkennt daran die Stufe; ohne Präfix = Unlimited). - 2. POST /licenses/ beachtet ein ausdrücklich angegebenes expires_at (bisher wurde es ignoriert). - 3. GET / meldet "features": ["key_prefix", "explicit_expiry"] (daran erkennt das Kundencenter die Erweiterung). - 4. models.License.license_key wird von 36 auf 64 Zeichen erweitert (DB-Änderung: siehe README, VORHER ausführen). - -Nutzung (Standard = nur prüfen, nichts ändern): - python3 apply_erweiterung.py /pfad/zu/licensing-system/backend # Trockenlauf - python3 apply_erweiterung.py /pfad/zu/licensing-system/backend --apply # anwenden (mit Sicherungskopien *.bak-) -Das Skript ist idempotent: bereits vorhandene Änderungen werden erkannt und übersprungen. -""" -import re, sys, time, py_compile, pathlib - -def main() -> int: - args = [a for a in sys.argv[1:] if not a.startswith('--')] - apply = '--apply' in sys.argv - if len(args) != 1: - print(__doc__); return 2 - root = pathlib.Path(args[0]) / 'app' - files = {n: root / p for n, p in {'models': 'models.py', 'schemas': 'schemas.py', 'licenses': 'routers/licenses.py', 'main': 'main.py'}.items()} - for n, f in files.items(): - if not f.exists(): - print(f'FEHLER: {f} nicht gefunden – falscher Pfad? (erwartet: .../licensing-system/backend)'); return 1 - src = {n: f.read_text() for n, f in files.items()} - new = dict(src); report = [] - - def step(name, key, old, repl, marker): - if marker in new[key]: - report.append(f' = {name}: bereits vorhanden'); return True - if old not in new[key]: - report.append(f' ! {name}: erwartete Stelle NICHT gefunden – bitte manuell prüfen (siehe README)'); return False - new[key] = new[key].replace(old, repl, 1); report.append(f' + {name}'); return True - - ok = True - ok &= step('models: license_key 64 Zeichen', 'models', - 'license_key = Column(String(36), unique=True, index=True, default=lambda: str(uuid.uuid4()))', - 'license_key = Column(String(64), unique=True, index=True, default=lambda: str(uuid.uuid4()))', 'String(64), unique=True') - ok &= step('schemas: key_prefix in LicenseCreate', 'schemas', - 'class LicenseCreate(LicenseBase):\n pass', - 'class LicenseCreate(LicenseBase):\n # Optionales Schlüssel-Präfix (Edition): Schlüssel lautet "-". Ohne Präfix gilt die Lizenz im Familytool als UNLIMITED.\n key_prefix: Optional[Literal["PREMIUM", "TRIAL", "LIFETIME"]] = None', - 'key_prefix: Optional[Literal') - if 'Literal' not in re.split(r'\nclass ', new['schemas'], maxsplit=1)[0]: - m = re.search(r'^from typing import (.+)$', new['schemas'], re.M) - new['schemas'] = new['schemas'].replace(m.group(0), f'from typing import Literal, {m.group(1)}', 1) if m else 'from typing import Literal\n' + new['schemas'] - report.append(' + schemas: Literal importiert') - ok &= step('licenses: festes Ablaufdatum hat Vorrang', 'licenses', - ' # Calculate expires_at based on duration_type\n expires_at = None\n if license.duration_type != schemas.LicenseDurationType.UNLIMITED:', - ' # Ein ausdrücklich angegebenes Ablaufdatum hat Vorrang; sonst wird es aus duration_type berechnet\n expires_at = license.expires_at\n if expires_at is None and license.duration_type != schemas.LicenseDurationType.UNLIMITED:', - 'expires_at = license.expires_at') - ok &= step('licenses: Schlüssel mit Präfix', 'licenses', - ' db_license = models.License(\n program_id=license.program_id,', - ' extra = {"license_key": f"{license.key_prefix}-{uuid.uuid4()}"} if license.key_prefix else {}\n db_license = models.License(\n **extra,\n program_id=license.program_id,', - 'f"{license.key_prefix}-') - if not re.search(r'^import uuid$', new['licenses'], re.M): - new['licenses'] = re.sub(r'^import datetime$', 'import datetime\nimport uuid', new['licenses'], count=1, flags=re.M); report.append(' + licenses: uuid importiert') - ok &= step('main: features in GET /', 'main', - 'return {"message": "Licensing System Backend is running!"}', - 'return {"message": "Licensing System Backend is running!", "features": ["key_prefix", "explicit_expiry"]}', '"features"') - - print('\n'.join(report)) - if not ok: - print('\nABBRUCH: Nicht alle Stellen passen (Code weicht ab). Es wurde NICHTS geändert.'); return 1 - changed = [n for n in files if new[n] != src[n]] - if not changed: - print('\nNichts zu tun – die Erweiterung ist bereits vollständig eingespielt.'); return 0 - if not apply: - print(f'\nTROCKENLAUF: würde {", ".join(files[n].name for n in changed)} ändern. Mit --apply ausführen.'); return 0 - ts = time.strftime('%Y%m%d-%H%M%S') - for n in changed: - files[n].with_name(files[n].name + f'.bak-{ts}').write_text(src[n]) - files[n].write_text(new[n]) - py_compile.compile(str(files[n]), doraise=True) - print(f'\nANGEWENDET: {", ".join(files[n].name for n in changed)} (Sicherungen: *.bak-{ts}). Jetzt Dienst neu starten (siehe README).') - return 0 - -if __name__ == '__main__': - sys.exit(main()) diff --git a/docs/night-agent.md b/docs/night-agent.md deleted file mode 100644 index 724acc0..0000000 --- a/docs/night-agent.md +++ /dev/null @@ -1,53 +0,0 @@ -# Nacht-Agent - -Arbeitet einmal pro Nacht (01:00 Uhr, `kc-night-agent.timer`) offene Plane-Tickets mit dem Label -**„agent“** ab, in den Status Backlog oder Todo. Ergebnis ist **immer** ein Branch auf der Forge -(`https://forge.flessinghome.de/flessinglabs/kundencenter`), bei Erfolg zusätzlich ein Pull -Request und Ticket-Status „In Review“. **Nichts wird automatisch gemergt oder live geschaltet** – -das bleibt bei der morgendlichen Durchsicht durch den Assistenten bzw. den Nutzer. - -## Absicherung -Der Agent läuft als Betriebssystembenutzer `kundencenter` (systemd-Dienst `kc-night-agent.service`, -zusätzlich `ProtectSystem=strict`/`ProtectHome=true`, nur `/var/lib/kundencenter/night-agent` -beschreibbar). Dieser Benutzer: -- hat kein `sudo`, kann also kein `systemctl restart` und keine Dienste anfassen, -- kann `/etc/kundencenter/*` nicht lesen (root:root, chmod 600) – keine Geheimnisse erreichbar, -- arbeitet in einer **eigenen frischen Arbeitskopie** (`/var/lib/kundencenter/night-agent/work`), - niemals in `/srv/kundencenter` selbst. - -Zusätzlich bekommt jeder Lauf feste Leitplanken als System-Prompt -(`ops/night-agent-guardrails.md`): ein Ticket = ein Branch, Tests müssen grün sein, keine -Vermutungen bei fehlenden Zugangsdaten, kein Aufräumen außerhalb des Tickets. - -## Ablauf je Ticket -1. Plane-Status → „In Progress“, Kommentar mit Branch-Namen. -2. `claude -p` mit Ticket-Text + Leitplanken, Budget-Deckel (`--max-budget-usd`, Standard 3 $), - Zeit-Deckel (Standard 45 Min./Ticket, insgesamt max. 150 Min./Nacht). -3. Unabhängig vom Agenten prüft das Skript selbst: `pnpm install && pnpm build && pnpm typecheck - && pnpm test` (Tests laufen automatisch gegen eine frische `kundencenter_test`, nie gegen die - produktive Datenbank). -4. Grün + Agent meldet `AGENT_STATUS: ok` → Branch pushen, Pull Request eröffnen, Plane-Status - „In Review“, Kommentar mit PR-Link. -5. Rot, oder Agent meldet `AGENT_STATUS: blocked`, oder keine Änderungen → Branch wird trotzdem - gepusht (nichts geht verloren), aber **kein** Pull Request; Kommentar in Plane beschreibt das - Problem. Status bleibt unverändert (kein automatisches „Wartet auf Input“, das entscheidet der - Mensch nach Lektüre des Kommentars). - -## Einrichtung -- `/etc/kundencenter/night-agent.env` (chmod 600, root): `ANTHROPIC_API_KEY` (eigener, vom - interaktiven Zugang getrennter Schlüssel – siehe unten) sowie optionale Stellschrauben - (`NIGHT_AGENT_MAX_TICKETS`, `NIGHT_AGENT_BUDGET_USD`, `NIGHT_AGENT_DEADLINE_MIN`, - `NIGHT_AGENT_TICKET_TIMEOUT_MIN`). -- `/etc/kundencenter/forge.env`: `FORGE_URL`, `FORGE_REPO` (`flessinglabs/kundencenter`), - `FORGE_TOKEN`. -- Plane-Label „agent“ muss auf ein Ticket gesetzt sein, damit der Agent es aufgreifen darf. -- Aktivieren: `systemctl enable --now kc-night-agent.timer`. Einmalig von Hand testen: - `systemctl start kc-night-agent.service` (Logs: `journalctl -u kc-night-agent` sowie - `/var/lib/kundencenter/night-agent/logs/`). - -## Bekannte Grenzen -- `kundencenter_test` wird bei jedem `pnpm test` neu angelegt (siehe `apps/api/test/global-setup.ts`) - – Agent-Lauf und ein manueller Testlauf am Tag sollten sich nicht überschneiden. -- Der Agent öffnet nie mehr als `NIGHT_AGENT_MAX_TICKETS` Pull Requests pro Nacht. -- Kein Zugriff auf Anbieter-Testzugänge (z. B. KeyHelp, KCS) – bei Tickets, die das brauchen, - meldet der Agent `blocked`. diff --git a/docs/produkte-bestellungen-vertraege.md b/docs/produkte-bestellungen-vertraege.md index 9bbe11e..606caa3 100644 --- a/docs/produkte-bestellungen-vertraege.md +++ b/docs/produkte-bestellungen-vertraege.md @@ -34,6 +34,6 @@ Jede Produktversion hat eine Preisbasis. **Brutto**: der Betrag ist der Endkunde Pakete beschreiben mehrere Produkte einer Software (Editionen, Preise, Laufzeiten, Provisionierung). Auf der Übernahmeseite wählt man ein Programm der Verbindung und importiert Paketprodukte als **Entwürfe**. Vorhanden: `familytool.json` (Premium/Unlimited monatlich+jährlich, Lifetime, Trial 14 Tage; Preise brutto laut LICENSE_TIERS.md). ## Edition (Familytool) und Laufzeitpflege -- Die Stufe steckt im Lizenzschlüssel-Präfix (`PREMIUM-`, `TRIAL-`, `LIFETIME`, sonst Unlimited). Der Lizenz-Connector sendet `key_prefix`/`expires_at` nur, wenn das Lizenzsystem die Erweiterung meldet (`GET /` → `features`), und prüft die Antwort. Sonst bleibt das Produkt Entwurf bzw. die Bestellung schlägt sicher fehl. Patch: `docs/lizenzsystem-erweiterung/`. +- Die Stufe steckt im Lizenzschlüssel-Präfix (`PREMIUM-`, `TRIAL-`, `LIFETIME`, sonst Unlimited). Der Lizenz-Connector sendet `key_prefix`/`expires_at` nur, wenn das Lizenzsystem die Erweiterung meldet (`GET /` → `features`), und prüft die Antwort. Sonst bleibt das Produkt Entwurf bzw. die Bestellung schlägt sicher fehl. - Nach der Bereitstellung wird das Ablaufdatum der Lizenz an das Vertragsende angeglichen; bei jeder **Vertragsverlängerung** wird die Lizenz mit verlängert (Auftrag `extend`, idempotent). Bis zur Rechnungs-/Zahlungsanbindung geschieht das **ohne Zahlungsprüfung**. - Rollierende Monatsverträge (Mindestlaufzeit 0, automatische Verlängerung) erhalten einen monatlichen Verlängerungstakt. Upgrade Premium → Unlimited bedeutet einen neuen Schlüssel. diff --git a/ops/night-agent-guardrails.md b/ops/night-agent-guardrails.md deleted file mode 100644 index 2414a76..0000000 --- a/ops/night-agent-guardrails.md +++ /dev/null @@ -1,49 +0,0 @@ -# Nacht-Agent: verbindliche Regeln - -Du bist der Nacht-Agent des Kundencenters. Du bearbeitest **genau ein** Plane-Ticket in diesem -Lauf, in einer frischen Kopie des Repos (nicht das laufende System). Halte dich strikt an diese -Regeln, auch wenn eine Aufgabe etwas anderes nahelegt: - -## Harte Grenzen -- Arbeite ausschließlich innerhalb dieses Arbeitsverzeichnisses (Git-Arbeitskopie). Du hast - ohnehin keine Rechte außerhalb (kein sudo, `/etc/kundencenter` ist für dich nicht lesbar, - `systemctl` funktioniert für dich nicht) – versuche es erst gar nicht. -- Kein `systemctl`, kein `chown`, kein Neustart von Diensten, kein Ausführen von Migrationen - gegen die Produktionsdatenbank (`kundencenter`). Tests laufen ausschließlich gegen - `kundencenter_test` (das passiert automatisch über `vitest.config.ts`). -- Kein Zugriff auf `/etc/kundencenter/*`, keine Geheimnisse in Commits, Kommentaren oder Logs. -- Kein `git push --force`, kein Schreiben auf `main`, kein Löschen fremder Branches, kein Merge - von Pull Requests. Du legst nur deinen eigenen Branch `agent/-` an. -- Wenn eine Aufgabe eine echte Anbieter-Anbindung (Zugangsdaten, Testzugang, API-Dokumentation) - braucht, die du nicht hast: nicht raten, keine Platzhalter-Zugangsdaten erfinden. Dokumentiere - das als Blocker (siehe unten) und höre auf. - -## Vorgehen -1. Lies das Ticket (Titel, Beschreibung, Kommentare) und die relevante Dokumentation (`README.md`, - `docs/*.md`) sowie den bestehenden Code für ähnliche Module, bevor du etwas änderst. -2. Halte dich an die bestehenden Konventionen: deutschsprachige Texte für Benutzer, Geldbeträge in - Cent, Migrationen fortlaufend nummeriert unter `migrations/`, neue API-Module als `KcModule` - unter `apps/api/src/modules/` mit Rechteprüfung (`requirePermission`) und Audit-Eintrag - (`audit(...)`) bei schreibenden Aktionen, Tests für neue Logik und neue Endpunkte. -3. Setze **nur** um, was für dieses eine Ticket nötig ist. Kein Aufräumen an anderer Stelle, keine - Umbenennungen "nebenbei". -4. Führe `pnpm -r test` aus. Bei Web-Änderungen zusätzlich `pnpm --filter @kc/web typecheck`. - Beides muss grün sein, bevor du fertig bist. -5. Wenn du fertig bist (oder nicht weiterkommst), gib **als letzten Absatz deiner Antwort genau - einen** dieser Blöcke aus, sonst nichts danach: - -``` -AGENT_STATUS: ok -ZUSAMMENFASSUNG: <2-4 Sätze auf Deutsch, was geändert wurde und was noch zu prüfen ist> -``` - -oder, wenn du das Ticket nicht abschließen konntest: - -``` -AGENT_STATUS: blocked -GRUND: -``` - -Committe deine Änderungen (auch bei `blocked`, wenn du Teilarbeit geleistet hast) mit einer -kurzen, deutschen Commit-Message. Führe keine Aktion aus, die über "Code ändern, testen, -committen" hinausgeht. diff --git a/ops/night-agent.mjs b/ops/night-agent.mjs deleted file mode 100755 index 9df4b6b..0000000 --- a/ops/night-agent.mjs +++ /dev/null @@ -1,174 +0,0 @@ -#!/usr/bin/env node -// Nacht-Agent: arbeitet nachts offene Plane-Tickets mit dem Label "agent" ab. -// Läuft als Benutzer "kundencenter" (kein sudo, kein Zugriff auf /etc/kundencenter, kein systemctl — -// das ist die eigentliche Absicherung, nicht nur diese Regeln hier). -// Ergebnis: ein Branch + Pull Request je erledigtem Ticket auf der Forge. Nichts wird live geschaltet, -// nichts wird automatisch gemerged. Siehe docs/night-agent.md. -import { spawn, spawnSync } from 'node:child_process'; -import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs'; -import { join } from 'node:path'; - -const env = process.env; -const need = (n) => { const v = env[n]; if (!v) { console.error(`Fehlt: ${n} (siehe /etc/kundencenter/night-agent.env, forge.env, plane.env)`); process.exit(2); } return v; }; -const FORGE_URL = need('FORGE_URL'), FORGE_REPO = need('FORGE_REPO'), FORGE_TOKEN = need('FORGE_TOKEN'); -const PLANE_BASE_URL = need('PLANE_BASE_URL'), PLANE_WORKSPACE = need('PLANE_WORKSPACE'), PLANE_PROJECT_ID = need('PLANE_PROJECT_ID'), PLANE_API_KEY = need('PLANE_API_KEY'); -need('ANTHROPIC_API_KEY'); - -const WORK_DIR = env.NIGHT_AGENT_WORK_DIR || '/var/lib/kundencenter/night-agent/work'; -const LOG_DIR = env.NIGHT_AGENT_LOG_DIR || '/var/lib/kundencenter/night-agent/logs'; -const MAX_TICKETS = Number(env.NIGHT_AGENT_MAX_TICKETS || 4); -const BUDGET_PER_TICKET = env.NIGHT_AGENT_BUDGET_USD || '3'; -const DEADLINE_MS = Date.now() + Number(env.NIGHT_AGENT_DEADLINE_MIN || 150) * 60_000; -const PER_TICKET_TIMEOUT_MS = Number(env.NIGHT_AGENT_TICKET_TIMEOUT_MIN || 45) * 60_000; -const LABEL_NAME = env.NIGHT_AGENT_LABEL || 'agent'; -const AGENT_ORIGIN = FORGE_URL.replace('https://', `https://night-agent:${FORGE_TOKEN}@`); - -mkdirSync(LOG_DIR, { recursive: true }); -const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-'); -const logFile = join(LOG_DIR, `${stamp}.log`); -const log = (...a) => { const line = `[${new Date().toISOString()}] ${a.join(' ')}`; console.log(line); appendFileSync(logFile, line + '\n'); }; - -function run(cmd, args, opts = {}) { - const r = spawnSync(cmd, args, { cwd: WORK_DIR, encoding: 'utf8', ...opts }); - if (r.status !== 0 && !opts.allowFail) throw new Error(`${cmd} ${args.join(' ')} fehlgeschlagen (${r.status}):\n${r.stderr || r.stdout}`); - return r; -} - -// ---- Plane ----------------------------------------------------------------- -const planeBase = `${PLANE_BASE_URL}/api/v1/workspaces/${PLANE_WORKSPACE}/projects/${PLANE_PROJECT_ID}`; -async function plane(path, opts = {}) { - const r = await fetch(`${planeBase}${path}`, { ...opts, headers: { 'x-api-key': PLANE_API_KEY, 'content-type': 'application/json', ...(opts.headers || {}) } }); - if (!r.ok) throw new Error(`Plane ${opts.method || 'GET'} ${path}: HTTP ${r.status} ${await r.text().catch(() => '')}`); - return r.status === 204 ? null : r.json(); -} -async function planeStates() { const r = await plane('/states/'); return Object.fromEntries((r.results ?? r).map((s) => [s.name, s.id])); } -async function planeLabelId(name) { const r = await plane('/labels/'); const l = (r.results ?? r).find((x) => x.name === name); return l?.id ?? null; } -async function planeComment(issueId, text) { return plane(`/issues/${issueId}/comments/`, { method: 'POST', body: JSON.stringify({ comment_html: `

${text.replace(/\n/g, '
')}

` }) }); } -async function planeSetState(issueId, stateId) { return plane(`/issues/${issueId}/`, { method: 'PATCH', body: JSON.stringify({ state: stateId }) }); } - -// ---- Forgejo ----------------------------------------------------------------- -async function forgejo(path, opts = {}) { - const r = await fetch(`${FORGE_URL}/api/v1${path}`, { ...opts, headers: { Authorization: `token ${FORGE_TOKEN}`, 'content-type': 'application/json', ...(opts.headers || {}) } }); - if (!r.ok) throw new Error(`Forge ${opts.method || 'GET'} ${path}: HTTP ${r.status} ${await r.text().catch(() => '')}`); - return r.status === 204 ? null : r.json(); -} -async function openPr(branch, title, body) { - return forgejo(`/repos/${FORGE_REPO}/pulls`, { method: 'POST', body: JSON.stringify({ head: branch, base: 'main', title, body }) }); -} - -// ---- Arbeitskopie ------------------------------------------------------------- -function prepareWorkdir() { - if (!existsSync(join(WORK_DIR, '.git'))) { - log('Klone Repo nach', WORK_DIR); - mkdirSync(WORK_DIR, { recursive: true }); - run('git', ['clone', AGENT_ORIGIN, '.']); - } else { - run('git', ['fetch', 'origin', 'main']); - run('git', ['checkout', 'main']); - run('git', ['reset', '--hard', 'origin/main']); - run('git', ['clean', '-fd']); // node_modules/dist bleiben (.gitignore) → kein Neuinstallieren jede Nacht - } -} - -const slug = (s) => s.toLowerCase().normalize('NFKD').replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40); - -function extractStatus(text) { - const m = /AGENT_STATUS:\s*(ok|blocked)\s*\n(?:ZUSAMMENFASSUNG|GRUND):\s*([\s\S]*?)\s*$/i.exec(text || ''); - if (!m) return { status: 'unknown', note: '(Der Agent hat keinen Abschlussblock ausgegeben.)' }; - return { status: m[1].toLowerCase(), note: m[2].trim() }; -} - -async function runClaudeOnTicket(issue) { - const guardrails = readFileSync(join(new URL('.', import.meta.url).pathname, 'night-agent-guardrails.md'), 'utf8'); - const prompt = [ - `Ticket #${issue.sequence_id}: ${issue.name}`, '', - (issue.description_html || '').replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/\s+/g, ' ').trim() || '(keine Beschreibung)', - ].join('\n'); - const args = ['-p', prompt, '--append-system-prompt', guardrails, '--permission-mode', 'bypassPermissions', - '--output-format', 'json', '--max-budget-usd', BUDGET_PER_TICKET, '--no-session-persistence']; - const r = spawnSync('claude', args, { cwd: WORK_DIR, encoding: 'utf8', timeout: PER_TICKET_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 }); - if (r.error) throw r.error; - let text = r.stdout; - try { text = JSON.parse(r.stdout).result ?? r.stdout; } catch { /* Text-Fallback */ } - return { text, timedOut: r.signal === 'SIGTERM' || r.status === null }; -} - -function verify() { - const steps = [['pnpm', ['install']], ['pnpm', ['build']], ['pnpm', ['typecheck']], ['pnpm', ['test']]]; - for (const [cmd, args] of steps) { - const r = spawnSync(cmd, args, { cwd: WORK_DIR, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); - if (r.status !== 0) return { ok: false, step: `${cmd} ${args.join(' ')}`, out: (r.stdout + r.stderr).slice(-4000) }; - } - return { ok: true }; -} - -async function handleTicket(issue, states) { - const branch = `agent/${issue.sequence_id}-${slug(issue.name)}`; - log('Ticket', issue.sequence_id, '-', issue.name, '→', branch); - await planeSetState(issue.id, states['In Progress']); - await planeComment(issue.id, `Nacht-Agent beginnt mit diesem Ticket (Zweig ${branch}).`); - - run('git', ['checkout', '-B', branch]); - let result; - try { result = await runClaudeOnTicket(issue); } - catch (e) { await planeComment(issue.id, `Nacht-Agent abgebrochen (technischer Fehler): ${String(e.message || e).slice(0, 500)}`); return; } - - const changed = run('git', ['status', '--porcelain'], { allowFail: true }).stdout.trim().length > 0; - if (!changed) { - await planeComment(issue.id, result.timedOut - ? `Nacht-Agent hat die Zeit überschritten, ohne Änderungen zu hinterlassen. Zusammenfassung/letzte Ausgabe:
${(result.text || '').slice(0, 1000)}` - : `Nacht-Agent hat keine Änderungen vorgenommen.
${extractStatus(result.text).note}`); - run('git', ['checkout', 'main']); run('git', ['branch', '-D', branch], { allowFail: true }); - return; - } - run('git', ['add', '-A']); - run('git', ['commit', '-m', `${issue.name} (Nacht-Agent, Ticket #${issue.sequence_id})`], { allowFail: true }); - - const { status, note } = extractStatus(result.text); - log('Status:', status); - const v = verify(); - if (!v.ok) { - run('git', ['push', '--force-with-lease', 'origin', branch]); - await planeComment(issue.id, `Nacht-Agent hat einen Zweig erstellt, aber Tests/Build schlagen fehl (${v.step}). Kein Pull Request. Zweig: ${branch}. Ende der Ausgabe:
${v.out.replace(/`);
-    return;
-  }
-  run('git', ['push', '--force-with-lease', 'origin', branch]);
-  if (status === 'blocked') {
-    await planeComment(issue.id, `Nacht-Agent kam nicht vollständig durch (Tests sind zwar grün, aber der Agent meldet einen offenen Punkt): ${note}
Zweig: ${branch} (kein Pull Request, zur Durchsicht).`); - return; - } - const pr = await openPr(branch, `${issue.name} (Ticket #${issue.sequence_id})`, - `Automatisch vom Nacht-Agenten erstellt.\n\n${note}\n\nTests, Typecheck und Build sind grün (siehe Lauf-Protokoll).\n\nPlane: ${PLANE_BASE_URL}/${PLANE_WORKSPACE}/projects/${PLANE_PROJECT_ID}/issues/${issue.id}`); - await planeSetState(issue.id, states['In Review']); - await planeComment(issue.id, `Nacht-Agent fertig. ${note}
Pull Request: ${pr.html_url}`); - log('PR eröffnet:', pr.html_url); -} - -async function main() { - log('=== Nacht-Agent startet ==='); - prepareWorkdir(); - const states = await planeStates(); - for (const need of ['In Progress', 'In Review']) if (!states[need]) throw new Error(`Plane-Status "${need}" existiert nicht`); - const labelId = await planeLabelId(LABEL_NAME); - if (!labelId) throw new Error(`Label "${LABEL_NAME}" existiert nicht in Plane`); - - const pickable = new Set([states['Backlog'], states['Todo']].filter(Boolean)); - const r = await plane(`/issues/?per_page=100`); - const all = r.results ?? r; - const candidates = all - .filter((i) => (i.labels || []).includes(labelId) && pickable.has(i.state)) - .sort((a, b) => a.sequence_id - b.sequence_id) - .slice(0, MAX_TICKETS); - - if (candidates.length === 0) { log('Keine offenen Tickets mit Label "agent" gefunden. Nichts zu tun.'); return; } - log(`${candidates.length} Ticket(s) ausgewählt:`, candidates.map((i) => '#' + i.sequence_id).join(', ')); - - for (const issue of candidates) { - if (Date.now() > DEADLINE_MS) { log('Zeitfenster erschöpft, breche für heute Nacht ab.'); break; } - try { await handleTicket(issue, states); } - catch (e) { log('Fehler bei Ticket', issue.sequence_id, ':', e.message); await planeComment(issue.id, `Nacht-Agent abgebrochen (technischer Fehler): ${String(e.message || e).slice(0, 500)}`).catch(() => {}); } - } - log('=== Nacht-Agent fertig ==='); -} - -main().catch((e) => { log('FATAL:', e.stack || e.message); process.exit(1); });