Nacht-Agent: automatische Bearbeitung von Plane-Tickets (Label agent)
This commit is contained in:
parent
4763548bfb
commit
fd34d121b1
3 changed files with 276 additions and 0 deletions
49
ops/night-agent-guardrails.md
Normal file
49
ops/night-agent-guardrails.md
Normal file
|
|
@ -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/<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.
|
||||
174
ops/night-agent.mjs
Executable file
174
ops/night-agent.mjs
Executable file
|
|
@ -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: `<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(/ /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, '<')}</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); });
|
||||
Loading…
Add table
Add a link
Reference in a new issue