Files
thomasandClaude ce844412e5 Jobsuche: Agent pro Benutzer in isoliertem Docker-Container
Jeder Suchlauf läuft nun in einem frischen Container pro Benutzer, dessen
pro-Benutzer-Home als ~/.claude gemountet ist — Memories und Session-Contexte
liegen damit strikt getrennt pro Benutzer. Die Such-Skills sind Shared-Code
aus dem Image und werden im Container nur nach ~/.claude/skills verlinkt.

Der Host-Runner startet pro Lauf `docker run --rm` und führt bis zu
JOBSUCHE_MAX_PARALLEL (Default 4) Läufe parallel über verschiedene Benutzer
aus (jeder hat eigenen Ollama-Key = getrennte Rate-Limits). Der alte gemeinsame
~/.claude-Pfad wird vom Runner nicht mehr beschrieben.

- source/agent/: neues Agent-Image (Dockerfile + entrypoint + drei Skills)
- scripts/jobsuche-runner.js: agentStarten als docker run, ensureAgentDir, runPool
- scripts/jobsuche-runner.sh + bin/-Kopie: Image-Guard
- package.json: docker:build-agent (lokal, ohne Registry-Push)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 15:41:22 +02:00

381 lines
16 KiB
JavaScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);
});