#!/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, '
')}
${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:${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); });