diff --git a/docs/night-agent.md b/docs/night-agent.md new file mode 100644 index 0000000..724acc0 --- /dev/null +++ b/docs/night-agent.md @@ -0,0 +1,53 @@ +# 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/ops/night-agent-guardrails.md b/ops/night-agent-guardrails.md new file mode 100644 index 0000000..2414a76 --- /dev/null +++ b/ops/night-agent-guardrails.md @@ -0,0 +1,49 @@ +# 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 new file mode 100755 index 0000000..9df4b6b --- /dev/null +++ b/ops/night-agent.mjs @@ -0,0 +1,174 @@ +#!/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); });