#!/usr/bin/env node // Host-side runner for the per-user job search. // // Why it lives on the host and not in the app: the app container has neither // `claude` nor `ollama` nor a docker socket. The app therefore only *queues* // runs (table suchlaeufe); this script — started by cron every few minutes — // picks them up and executes them. It replaces the two old single-user scripts // (jobsuche-cron.sh and jobsuche-remote-cron.sh), whose search criteria were // hard-coded into the prompt. // // Isolation: every run executes in its OWN throwaway Docker container // (AGENT_IMAGE), with the requesting user's dedicated home directory mounted // as /home/agent. That home IS that user's ~/.claude (memories, session // contexts, history, settings) — never shared with another user. The three // search skills are shared code baked into the image and symlinked into // ~/.claude/skills by the image entrypoint. // // Per run it: // 1. enqueues scheduled runs that are due (per user's Zeitplan), // 2. claims queued runs (status angefordert -> laeuft) and runs them in // parallel up to MAX_PARALLEL — different users have different Ollama // keys and thus separate rate limits, so parallelism is safe across users, // 3. builds the prompt from that user's Suchprofil + their Lebenslauf, // 4. starts a container that runs the headless agent with THAT user's // credentials: // - BEWERBUNG_API_KEY = the user's API token (created if missing), so // every imported Jobangebot lands in their account and passes the // same dedup/blacklist path as the rest of the app, // - ANTHROPIC_* = the user's own Ollama endpoint+key, so the search is // billed to whoever requested it, // 5. parses the agent's final "ERGEBNIS neu=… dubletten=… verworfen=…" line // and writes status + counts back, so the UI can show what happened. const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { spawn, spawnSync } = require('child_process'); const sqlite3 = require('sqlite3'); const suchprofil = require('../lib/suchprofil'); const DB_PATH = process.env.JOBSUCHE_DB || '/opt/jobbi-bewerbung/data/bewerbungen.db'; const LOG_DIR = process.env.JOBSUCHE_LOG_DIR || '/opt/jobbi-bewerbung/logs'; const API_URL = process.env.JOBSUCHE_API_URL || 'http://localhost:4327/api/v1'; const RUN_TIMEOUT_MS = (Number(process.env.JOBSUCHE_TIMEOUT) || 2700) * 1000; // A run that has been 'laeuft' for longer than this lost its process (reboot, // OOM); it would otherwise block the user's queue forever. const STALE_MS = RUN_TIMEOUT_MS + 10 * 60 * 1000; const LOG_RETENTION_DAYS = Number(process.env.JOBSUCHE_LOG_RETENTION_DAYS) || 30; // Agent-Container. Das Image wird lokal gebaut (npm run docker:build-agent), // nicht über die Registry verteilt — es läuft nur auf diesem Host. const AGENT_IMAGE = process.env.JOBSUCHE_AGENT_IMAGE || 'jobbi-bewerbung-agent:latest'; // uid/gid, als die der Container läuft und der das pro-Benutzer-Home gehört. const AGENT_UID = Number(process.env.JOBSUCHE_AGENT_UID) || 1000; const AGENT_GID = Number(process.env.JOBSUCHE_AGENT_GID) || AGENT_UID; // Pro-Benutzer-Verzeichnisse (je HOME = je ~/.claude). const AGENTS_DIR = process.env.JOBSUCHE_AGENTS_DIR || '/opt/jobbi-bewerbung/agents'; // Gleichzeitig laufende Such-Container. Verschiedene Benutzer haben getrennte // Ollama-Keys -> getrennte Rate-Limits, deshalb ist pro-Benutzer-Parallelität // sicher. Der Wert begrenzt lediglich die Host-Last. const MAX_PARALLEL = Number(process.env.JOBSUCHE_MAX_PARALLEL) || 4; const CFG = 'cfg:'; const db = new sqlite3.Database(DB_PATH); const all = (sql, p = []) => new Promise((res, rej) => db.all(sql, p, (e, r) => (e ? rej(e) : res(r)))); const get = (sql, p = []) => new Promise((res, rej) => db.get(sql, p, (e, r) => (e ? rej(e) : res(r)))); const run = (sql, p = []) => new Promise((res, rej) => db.run(sql, p, function (e) { e ? rej(e) : res(this); })); function log(msg) { console.log(`${new Date().toISOString()} ${msg}`); } async function cfgWert(userId, key) { const row = await get('SELECT value FROM app_state WHERE user_id = ? AND key = ?', [userId, CFG + key]); return row && row.value ? row.value : ''; } // The user's API token, created on first use. Without it the agent could not // import anything; making the user generate one by hand first would just be a // trap ("profile active, nothing happens"). async function ensureApiKey(userId) { const vorhanden = await cfgWert(userId, 'API_TOKEN'); if (vorhanden) return vorhanden; const token = crypto.randomBytes(32).toString('hex'); await run( 'INSERT INTO app_state (user_id, key, value) VALUES (?, ?, ?) ' + 'ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value', [userId, CFG + 'API_TOKEN', token] ); log(` API-Key für Benutzer ${userId} automatisch erzeugt.`); return token; } // The CV text the search profile is derived from. Prefer the Lebenslauf, fall // back to a Profil/Kurzprofil — the point is to give the model the user's actual // roles and technologies rather than a hard-coded list. async function profilText(userId) { const rows = await all( `SELECT typ, inhalt FROM basis_dokumente WHERE user_id = ? AND typ IN ('Lebenslauf', 'Profil/Kurzprofil') AND inhalt IS NOT NULL AND inhalt != '' ORDER BY CASE typ WHEN 'Lebenslauf' THEN 0 ELSE 1 END, id ASC`, [userId] ); return rows.map((r) => (r.inhalt || '').trim()).filter(Boolean).join('\n\n---\n\n'); } // Legt das pro-Benutzer-Home an (falls noch nicht vorhanden) und übergibt es // an den Container-Agent. Bleibt über Läufe hinweg bestehen, damit Memories // und Contexte erhalten bleiben. function ensureAgentDir(userId) { const dir = path.join(AGENTS_DIR, String(userId)); fs.mkdirSync(dir, { recursive: true }); spawnSync('chown', ['-R', `${AGENT_UID}:${AGENT_GID}`, dir]); return dir; } // 1. Queue scheduled runs that are due. async function faelligeEinreihen() { const jetzt = new Date(); const users = await all('SELECT user_id FROM suchprofil WHERE aktiv = 1'); for (const { user_id: userId } of users) { const profil = suchprofil.fromRow(await get('SELECT * FROM suchprofil WHERE user_id = ?', [userId])); if (suchprofil.validate(profil).length) continue; // unbrauchbares Profil -> kein Zeitplan-Lauf // Only scheduled runs count here: a manual test run must not swallow the // day's automatic run. const letzter = await get( "SELECT angefordert_at FROM suchlaeufe WHERE user_id = ? AND ausloeser = 'zeitplan' ORDER BY id DESC LIMIT 1", [userId] ); const letzterAt = letzter ? `${letzter.angefordert_at}Z`.replace(' ', 'T') : null; if (!suchprofil.istFaellig(profil, jetzt, letzterAt)) continue; const offen = await get( "SELECT id FROM suchlaeufe WHERE user_id = ? AND status IN ('angefordert','laeuft') LIMIT 1", [userId] ); if (offen) continue; // läuft schon etwas für diesen Benutzer await run("INSERT INTO suchlaeufe (user_id, status, ausloeser) VALUES (?, 'angefordert', 'zeitplan')", [userId]); log(`Zeitplan: Lauf für Benutzer ${userId} eingereiht.`); } } // Runs whose process died: release them so the user is not stuck. async function verwaisteAufraeumen() { const res = await run( `UPDATE suchlaeufe SET status = 'fehler', beendet_at = CURRENT_TIMESTAMP, fehler = 'Lauf abgebrochen (Container nicht mehr vorhanden).' WHERE status = 'laeuft' AND gestartet_at IS NOT NULL AND (julianday('now') - julianday(gestartet_at)) * 86400000 > ?`, [STALE_MS] ); if (res.changes) log(`${res.changes} verwaiste(n) Lauf/Läufe als Fehler markiert.`); } // Claim the oldest queued run. The conditional UPDATE is the claim: a second // runner starting concurrently gets changes = 0 and moves on. async function naechstenLaufClaimen() { const kandidat = await get("SELECT * FROM suchlaeufe WHERE status = 'angefordert' ORDER BY id ASC LIMIT 1"); if (!kandidat) return null; const res = await run( "UPDATE suchlaeufe SET status = 'laeuft', gestartet_at = CURRENT_TIMESTAMP WHERE id = ? AND status = 'angefordert'", [kandidat.id] ); if (!res.changes) return null; return get('SELECT * FROM suchlaeufe WHERE id = ?', [kandidat.id]); } async function laufAbschliessen(id, status, felder = {}) { await run( `UPDATE suchlaeufe SET status = ?, beendet_at = CURRENT_TIMESTAMP, neu = ?, dubletten = ?, verworfen = ?, fehler = ?, log_datei = ? WHERE id = ?`, [status, felder.neu ?? null, felder.dubletten ?? null, felder.verworfen ?? null, felder.fehler || null, felder.log || null, id] ); } // Start the headless agent inside a throwaway container for this user. Only the // curated env below is passed in (not process.env) so no host secrets leak. function agentStarten(prompt, envObj, logStream, containerName, agentDir) { return new Promise((resolve) => { const args = [ 'run', '--rm', '--name', containerName, // --network host: der Agent erreicht den Tracker (localhost:4327), das // Internet (WebSearch/WebFetch/Arbeitsagentur) und den Ollama-Endpoint // des Benutzers — alles über die Host-Netzwerk-Sicht. '--network', 'host', '--user', `${AGENT_UID}:${AGENT_GID}`, '-e', 'HOME=/home/agent', '-e', 'IS_SANDBOX=1', ]; for (const [k, v] of Object.entries(envObj)) args.push('-e', `${k}=${v}`); args.push( '-v', `${agentDir}:/home/agent`, '-w', '/work', AGENT_IMAGE, 'claude', '--dangerously-skip-permissions', '-p', prompt, ); const kind = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] }); let ausgabe = ''; const sammeln = (buf) => { const s = buf.toString(); ausgabe += s; logStream.write(s); }; kind.stdout.on('data', sammeln); kind.stderr.on('data', sammeln); // Beim Timeout den `docker run`-Client killen UND den Container per Name // stoppen — ein kill des Clients allein ließe den Container sonst weiterlaufen. const timer = setTimeout(() => { spawnSync('docker', ['kill', containerName], { stdio: 'ignore' }); kind.kill('SIGTERM'); setTimeout(() => kind.kill('SIGKILL'), 10000); }, RUN_TIMEOUT_MS); kind.on('error', (err) => { clearTimeout(timer); resolve({ code: -1, ausgabe, fehler: `Agent-Container nicht startbar: ${err.message}` }); }); kind.on('close', (code, signal) => { clearTimeout(timer); const abgebrochen = signal === 'SIGTERM' || signal === 'SIGKILL'; resolve({ code, ausgabe, fehler: abgebrochen ? `Zeitlimit von ${RUN_TIMEOUT_MS / 1000}s überschritten.` : null, }); }); }); } async function laufAusfuehren(lauf) { const userId = lauf.user_id; const user = await get('SELECT username FROM users WHERE id = ?', [userId]); const name = user ? user.username : `#${userId}`; const containerName = `jobbi-agent-${userId}-${lauf.id}`; log(`Lauf ${lauf.id} (Benutzer ${name}, ${lauf.ausloeser}) startet im Container ${containerName}.`); const profil = suchprofil.fromRow(await get('SELECT * FROM suchprofil WHERE user_id = ?', [userId])); const fehler = suchprofil.validate(profil); if (fehler.length) { await laufAbschliessen(lauf.id, 'fehler', { fehler: fehler.join(' ') }); log(` abgebrochen: ${fehler.join(' ')}`); return; } const ollamaKey = await cfgWert(userId, 'OLLAMA_API_KEY'); if (!ollamaKey) { const msg = 'Kein eigener Ollama-API-Schlüssel hinterlegt — der Suchlauf läuft über das KI-Kontingent des Benutzers. Bitte in den Einstellungen eintragen.'; await laufAbschliessen(lauf.id, 'fehler', { fehler: msg }); log(` abgebrochen: kein Ollama-Key für ${name}.`); return; } const apiKey = await ensureApiKey(userId); const modell = (await cfgWert(userId, 'OLLAMA_MODEL')) || 'glm-5.2:cloud'; const prompt = suchprofil.buildPrompt(profil, await profilText(userId)); fs.mkdirSync(LOG_DIR, { recursive: true }); const stamp = new Date().toISOString().replace(/[:.]/g, '-'); const logDatei = path.join(LOG_DIR, `jobsuche_${name}_${stamp}.log`); const logStream = fs.createWriteStream(logDatei, { flags: 'a' }); logStream.write(`=== Suchlauf ${lauf.id} — Benutzer ${name} (${lauf.ausloeser}) ===\n`); logStream.write(`Modus: ${profil.modus} | Städte: ${profil.staedte.join(', ') || '–'}\n`); logStream.write(`Modell: ${modell} | Container: ${containerName}\n${'-'.repeat(50)}\n`); // Nur kuratierte Env-Vars in den Container — keine Host-Secrets. const envObj = { ANTHROPIC_BASE_URL: (await cfgWert(userId, 'OLLAMA_HOST')) || 'https://ollama.com', ANTHROPIC_AUTH_TOKEN: ollamaKey, ANTHROPIC_MODEL: modell, ANTHROPIC_SMALL_FAST_MODEL: modell, BEWERBUNG_API_URL: API_URL, BEWERBUNG_API_KEY: apiKey, JOBSUCHE_MODEL: modell, }; const agentDir = ensureAgentDir(userId); const { code, ausgabe, fehler: laufFehler } = await agentStarten(prompt, envObj, logStream, containerName, agentDir); const ergebnis = suchprofil.parseErgebnis(ausgabe); logStream.write(`\n${'-'.repeat(50)}\nExit-Code: ${code}\n`); logStream.end(); if (laufFehler) { await laufAbschliessen(lauf.id, 'fehler', { fehler: laufFehler, log: logDatei }); log(` Lauf ${lauf.id} fehlgeschlagen: ${laufFehler}`); return; } if (code !== 0 && !ergebnis) { await laufAbschliessen(lauf.id, 'fehler', { fehler: `Suchlauf mit Exit-Code ${code} beendet (Details im Log).`, log: logDatei, }); log(` Lauf ${lauf.id} fehlgeschlagen (Exit ${code}).`); return; } if (!ergebnis) { // The agent finished but did not report its tally — treat as done with unknown // numbers rather than as a failure; the import itself already happened. await laufAbschliessen(lauf.id, 'fertig', { neu: 0, dubletten: 0, verworfen: 0, log: logDatei }); log(` Lauf ${lauf.id} beendet, aber ohne ERGEBNIS-Zeile.`); return; } await laufAbschliessen(lauf.id, 'fertig', { ...ergebnis, log: logDatei }); log(` Lauf ${lauf.id} fertig: ${ergebnis.neu} neu, ${ergebnis.dubletten} Dubletten, ${ergebnis.verworfen} verworfen.`); } // Führt eine Liste von Läufen mit höchstens `parallel` gleichzeitig aus. Jeder // Lauf läuft in seinem eigenen Container; verschiedene Benutzer haben getrennte // Ollama-Keys, deshalb ist pro-Benutzer-Parallelität sicher. async function runPool(items, parallel, fn) { const queue = items.slice(); const workers = Array.from({ length: Math.min(parallel, queue.length || 1) }, async () => { while (queue.length) { const item = queue.shift(); try { await fn(item); } catch (e) { log(` Lauf ${item.id} unerwartet abgebrochen: ${e && e.message ? e.message : e}`); try { await laufAbschliessen(item.id, 'fehler', { fehler: `Runner-Ausnahme: ${e && e.message ? e.message : e}` }); } catch (_) { /* Status ist dann zwar offen, aber ein Folge-Tick räumt auf. */ } } } }); await Promise.all(workers); } function alteLogsAufraeumen() { try { const grenze = Date.now() - LOG_RETENTION_DAYS * 86400000; for (const f of fs.readdirSync(LOG_DIR)) { if (!f.startsWith('jobsuche_') || !f.endsWith('.log')) continue; const p = path.join(LOG_DIR, f); if (fs.statSync(p).mtimeMs < grenze) fs.unlinkSync(p); } } catch (e) { /* Aufräumen ist best effort */ } } async function main() { if (!fs.existsSync(DB_PATH)) { console.error(`Datenbank nicht gefunden: ${DB_PATH}`); process.exit(1); } await run('PRAGMA busy_timeout = 10000'); // die App schreibt parallel await verwaisteAufraeumen(); await faelligeEinreihen(); // Alle wartenden Läufe einsammeln (pro Benutzer steht ohnehin nur einer an) // und mit begrenzter Parallelität abarbeiten. const laeufe = []; let lauf; while ((lauf = await naechstenLaufClaimen())) laeufe.push(lauf); if (!laeufe.length) { log('Nichts zu tun.'); } else { log(`${laeufe.length} Lauf/Läufe eingereiht, starte bis zu ${MAX_PARALLEL} parallel.`); await runPool(laeufe, MAX_PARALLEL, laufAusfuehren); } alteLogsAufraeumen(); db.close(); } main().catch((e) => { console.error('Runner-Fehler:', e); process.exit(1); });