Interne Entwicklungswerkzeuge entfernen

Night-Agent (ops/night-agent.mjs, Leitplanken, Doku) und das Skript zur
Lizenzsystem-Erweiterung werden für den Betrieb einer Installation nicht
gebraucht. README-Hinweis entfernt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-10-02 13:51:39 +02:00
parent fca2b9d746
commit fddaa46d7d
8 changed files with 2 additions and 413 deletions

1
.gitignore vendored
View file

@ -6,3 +6,4 @@ dist
!.env.example
*.log
*.tsbuildinfo
.pnpm-store/

View file

@ -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.

View file

@ -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 <DBNAME> > licensing-vor-erweiterung.sql
cp -a <pfad>/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
<pfad>/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 <pfad>/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 <pfad>/backend # zeigt nur, was geändert würde
python3 apply_erweiterung.py <pfad>/backend --apply # legt Sicherungen *.bak-<zeit> 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-<zeit>` 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 `<PRAEFIX>-<uuid>`.
- `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.

View file

@ -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 "<PRAEFIX>-<uuid>" (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-<zeit>)
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 "<PRAEFIX>-<uuid>". 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())

View file

@ -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`.

View file

@ -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.

View file

@ -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/<ticket>-<kurzname>` 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/<name>` 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: <was fehlt oder unklar ist – z. B. fehlender Testzugang, fehlende Entscheidung>
```
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.

View file

@ -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: `<p>${text.replace(/\n/g, '<br/>')}</p>` }) }); }
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(/&nbsp;/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 <code>${branch}</code>).`);
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:<br>${(result.text || '').slice(0, 1000)}`
: `Nacht-Agent hat keine Änderungen vorgenommen.<br>${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 <strong>Tests/Build schlagen fehl</strong> (${v.step}). Kein Pull Request. Zweig: <code>${branch}</code>. Ende der Ausgabe:<br><pre>${v.out.replace(/</g, '&lt;')}</pre>`);
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}<br>Zweig: <code>${branch}</code> (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}<br>Pull Request: <a href="${pr.html_url}">${pr.html_url}</a>`);
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); });