Files
tickte-system/docs/superpowers/plans/2026-10-17-workers-p1-dienst-fundament.md
T

193 KiB
Raw Blame History

Workers P1 – Dienst-Fundament – Umsetzungsplan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Ein eigenständiger Node-Dienst webklar-workers mit Appwrite-Sammlungen, Nie-doppelt-Register samt Startbestand, Ereignisstrom, Budget-Wächter, Claude-Hülle, Otto (Nachtplan + Morgenbericht) und JWT-geschützter HTTP-API, auf dem P2–P6 aufbauen. Architecture: Reine Module mit injizierten Abhängigkeiten (deps = { db, clock, log, claude, notify }) ohne Netz in Tests; Appwrite ist die einzige Datenhaltung (13 Sammlungen workers*, nur Server-Schlüssel), ein Tick alle 60 s mit neustartfesten Tagesmarkern treibt Otto, die API (Port 8092) liest daraus Zustand, Ereignisse und Spur. Betrieb als gehärteter Container webklar-workers hinter dem nginx von ticket.webklar.com. Tech Stack: Node 22 (ESM, node:test, node:http), node-appwrite@14.1.0, @anthropic-ai/sdk@0.135.0, nodemailer@10.1.2, Docker Compose. Spec: /home/webklar/apps/ticket.webklar.com/dev-workers/docs/superpowers/specs/2026-10-17-workers-design.md · Schnittstellen: /home/webklar/apps/ticket.webklar.com/dev-workers/docs/superpowers/plans/2026-10-17-workers-00-uebersicht.md (verbindlich)

Global Constraints

  • Alle Regeln aus Übersicht §2 gelten.
  • Orte: Repo knso/workers-dienst (privat, Benutzer knso, nicht Org WEBklar); Dev-Checkout /home/webklar/apps/workers-dev, Branch p1-fundament; Betrieb /home/webklar/apps/workers, Branch main; Agenten committen lokal und pushen nie.
  • Befehle laufen im Dev-Checkout; Tests: timeout 300 node --max-old-space-size=1024 --test test/<datei>.test.js, Gesamtlauf … --test test/*.test.js; ESM, keine Semikolons, deutsche Kommentare.
  • Abhängigkeiten in P1 exakt @anthropic-ai/sdk@0.135.0, node-appwrite@14.1.0, nodemailer@10.1.2; spätere Pläne ergänzen imapflow@2.5.2, mailparser@3.9.40, playwright-core@1.62.1 (in P1 nicht installieren).
  • Keine echten Anthropic-, SMTP- oder Appwrite-Aufrufe in Tests; DB, Uhr, SDK, Transport und fetch kommen per Parameter (deps, sdk, createTransport, fetchImpl).
  • Appwrite: Projekt 6a1058610003c5a13a05, Datenbank woms-database, Rechte [], documentSecurity=false, IDs ≤ 36 Zeichen, kein Feld data, JSON-Felder als Text, Zeiten ISO-8601 UTC.
  • Dienst: Port 8092 nur intern (Basis /api/workers), in Compose weder ports: noch Traefik-Label; Zugang ausschließlich über nginx von ticket.webklar.com.
  • Auth: X-Appwrite-JWT → Appwrite GET /account, 60 s Cache; Label mitarbeiter oder admin ist Pflicht (Kunden-Konten teilen das Appwrite-Projekt), admin für /settings.
  • Budget: 10 $/Tag, 250 $/Monat, Warnung bei 80 %, Tag gestoppt bei 100 %; mailColdPerDay ≤ 3 ohne Ausnahme, mailReplyCapPerDay ≤ 15, runsPerNight 1–2.
  • Preise je MTok: Opus 4/20, Sonnet 2/10, Haiku 0,10/0,50 (Prompt > 100 000 Tokens: 0,50/2,50); Cache-Lesen ×0,05 (Haiku ×0,1), Cache-Schreiben 5 min ×1,25, 1 h ×2; Batch: Tokens ×0,5; Websuche 0,01 $ je Suche (nie rabattiert).
  • Modelle claude-opus-5-5, claude-sonnet-5-5, claude-haiku-5-5; kein temperature/top_p/top_k; Suche (Werkzeuge) und Strukturierung (Schema) sind getrennte Aufrufe; pause_turn höchstens 5 Fortsetzungen.
  • API-Budgetantworten (HTTP 400 „You have reached your specified … API usage limits“, HTTP 429 enforced_spend_limit_reached) ⇒ BudgetStop('console'), Tag gestoppt, nie wiederholen.
  • Zeit (Europe/Berlin, Tick 60 s): nachtplan ab 22:00 (Fenster bis 05:00), nacht2 ab 01:00, bericht ab 06:30; Sendefenster 09:00–16:00 Mo–Fr, Feiertage werden nicht berücksichtigt.
  • Register: $id = sha256(kind + ":" + value) als Hex, erste 32 Zeichen; Werte werden nie gespeichert; namplz ist nur Hinweis (hint=true).
  • Alt-Passwörter (portalPasswort, portal_passwort, portalPassword) werden nie gelesen oder übernommen; der Seed liest customers/websiteProjects nur mit Query.select-Whitelist.
  • Keine Schreibzugriffe auf customers, workorders, websiteProjects, customerPortalAccess, Appwrite-Benutzer (Übersicht §2.13); der Seed liest dort nur.
  • Secrets (Schlüssel, Passwörter, Tokens) erscheinen nie in Logs, Fehlermeldungen, Ereignissen oder Skriptausgaben; Skripte drucken nur Zähler.
  • Otto macht in P1 keinen Modellaufruf: Nachtplan und Bericht sind deterministischer Code.
  • Container-Werte exakt Übersicht §9 (mem_limit 768m, memswap_limit 768m, cpus 1.0, NODE_OPTIONS=--max-old-space-size=384, …); apk add chromium kommt erst mit P4.
  • Jede Commit-Nachricht endet mit der Zeile Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.

Review Focus

  • Portalkunde mit gültigem Appwrite-JWT (Label fehlt oder customer) ruft /state oder PUT /settings → 403 {error} ohne Daten; Mitarbeiter ohne admin → PUT /settings 403, POST /pause 200. → Test in P1-T24.
  • Widerspruch von einer Freemail-Adresse (x@gmail.com) → nur diese Adresse gesperrt, nie gmail.com; von info@mueller.de → Adresse und Domain mueller.de. → Test in P1-T19.
  • Dieselbe Firma in Mischschreibweisen (Telefon +49 (0) 631 123456 / 0631/123456 / 0049 631 123456; Website [x](https://www.x.de/) / x.de/kontakt) → je ein identischer Schlüssel; Website keine, Facebook-Link, Verzeichnis → kein Domain-Schlüssel; betrieb.jimdofree.com → volle Subdomain statt jimdofree.com. → Tests in P1-T16 und P1-T17.
  • Absturz zwischen Register-Eintrag und Anlage des Interessenten, Neustart, dieselbe Suche liefert denselben Fund mit derselben prospectId (Aufrufer vergeben sie über prospectIdFor, E10) → wieder fresh; ein anderer Interessent mit gleichem Schlüssel → Treffer. → Test in P1-T18.
  • Vier gleichzeitige Claude-Aufrufe (parallele Scouts) und ein Neustart mitten in der Nacht → alle Kosten gebucht (kein verlorenes Update), Warnung/Stopp genau einmal, Nachtplan nicht doppelt. → Tests in P1-T09, P1-T11, P1-T26.

File Structure

Repo /home/webklar/apps/workers-dev/ (Zweck je Datei):

Datei Verantwortung
package.json, package-lock.json, .gitignore, .dockerignore, Dockerfile exakte Pins, Image node:22-alpine
src/config.js Env → Konfiguration; Fehler nennen nur Variablennamen
src/errors.js HttpError, ValidationError, NotFoundError mit status
src/db.js Appwrite-Hülle (node-appwrite), ConflictError, Query-Re-Export, Schreibsperre GESPERRTE_SAMMLUNGEN
src/schema.js SCHEMA (13 Sammlungen nach Übersicht §4), rowBytes
src/roles.js ROLES, FLOORS, assertCap, CapError, Raum-Helfer
src/events.js emit mit Art-/Rollen-Prüfung, strikt aufsteigendem ts
src/settings.js Standardwerte §4.1, getSettings, updateSettings mit Validierung
src/schedule.js berlinNow, runKey, isDue, inSendWindow, berlinStartOfDay
src/daily.js einziger, serialisierter Schreiber von workersDaily, Lauf-Marker
src/budget.js costOf, checkBudget, book, markStopped, formatUsd
src/notify.js interne Mails an settings.reportEmail (Berichtskonto)
src/claude.js createClaude mit ask, search, batch; BudgetStop; BOT_VERBOT_DOMAINS
src/jobs.js Auftragswarteschlange workersJobs
src/register/normalize.js Normalisierung (Port aus WOMS leadDuplicates.js + neue Funktionen)
src/register/keys.js keysFor, keyId, claimKeys, isSuppressed, suppress
src/register/seed.js reine Seed-Bausteine für Alt-Leads und Kunden
src/otto/nachtplan.js planNight: Räume/Regionen wählen, suche-Jobs, auftrag-Ereignisse
src/otto/bericht.js buildReport, sendReport (Morgenbericht)
src/view.js buildState, buildEvents, buildTrace für die API
src/api.js HTTP-Server, JWT-Prüfung, Routen
src/tick.js createTick: Job-Tabelle, Marker, Pause-Regeln
src/main.js buildDeps, start, Graceful Shutdown
scripts/setup-collections.mjs Sammlungen anlegen, --check
scripts/seed-register.mjs Startbestand, --dry-run
scripts/check-modelle.mjs manuelle Prüfung web_search-Fähigkeit
scripts/jetzt.mjs Bericht/Nachtplan sofort auslösen (Abnahme)
deploy/compose-workers.yml, deploy/nginx-workers.conf, deploy/serverpuls-apps-eintrag.json Text-Bausteine für Kenso
test/*.test.js, test/support/{helpers,fakeDb,fakeSdk,apiBoot}.js, test/fixtures/* Tests, Fakes, Fixtures

Ergänzung zur Übersicht

Diese Punkte fehlen in …-00-uebersicht.md, P1 braucht sie und legt sie so fest. Der Plan-Verantwortliche übernimmt sie in die Übersicht; P2–P6 dürfen sich darauf stützen.

  • E1 Schema: §4-Felder mit s(n), n ≥ 8000 (workersJobs.payloadJson/resultJson, workersMessages.body, workersPult.originalText/draft, workersPreviews.reviewNotes) werden mit size: 20000 angelegt (Appwrite-TEXT; sonst „Row width limit reached“, 65 535 Byte je Zeile). Das §4-Maximum steht als max im Schema und wird von den Schreibern eingehalten. Zusätzliche Indizes (Zählabfragen und Auftragssuche): workersJobs.idx_kind_status (kind+status) und workersThreads.idx_status (status).
  • E2 Rollen: ROLES = Array in Tabellenreihenfolge §3 aus { workerId, name, floor, room, model:'opus'|'sonnet'|'haiku', role, caps }; FLOORS = Array der 10 Etagen-Strings; Fixture workers-roster.json = { "floors": FLOORS, "roles": ROLES }; MODELS = { opus:'claude-opus-5-5', sonnet:'claude-sonnet-5-5', haiku:'claude-haiku-5-5' }; Zusätze SEARCH_ROOMS, workerForRoom(room), roomLabel(room), hasCap. trace.worker = ROLES-Eintrag + modelId (voller Modellname).
  • E3 deps/DB: clock = { now(): number } (ms); log(msg); notify({ subject, text }) → { sent, reason? } (neu, src/notify.js, sendet an settings.reportEmail über WORKERS_REPORT_SMTP_*); db.get → Dokument oder null; db.list/listAll → Array; neu db.count(col, queries) → number; Query kommt aus src/db.js.
  • E4 Tageszähler/Zeit: neue Datei src/daily.js ist der einzige Schreiber von workersDaily (serialisiert); book addiert counters je Worker, die Schlüssel mailsCold, mailsReply, searches, found, contacted, deals zusätzlich in die Tagesfelder; Marker in countersJson.__runs. isDue(kind, now, last): now = Ergebnis von berlinNow(clock); last = Lauf-Schlüssel (runKey: Tag, bei nachtplan/nacht2 der Tag des 22:00-Starts) bzw. ms-Zeitstempel bei Intervallarten.
  • E5 Budget: checkBudget(deps, estUsd, { reply = false }); reply:true (Gespräche: Nora, Max, Clara, Theo) ignoriert nur die Tagesgrenze (Spec 8.10), nie Monat, Pause oder Console-Stopp; ask/search nehmen reply; neu markStopped(deps, reason); costOf kennt den Haiku-Langprompt-Tarif. Folge für P4: Die Pause blockiert auch Noras Einordnung (Modellaufruf); der Posteingang wird in der Pause nur abgelegt und nach Aufhebung vor jedem Versand eingeordnet.
  • E6 Claude: createClaude({ apiKey, sdk, searchModel }); search liefert zusätzlich incomplete (Fortsetzungsgrenze erreicht) und toolErrors: [{ tool, code, url? }] (z. B. url_not_allowed bei robots.txt ⇒ Quelle auslassen); Env WORKERS_SEARCH_MODEL (optional; Fallback claude-sonnet-5-5, falls Haiku kein web_search kann).
  • E7 Register: keysFor akzeptiert für website/email/phone/name auch Arrays; claimKeys(…, { blocked:true }) setzt blocked auch auf bereits vorhandenen Schlüsseln; gleiche prospectId auf vorhandenem Schlüssel ist kein Treffer (Wiederholung nach Absturz); isSuppressed prüft zusätzlich workersKeys mit blocked=true (Kunden, 2 kontaktierte Alt-Leads); suppress/isSuppressed leiten die Domain aus einer Firmen-Adresse ab, nie aus einer Freemail-Adresse.
  • E8 Jobs: fail(deps, jobId, error, { retry = true }) (nach 3 Versuchen fehler), reclaimStale(deps, { olderThanMs }) (hängende laeuft-Jobs zurück auf offen); Otto plant nur suche-Jobs (Rita, Finn, Ali, Hanna, Sven, Lea); Kim und Pia treibt der Tick alarme (P3).
  • E9 API: trace.events neueste zuerst, trace.today.tokens = { in, out }; GET /events ohne after liefert die letzten limit Ereignisse aufsteigend; Zeitstempel (ts, lastEventTs) immer als ISO mit Z (Appwrite liefert …+00:00, ein ungeschütztes + in ?after= würde zum Leerzeichen); Fehlertexte: 401 „Nicht angemeldet.“ / „Sitzung abgelaufen.“, 403 „Kein Zugriff.“ / „Nur für Admins.“, 404 „Nicht gefunden.“, 502 „Anmeldedienst nicht erreichbar.“; connected eines Workers = sein Raum ist bruecke oder steht in settings.connectedRooms (alle Räume, nicht nur Suchräume).
  • E10 Interessenten-ID: prospectIdFor(keys) (src/register/keys.js) = 'p' + erste 30 Zeichen von keyId des ersten harten Schlüssels (kind ≠ namplz), '' ohne harten Schlüssel (dann ID.unique()). P3/P6 benutzen sie als workersProspects.$id und claimKeys-prospectId, damit eine Wiederholung nach einem Absturz dieselbe ID liefert (E7).
  • E11 Harte Sperren im Code: GESPERRTE_SAMMLUNGEN (src/db.js: customers, workorders, websiteProjects, customerPortalAccess) – db.create/update/remove darauf wirft (Übersicht §2.13); BOT_VERBOT_DOMAINS (src/claude.js) – search setzt sie immer als blocked_domains des web_fetch-Werkzeugs (Übersicht §2.11: Plattformen mit Bot-Verbot nie direkt abrufen; Treffer der Websuche bleiben erlaubt); getSettings begrenzt mailColdPerDay auf 3 und mailReplyCapPerDay auf 15 auch bei von Hand geändertem Dokument.

P1-T01: Gerüst, Testhelfer, Config

Files:

  • Create: /home/webklar/apps/workers-dev/package.json, .gitignore, src/config.js, test/support/helpers.js (zunächst fakeClock und near), package-lock.json (generiert)
  • Test: test/config.test.js

Interfaces:

  • Produces: loadConfig(env) → { appwrite:{endpoint,projectId,apiKey}, anthropicKey, searchModel, api:{port:8092}, vorschau:{port:8093,root:'/data/vorschau',baseUrl:'https://entwurf.project.webklar.com'}, smtp, imap, alertImap, reportSmtp, places, tz:'Europe/Berlin' }, ConfigError (.missing: string[]), fakeClock(iso) → { now(), set(isoOrMs), advance(ms) }, near(a, b) (wirft, wenn |a − b| ≥ 1e-9; gemeinsam für alle Kostentests).

  • Consumes: —

  • Step 1: Gerüst anlegen und Test schreiben. mkdir -p /home/webklar/apps/workers-dev && cd /home/webklar/apps/workers-dev && git init -b p1-fundament. package.json:

    { "name": "workers-dienst", "version": "0.1.0", "private": true, "type": "module", "engines": { "node": ">=22" },
      "scripts": { "test": "node --max-old-space-size=1024 --test test/*.test.js", "start": "node src/main.js" },
      "dependencies": { "@anthropic-ai/sdk": "0.135.0", "node-appwrite": "14.1.0", "nodemailer": "10.1.2" } }
    

    .gitignore: node_modules, .env, data/. Dann NODE_OPTIONS=--max-old-space-size=1024 timeout 300 npm install --ignore-scripts (erzeugt package-lock.json; prüfen: rg -m1 '"version"' node_modules/node-appwrite/package.json zeigt 14.1.0). test/support/helpers.js: fakeClock(iso = '2026-10-19T09:00:00.000Z') mit now() (ms), set(isoOrMs), advance(ms), dazu near(a, b) = assert.ok(Math.abs(a - b) < 1e-9, ${a} != ${b}). Test:

    import test from 'node:test'
    import assert from 'node:assert/strict'
    import { loadConfig, ConfigError } from '../src/config.js'
    const BASE = { APPWRITE_ENDPOINT: 'http://webklar-appwrite/v1', APPWRITE_PROJECT_ID: '6a1058610003c5a13a05', APPWRITE_API_KEY: 'aw-geheim-1', ANTHROPIC_API_KEY: 'sk-geheim-2' }
    const REPORT = { WORKERS_REPORT_SMTP_HOST: 'mail.example', WORKERS_REPORT_SMTP_PORT: '587', WORKERS_REPORT_SMTP_USER: 'u', WORKERS_REPORT_SMTP_PASS: 'p', WORKERS_REPORT_SMTP_FROM: 'a@example.de' }
    
    test('fehlende Pflichtnamen: Fehler nennt nur Namen, nie Werte', () => {
      assert.throws(() => loadConfig({ ...BASE, APPWRITE_API_KEY: '', ANTHROPIC_API_KEY: ' ' }), (e) => {
        assert.ok(e instanceof ConfigError)
        assert.deepEqual(e.missing, ['APPWRITE_API_KEY', 'ANTHROPIC_API_KEY'])
        assert.ok(!e.message.includes('geheim'))
        return true
      })
    })
    test('Festwerte; nicht gesetzte Gruppen sind null', () => {
      const c = loadConfig(BASE)
      assert.equal(c.api.port, 8092)
      assert.deepEqual(c.vorschau, { port: 8093, root: '/data/vorschau', baseUrl: 'https://entwurf.project.webklar.com' })
      assert.equal(c.tz, 'Europe/Berlin'); assert.equal(c.searchModel, ''); assert.equal(c.anthropicKey, 'sk-geheim-2')
      for (const g of ['smtp', 'imap', 'alertImap', 'reportSmtp', 'places']) assert.equal(c[g], null)
    })
    test('halb gesetzte Gruppe: Fehler nennt die fehlenden Namen der Gruppe', () => {
      assert.throws(() => loadConfig({ ...BASE, WORKERS_REPORT_SMTP_HOST: 'mail.example', WORKERS_REPORT_SMTP_USER: 'u' }),
        (e) => { assert.deepEqual(e.missing, ['WORKERS_REPORT_SMTP_PORT', 'WORKERS_REPORT_SMTP_PASS', 'WORKERS_REPORT_SMTP_FROM']); return true })
    })
    test('vollständige Gruppe: Port als Zahl; ungültiger Port nennt den Namen', () => {
      assert.deepEqual(loadConfig({ ...BASE, ...REPORT }).reportSmtp, { host: 'mail.example', port: 587, user: 'u', pass: 'p', from: 'a@example.de' })
      assert.throws(() => loadConfig({ ...BASE, ...REPORT, WORKERS_REPORT_SMTP_PORT: 'abc' }), (e) => e.missing.includes('WORKERS_REPORT_SMTP_PORT'))
    })
    
  • Step 2: Test laufen lassen, Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/config.test.js → FAIL ERR_MODULE_NOT_FOUND (src/config.js).

  • Step 3: Implementieren. src/config.js: loadConfig(env = process.env); Werte werden getrimmt, leer = fehlt. Pflicht: APPWRITE_ENDPOINT, APPWRITE_PROJECT_ID, APPWRITE_API_KEY, ANTHROPIC_API_KEY. Gruppen (Reihenfolge je Gruppe: HOST, PORT, USER, PASS, FROM): smtp = WORKERS_SMTP_* (mit FROM), imap = WORKERS_IMAP_* (ohne FROM), alertImap = WORKERS_ALERT_IMAP_* (ohne FROM), reportSmtp = WORKERS_REPORT_SMTP_* (mit FROM), places = WORKERS_PLACES_API_KEY → { apiKey }. Komplett ungesetzt → null; teilweise gesetzt oder PORT keine ganze Zahl 1–65535 → ConfigError mit .missing in genannter Reihenfolge, Pflichtnamen zuerst; ConfigError.message = Konfiguration unvollständig: + Namenliste. searchModel = WORKERS_SEARCH_MODEL oder ''.

  • Step 4: Test laufen lassen. Gleicher Befehl → PASS (4 Tests).

  • Step 5: Commit. git add package.json package-lock.json .gitignore src/config.js test/support/helpers.js test/config.test.js && git commit -m "P1-T01: Gerüst, Config, Testhelfer" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


P1-T02: Schema der 13 Sammlungen

Files:

  • Create: src/schema.js
  • Test: test/schema.test.js

Interfaces:

  • Produces: SCHEMA: [{ id, name, attributes:[{ key, type:'string'|'integer'|'float'|'boolean'|'datetime'|'enum', size?, required, array, elements?, default?, max? }], indexes:[{ key, type:'key'|'unique', attributes:[…] }] }] (Übersicht §6), TEXT_SIZE = 20000, rowBytes(collection) → number, collectionDef(id).

  • Consumes: —

  • Step 1: Test schreiben.

    import { SCHEMA, rowBytes, TEXT_SIZE, collectionDef } from '../src/schema.js'
    const IDS = ['workersSettings', 'workersProspects', 'workersKeys', 'workersSuppression', 'workersSearchLog', 'workersJobs', 'workersEvents', 'workersDaily', 'workersThreads', 'workersMessages', 'workersPult', 'workersOffers', 'workersPreviews']
    const attr = (c, k) => collectionDef(c).attributes.find((a) => a.key === k)
    
    test('13 Sammlungen in Übersichts-Reihenfolge', () => assert.deepEqual(SCHEMA.map((c) => c.id), IDS))
    test('Attribut-Schlüssel je Sammlung exakt nach §4', () => {
      const KEYS = {
        workersSettings: ['paused', 'dailyBudgetUsd', 'monthlyBudgetUsd', 'warnPct', 'mailColdPerDay', 'mailReplyCapPerDay', 'sendWindowStart', 'sendWindowEnd', 'sendWeekdaysOnly', 'runsPerNight', 'candidatesPerRun', 'activeRooms', 'connectedRooms', 'regions', 'previewGate', 'previewGateN', 'previewsSent', 'offerText', 'offerProductKey', 'pultStaleDays', 'retentionDays', 'followupDays', 'previewReminderDays', 'previewDeleteDays', 'reportEmail', 'updatedAt', 'updatedBy'],
        workersProspects: ['entrance', 'room', 'foundBy', 'source', 'sourceUrl', 'foundAt', 'name', 'contactName', 'email', 'emailSource', 'phone', 'phoneSource', 'website', 'street', 'postalCode', 'city', 'placeId', 'requestText', 'requestedAt', 'score', 'scoreReason', 'status', 'botLeadReason', 'threadId', 'previewId', 'offerId', 'customerId', 'isHealth', 'purgeAfter', 'purgedAt', 'updatedAt'],
        workersKeys: ['kind', 'prospectId', 'origin', 'blocked', 'hint', 'createdAt'],
        workersSuppression: ['kind', 'reason', 'createdAt', 'source'],
        workersSearchLog: ['workerId', 'room', 'region', 'query', 'at', 'found', 'fresh'],
        workersJobs: ['kind', 'workerId', 'status', 'prospectId', 'threadId', 'previewId', 'attempts', 'batchId', 'customId', 'payloadJson', 'resultJson', 'error', 'createdAt', 'startedAt', 'finishedAt'],
        workersEvents: ['ts', 'workerId', 'kind', 'jobId', 'prospectId', 'text', 'link', 'to', 'tokensIn', 'tokensOut', 'costUsd', 'metaJson'],
        workersDaily: ['costUsd', 'mailsCold', 'mailsReply', 'searches', 'found', 'contacted', 'deals', 'countersJson', 'warned', 'stopped'],
        workersThreads: ['prospectId', 'channel', 'platform', 'platformUrl', 'toEmail', 'subject', 'status', 'lastInboundAt', 'lastOutboundAt', 'followupDueAt', 'followupSent', 'reminderDueAt', 'reminderSent', 'createdAt'],
        workersMessages: ['threadId', 'direction', 'author', 'subject', 'body', 'messageId', 'inReplyTo', 'referencesJson', 'sentAt', 'channel', 'contactSource', 'classification', 'classificationConfidence', 'countsAsCold', 'createdAt'],
        workersPult: ['kind', 'prospectId', 'threadId', 'previewId', 'title', 'originalText', 'draft', 'sourceUrl', 'status', 'claimedBy', 'claimedAt', 'doneBy', 'doneAt', 'createdAt', 'staleAt'],
        workersOffers: ['tokenHash', 'prospectId', 'previewId', 'productKey', 'businessName', 'email', 'previewUrl', 'status', 'expiresAt', 'stripeSessionId', 'customerId', 'paidAt', 'createdAt'],
        workersPreviews: ['token', 'prospectId', 'builder', 'status', 'round', 'reviewNotes', 'filesJson', 'createdAt', 'deleteAfter', 'convertedProjectId'] }
      for (const [id, keys] of Object.entries(KEYS)) assert.deepEqual(collectionDef(id).attributes.map((a) => a.key), keys, id)
    })
    test('Regeln: id ≤ 36, kein Feld data oder $…, Index zeigt auf vorhandene Felder, Indexschlüssel ≤ 36, Pflichtfeld ohne default', () => {
      for (const c of SCHEMA) {
        assert.ok(c.id.length <= 36)
        const keys = new Set(c.attributes.map((a) => a.key))
        for (const a of c.attributes) { assert.ok(a.key !== 'data' && !a.key.startsWith('$')); assert.ok(!(a.required && a.default !== undefined), `${c.id}.${a.key}`) }
        for (const i of c.indexes) { assert.ok(i.key.length <= 36); for (const k of i.attributes) assert.ok(keys.has(k), `${c.id}.${i.key}`) }
      }
    })
    test('Spotchecks §4', () => {
      assert.equal(attr('workersProspects', 'status').elements.length, 12)
      assert.deepEqual(attr('workersJobs', 'kind').elements.length, 15)
      assert.deepEqual(collectionDef('workersOffers').indexes.find((i) => i.attributes.join() === 'tokenHash').type, 'unique')
      assert.ok(collectionDef('workersPult').indexes.some((i) => i.attributes.join() === 'status,createdAt'))
      assert.ok(collectionDef('workersJobs').indexes.some((i) => i.key === 'idx_kind_status' && i.attributes.join() === 'kind,status'))   // E1
      assert.ok(collectionDef('workersThreads').indexes.some((i) => i.key === 'idx_status' && i.attributes.join() === 'status'))       // E1
      assert.equal(attr('workersSettings', 'mailColdPerDay').type, 'integer')
      assert.equal(attr('workersSettings', 'activeRooms').array, true)
    })
    test('E1: lange Felder sind physisch TEXT, §4-Maximum bleibt als max', () => {
      for (const [c, k, max] of [['workersJobs', 'payloadJson', 16000], ['workersJobs', 'resultJson', 16000], ['workersMessages', 'body', 16000], ['workersPult', 'originalText', 8000], ['workersPult', 'draft', 8000], ['workersPreviews', 'reviewNotes', 8000]]) {
        assert.equal(attr(c, k).size, TEXT_SIZE); assert.equal(attr(c, k).max, max)
      }
      assert.equal(TEXT_SIZE, 20000)
    })
    test('rowBytes: Formel und Grenze', () => {
      assert.equal(rowBytes({ attributes: [{ key: 'a', type: 'string', size: 256 }] }), 1026)       // 4*256+2
      assert.equal(rowBytes({ attributes: [{ key: 'a', type: 'string', size: 64 }] }), 257)         // 4*64+1
      assert.equal(rowBytes({ attributes: [{ key: 'a', type: 'string', size: 20000 }] }), 20)       // TEXT
      assert.ok(rowBytes({ attributes: [{ key: 'a', type: 'string', size: 16000 }, { key: 'b', type: 'string', size: 16000 }] }) > 65535)   // darum E1
      for (const c of SCHEMA) assert.ok(rowBytes(c) <= 60000, c.id)
    })
    
  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/schema.test.js → FAIL ERR_MODULE_NOT_FOUND (src/schema.js).

  • Step 3: Implementieren. src/schema.js: Mini-DSL s(key,size,opts), i, f, b, dt, e(key,elements,opts) und idx(attrs…); SCHEMA Sammlung für Sammlung aus Übersicht §4 abschreiben (Mapping s(n)→string+size, i→integer, f→float, b→boolean, dt→datetime, e[…]→enum+elements, []→array:true, *→required:true, sonst required:false; Reihenfolge wie §4; Indizes aus §4 mit Schlüssel idx_<attr>_<attr>, tokenHash als unique; Zusatzindizes aus E1 mit den Schlüsseln idx_kind_status und idx_status). name = Sammlungs-ID. Felder aus E1 mit size: TEXT_SIZE und max. rowBytes(c): je Attribut Array → 20; string > 16381 → 20, sonst size*4 + (size > 255 ? 2 : 1); enum → längstes Element*4 + 1; integer/float/datetime → 8; boolean → 1; Summe.

  • Step 4: Test laufen lassen. Gleicher Befehl → PASS (6 Tests).

  • Step 5: Commit. git add src/schema.js test/schema.test.js && git commit -m "P1-T02: Schema der 13 Workers-Sammlungen" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


P1-T03: Setup-Skript für die Sammlungen

Files:

  • Create: scripts/setup-collections.mjs
  • Test: test/setupCollections.test.js

Interfaces:

  • Consumes: SCHEMA (T02).

  • Produces: main({ check, client, log, schema = SCHEMA }) → { missingCollections:[id], missingAttributes:[{collection,key}], missingIndexes:[{collection,key}] }; exitCodeFor(result, check) → 0|1; attributeCall(collectionId, attr) → [methodName, args] (SDK-Aufruf, args beginnt mit 'woms-database'); createSetupClient({ endpoint, projectId, apiKey }, { sdk }) → { collectionExists(id), listAttributes(id), listIndexes(id), createCollection(id, name), createAttribute(id, attr), waitForAttribute(id, key), createIndex(id, index) }.

  • Step 1: Test schreiben. Fake-Client im Test: Objekt existing[id] = { attrs:[Schlüssel], idx:[Schlüssel] }, protokolliert schreibende Aufrufe in calls.

    import { main, exitCodeFor, attributeCall, createSetupClient } from '../scripts/setup-collections.mjs'
    import { SCHEMA } from '../src/schema.js'
    function fakeClient(existing = {}) {
      const calls = []
      const st = structuredClone(existing)   // { id: { attrs: [keys], idx: [keys] } }
      return { calls, st,
        collectionExists: async (id) => id in st,
        listAttributes: async (id) => (st[id]?.attrs ?? []).map((key) => ({ key, status: 'available' })),
        listIndexes: async (id) => (st[id]?.idx ?? []).map((key) => ({ key })),
        createCollection: async (id, name) => { calls.push(['createCollection', id, name]); st[id] = { attrs: [], idx: [] } },
        createAttribute: async (id, a) => { calls.push(['createAttribute', id, a.key]); st[id].attrs.push(a.key) },
        waitForAttribute: async (id, key) => { calls.push(['wait', id, key]) },
        createIndex: async (id, i) => { calls.push(['createIndex', id, i.key]); st[id].idx.push(i.key) } }
    }
    test('--check meldet alles als fehlend, ändert nichts, Exit 1', async () => {
      const c = fakeClient(); const lines = []
      const r = await main({ check: true, client: c, log: (l) => lines.push(l) })
      assert.equal(r.missingCollections.length, 13); assert.equal(c.calls.length, 0)
      assert.equal(exitCodeFor(r, true), 1); assert.ok(lines.some((l) => l.includes('workersJobs') && l.includes('FEHLT')))
    })
    test('Lauf legt an: Reihenfolge Sammlung → Attribute → warten → Indizes; zweiter Lauf und --check sind leer', async () => {
      const c = fakeClient(); await main({ check: false, client: c, log: () => {} })
      const ops = c.calls.map((x) => x[0]); assert.equal(ops.filter((o) => o === 'createCollection').length, 13)
      const iJobs = c.calls.findIndex((x) => x[0] === 'createIndex' && x[1] === 'workersJobs')
      assert.ok(c.calls.slice(0, iJobs).some((x) => x[0] === 'wait' && x[1] === 'workersJobs'))
      const n = c.calls.length; await main({ check: false, client: c, log: () => {} }); assert.equal(c.calls.length, n)
      assert.equal(exitCodeFor(await main({ check: true, client: c, log: () => {} }), true), 0)
    })
    test('fehlendes Attribut in vorhandener Sammlung wird nachgezogen, sonst nichts', async () => {
      const full = Object.fromEntries(SCHEMA.map((s) => [s.id, { attrs: s.attributes.map((a) => a.key), idx: s.indexes.map((i) => i.key) }]))
      full.workersDaily.attrs = full.workersDaily.attrs.filter((k) => k !== 'stopped')
      const c = fakeClient(full); await main({ check: false, client: c, log: () => {} })
      assert.deepEqual(c.calls.filter((x) => x[0] === 'createAttribute'), [['createAttribute', 'workersDaily', 'stopped']])
    })
    test('attributeCall: Abbildung auf node-appwrite 14 (Positionsargumente)', () => {
      assert.deepEqual(attributeCall('c', { key: 'a', type: 'string', size: 64, required: true }), ['createStringAttribute', ['woms-database', 'c', 'a', 64, true, undefined, false]])
      assert.deepEqual(attributeCall('c', { key: 'a', type: 'enum', elements: ['x', 'y'], required: false }), ['createEnumAttribute', ['woms-database', 'c', 'a', ['x', 'y'], false, undefined, false]])
      assert.deepEqual(attributeCall('c', { key: 'a', type: 'string', size: 32, array: true }), ['createStringAttribute', ['woms-database', 'c', 'a', 32, false, undefined, true]])
      assert.equal(attributeCall('c', { key: 'a', type: 'integer' })[0], 'createIntegerAttribute')
      assert.equal(attributeCall('c', { key: 'a', type: 'float' })[0], 'createFloatAttribute')
      assert.equal(attributeCall('c', { key: 'a', type: 'boolean' })[0], 'createBooleanAttribute')
      assert.equal(attributeCall('c', { key: 'a', type: 'datetime' })[0], 'createDatetimeAttribute')
    })
    test('echter Client fragt Attribute/Indizes mit limit(200) (Standard wären 25) und legt Sammlungen ohne Rechte an', async () => {
      const calls = []; const page = { attributes: [{ key: 'a', status: 'available' }], indexes: [{ key: 'i' }] }
      class Databases { async listAttributes(...a) { calls.push(['listAttributes', ...a]); return page } async listIndexes(...a) { calls.push(['listIndexes', ...a]); return page } async createCollection(...a) { calls.push(['createCollection', ...a]) } }
      class Client { setEndpoint() { return this } setProject() { return this } setKey() { return this } }
      const c = createSetupClient({ endpoint: 'e', projectId: 'p', apiKey: 'k' }, { sdk: { Client, Databases } })
      await c.listAttributes('workersJobs'); await c.listIndexes('workersJobs'); await c.createCollection('workersJobs', 'workersJobs')
      assert.deepEqual(calls, [['listAttributes', 'woms-database', 'workersJobs', [Query.limit(200)]], ['listIndexes', 'woms-database', 'workersJobs', [Query.limit(200)]], ['createCollection', 'woms-database', 'workersJobs', 'workersJobs', [], false, true]])
    })
    

    (Import im Test: import { Query } from 'node-appwrite'; src/db.js entsteht erst in T04.)

  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/setupCollections.test.js → FAIL ERR_MODULE_NOT_FOUND (scripts/setup-collections.mjs).

  • Step 3: Implementieren. main: erst alles prüfen (Sammlung, Attribute, Indizes), Zeilen + Collection <id> / = <id>: n Attribute, m Indizes vorhanden / - <id>.<key> FEHLT (Muster /home/webklar/apps/kundenbereich.webklar.com/build/scripts/unterkunft-setup.mjs; fehlt die Sammlung, druckt --check - Collection <id> FEHLT und danach je Attribut/Index eine FEHLT-Zeile); mit check hier zurückkehren (Schlusszeile Alles vorhanden. bzw. <n> Eintrag/Einträge fehlen.); sonst je Sammlung createCollection(id, id), fehlende Attribute anlegen, danach für jedes neue Attribut waitForAttribute, erst dann fehlende Indizes; Schlusszeile Fertig.. attributeCall nach den Signaturen in node_modules/node-appwrite/dist/services/databases.d.ts (string: (db,col,key,size,required,default,array); enum: (db,col,key,elements,required,default,array); integer/float: (db,col,key,required,min,max,default,array); boolean/datetime: (db,col,key,required,default,array)); required/array default false. createSetupClient mit sdk.Client/Databases, listAttributes/listIndexes mit Query.limit(200), createCollection(DB, id, name, [], false, true), waitForAttribute pollt getAttribute jede Sekunde bis available (60 s, failed wirft). CLI (nur wenn direkt gestartet): --check; Env APPWRITE_ENDPOINT (Standard https://appwrite.webklar.com/v1), APPWRITE_PROJECT_ID (Standard 6a1058610003c5a13a05), APPWRITE_API_KEY (fehlt → Meldung mit dem Namen, Exit 2); Exit exitCodeFor.

  • Step 4: Test laufen lassen. Gleicher Befehl → PASS (5 Tests).

  • Step 5: Commit. git add scripts/setup-collections.mjs test/setupCollections.test.js && git commit -m "P1-T03: Setup-Skript mit --check" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


P1-T04: Appwrite-Hülle db.js und schema-prüfende Fake-DB

Files:

  • Create: src/db.js, test/support/fakeDb.js
  • Modify: test/support/helpers.js (ergänzt makeDeps)
  • Test: test/db.test.js, test/fakeDb.test.js

Interfaces:

  • Produces: createDb({ endpoint, projectId, apiKey }, { sdk }) → { get(col,id) → doc|null, list(col,queries=[]) → doc[], listAll(col,queries=[]) → doc[], count(col,queries=[]) → number, create(col,data,id?) → doc, update(col,id,data) → doc, remove(col,id) } (Übersicht §6 + E3); ConflictError (.code = 409); DATABASE_ID = 'woms-database'; Query (Re-Export); GESPERRTE_SAMMLUNGEN = ['customers','workorders','websiteProjects','customerPortalAccess'] (E11): create/update/remove darauf werfen Error('Schreiben in <col> ist für Workers gesperrt') ohne SDK-Aufruf, Lesen bleibt erlaubt. createFakeDb({ schema = SCHEMA, clock, loose = ['customers','workorders','websiteProjects','customerPortalAccess'] }) mit derselben Schnittstelle (inkl. Schreibsperre; nur seed darf in lose Sammlungen schreiben) plus seed(col, over = {}, id?) → doc (füllt Pflichtfelder mit Typ-Standardwerten), all(col) → doc[], calls = Array { op:'get'|'list'|'listAll'|'count'|'create'|'update'|'remove', col, id?, queries? } (listAll protokolliert nur den eigenen Aufruf, nicht die Seiten). makeDeps({ now, …over }) → { db, clock, log }.

  • sdk = { Client, Databases, ID } (Standard: import * as sdk from 'node-appwrite').

  • Step 1: Tests schreiben. db.test.js (Fake-SDK: mkSdk(fns, calls) baut { Client, ID, Databases }, jede Methode aus fns protokolliert ihre Argumente in calls):

    const mkSdk = (fns, calls = []) => ({ Client: class { setEndpoint() { return this } setProject() { return this } setKey() { return this } }, ID: { unique: () => 'uniq1' },
      Databases: class { constructor() { for (const [k, f] of Object.entries(fns)) this[k] = async (...a) => { calls.push([k, ...a]); return f(...a) } } } })
    const err = (code) => Object.assign(new Error('x'), { code })
    const cfg = { endpoint: 'e', projectId: 'p', apiKey: 'k' }
    test('create reicht Datenbank, Sammlung, ID und Daten durch; ohne ID ID.unique(); 409 → ConflictError, sonst unverändert', async () => {
      const calls = []; const db = createDb(cfg, { sdk: mkSdk({ createDocument: async (d, c, id, data) => { if (data.boom) throw err(data.boom); return { $id: id, ...data } } }, calls) })
      assert.deepEqual(await db.create('workersKeys', { a: 1 }, 'x'), { $id: 'x', a: 1 }); assert.deepEqual(calls[0], ['createDocument', DATABASE_ID, 'workersKeys', 'x', { a: 1 }])
      assert.equal((await db.create('workersKeys', { a: 1 })).$id, 'uniq1')
      await assert.rejects(db.create('workersKeys', { boom: 409 }, 'x'), (e) => e instanceof ConflictError && e.code === 409)
      const e500 = await db.create('workersKeys', { boom: 500 }, 'x').catch((e) => e); assert.ok(!(e500 instanceof ConflictError)); assert.equal(e500.code, 500)
    })
    test('get: gefunden → Dokument; 404 → null; anderer Fehler wirft', async () => {
      const db = createDb(cfg, { sdk: mkSdk({ getDocument: async (d, c, id) => { if (id === 'weg') throw err(404); if (id === 'kaputt') throw err(500); return { $id: id } } }) })
      assert.deepEqual(await db.get('c', 'a'), { $id: 'a' }); assert.equal(await db.get('c', 'weg'), null); await assert.rejects(db.get('c', 'kaputt'), (e) => e.code === 500)
    })
    test('list liefert documents; count liefert total mit Query.limit(1)', async () => {
      const calls = []; const db = createDb(cfg, { sdk: mkSdk({ listDocuments: async () => ({ total: 7, documents: [{ $id: 'a' }] }) }, calls) })
      assert.deepEqual(await db.list('c', [Query.equal('s', ['x'])]), [{ $id: 'a' }]); assert.equal(await db.count('c', [Query.equal('s', ['x'])]), 7)
      assert.deepEqual(calls[1], ['listDocuments', DATABASE_ID, 'c', [Query.equal('s', ['x']), Query.limit(1)]])
    })
    test('listAll holt 250 Dokumente in 3 Seiten à 100 (limit + cursorAfter); eigene limit-Query wirft', async () => {
      const all = Array.from({ length: 250 }, (_, i) => ({ $id: `d${i}` })); const calls = []
      const db = createDb(cfg, { sdk: mkSdk({ listDocuments: async (d, c, qs) => { const after = qs.map((q) => JSON.parse(q)).find((q) => q.method === 'cursorAfter')?.values[0]; const start = after ? Number(after.slice(1)) + 1 : 0; return { total: 250, documents: all.slice(start, start + 100) } } }, calls) })
      assert.equal((await db.listAll('c')).length, 250); assert.equal(calls.length, 3); assert.ok(calls[1][3].includes(Query.limit(100))); assert.ok(calls[1][3].includes(Query.cursorAfter('d99')))
      await assert.rejects(db.listAll('c', [Query.limit(5)]), /limit/)
    })
    test('update und remove delegieren', async () => {
      const calls = []; const db = createDb(cfg, { sdk: mkSdk({ updateDocument: async (d, c, id, data) => ({ $id: id, ...data }), deleteDocument: async () => ({}) }, calls) })
      assert.deepEqual(await db.update('c', 'a', { x: 1 }), { $id: 'a', x: 1 }); await db.remove('c', 'a'); assert.deepEqual(calls.map((c) => c[0]), ['updateDocument', 'deleteDocument'])
    })
    test('Kundensammlungen sind für Schreibzugriffe gesperrt (Übersicht §2.13), Lesen geht; kein SDK-Schreibaufruf', async () => {
      const calls = []; const db = createDb(cfg, { sdk: mkSdk({ listDocuments: async () => ({ total: 0, documents: [] }), createDocument: async () => ({}), updateDocument: async () => ({}), deleteDocument: async () => ({}) }, calls) })
      assert.deepEqual(GESPERRTE_SAMMLUNGEN, ['customers', 'workorders', 'websiteProjects', 'customerPortalAccess'])
      for (const c of GESPERRTE_SAMMLUNGEN) { await assert.rejects(db.create(c, {}), /gesperrt/); await assert.rejects(db.update(c, 'x', {}), /gesperrt/); await assert.rejects(db.remove(c, 'x'), /gesperrt/); await db.list(c) }
      assert.ok(calls.every((c) => c[0] === 'listDocuments'))
    })
    

    fakeDb.test.js (seed ist synchron):

    const ISO = '2026-10-19T09:00:00.000Z'
    const OK = { kind: 'dom', origin: 'workers', blocked: false, hint: false, createdAt: ISO }
    test('prüft Dokumente gegen SCHEMA wie Appwrite', async () => {
      const db = createFakeDb()
      await assert.rejects(db.create('workersKeys', { ...OK, nope: 1 }, 'a'), /Unknown attribute/)
      await assert.rejects(db.create('workersKeys', { ...OK, kind: 'xx' }, 'a'), /enum/i)
      await assert.rejects(db.create('workersKeys', { kind: 'dom' }, 'a'), /required/i)
      await assert.rejects(db.create('workersEvents', { ts: ISO, workerId: 'otto', kind: 'leerlauf', text: 'x'.repeat(301) }, 'e'), /size|length/i)
      await db.create('workersKeys', OK, 'a'); await assert.rejects(db.create('workersKeys', OK, 'a'), ConflictError)
    })
    test('Länge nach max statt size: payloadJson mit 16001 Zeichen wird abgelehnt, 16000 nicht', async () => {
      const db = createFakeDb(); const base = { kind: 'suche', workerId: 'ali', status: 'offen', attempts: 0, createdAt: ISO }
      await assert.rejects(db.create('workersJobs', { ...base, payloadJson: 'x'.repeat(16001) }), /size|length/i); await db.create('workersJobs', { ...base, payloadJson: 'x'.repeat(16000) })
    })
    test('Standard-Limit 25 wie Appwrite; Query.limit(100) und listAll holen alle', async () => {
      const db = createFakeDb(); for (let i = 0; i < 30; i++) db.seed('workersEvents', { ts: ISO, workerId: 'otto', kind: 'leerlauf', text: String(i) })
      assert.equal((await db.list('workersEvents')).length, 25); assert.equal((await db.list('workersEvents', [Query.limit(100)])).length, 30); assert.equal((await db.listAll('workersEvents')).length, 30)
    })
    test('Queries: equal (mehrere Werte = ODER), greaterThan, orderDesc, offset, cursorAfter, select', async () => {
      const db = createFakeDb(); for (const [i, k] of ['fund', 'doppelt', 'fund'].entries()) db.seed('workersEvents', { ts: `2026-10-19T09:00:0${i}.000Z`, workerId: 'otto', kind: k, text: `t${i}` }, `e${i}`)
      const ids = async (qs) => (await db.list('workersEvents', qs)).map((d) => d.$id)
      assert.deepEqual(await ids([Query.equal('kind', ['fund', 'doppelt'])]), ['e0', 'e1', 'e2']); assert.deepEqual(await ids([Query.equal('kind', ['doppelt'])]), ['e1'])
      assert.deepEqual(await ids([Query.greaterThan('ts', '2026-10-19T09:00:00.000Z'), Query.orderDesc('ts')]), ['e2', 'e1']); assert.deepEqual(await ids([Query.orderAsc('ts'), Query.offset(1)]), ['e1', 'e2'])
      assert.deepEqual(await ids([Query.orderAsc('ts'), Query.cursorAfter('e0'), Query.limit(1)]), ['e1'])
      assert.deepEqual(Object.keys((await db.list('workersEvents', [Query.select(['kind'])]))[0]).sort(), ['$id', 'kind'])
    })
    test('unbekannte Query-Methode wirft', async () => assert.rejects(createFakeDb().list('workersEvents', [Query.search('text', 'x')]), /nicht unterstützt/))
    test('unique-Index (tokenHash) → ConflictError; unbekannte Sammlung → code 404; lose Sammlung prüft nichts', async () => {
      const db = createFakeDb(); db.seed('workersOffers', { tokenHash: 'h1' }); assert.throws(() => db.seed('workersOffers', { tokenHash: 'h1' }), ConflictError)
      await assert.rejects(db.get('nirgends', 'x'), (e) => e.code === 404); db.seed('customers', { name: 'Freitext', irgendwas: 1 }, 'c1'); assert.equal((await db.get('customers', 'c1')).irgendwas, 1)
      for (const op of [() => db.create('customers', { name: 'x' }), () => db.update('customers', 'c1', { name: 'y' }), () => db.remove('customers', 'c1')]) await assert.rejects(op(), /gesperrt/)   // Schreibsperre auch im Fake (§2.13)
    })
    test('$-Schlüssel in data werden abgelehnt (Appwrite verträgt kein fremdes $id); datetime darf einen Offset tragen', async () => {
      const db = createFakeDb(); await db.create('workersKeys', OK, 'a')
      await assert.rejects(db.update('workersKeys', 'a', { $id: 'zzz' }), /Unknown attribute/); await assert.rejects(db.update('workersKeys', 'a', { createdAt: 'gestern' }), /createdAt/)
      await db.update('workersKeys', 'a', { createdAt: '2026-10-19T09:00:00.000+00:00' })
    })
    
  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/db.test.js test/fakeDb.test.js → FAIL ERR_MODULE_NOT_FOUND.

  • Step 3: Implementieren. db.js: new sdk.Client().setEndpoint().setProject().setKey(), new sdk.Databases(client); create(col, data, id = sdk.ID.unique()); Fehler mit code === 409 → new ConflictError(...) (Ursache als cause); get fängt nur 404; listAll hängt selbst Query.limit(100) und Query.cursorAfter(lastId) an, bis eine Seite < 100 liefert (Sicherung: höchstens 1000 Seiten), eigene limit/cursor-Queries → Fehler; count = total einer limit(1)-Abfrage. create/update/remove prüfen zuerst GESPERRTE_SAMMLUNGEN (Fehlertext Schreiben in <col> ist für Workers gesperrt). Kein Retry in dieser Schicht. fakeDb.js: Dokumente in Maps; jeder Aufruf wird in calls protokolliert (Form siehe Interfaces); Queries per JSON.parse der SDK-Query-Strings; $id (ohne ID fake000001…), $createdAt/$updatedAt aus clock; Prüfung gegen SCHEMA (unbekannte Felder – auch jeder $…-Schlüssel in data, Typen, datetime = von Date.parse lesbarer ISO-String, Offset erlaubt, Enum, Länge nach max ?? size, Pflichtfelder nur bei create, unique-Indizes → ConflictError; Meldungen nennen Feldnamen und Art: Unknown attribute: <feld>, <feld>: enum …, <feld>: required, <feld>: size/length …, <feld>: ungültiger Wert); Standard-Limit 25; nicht unterstützte Query-Methoden werfen Query-Methode nicht unterstützt: <name>; listAll ruft die eigene list-Paginierung. helpers.js: makeDeps({ now = '2026-10-19T09:00:00.000Z', ...over }).

  • Step 4: Tests laufen lassen. Gleicher Befehl → PASS.

  • Step 5: Commit. git add src/db.js test/support/fakeDb.js test/support/helpers.js test/db.test.js test/fakeDb.test.js && git commit -m "P1-T04: db.js mit ConflictError und schema-prüfende Fake-DB" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


P1-T05: Rollen-Register mit Werkzeug-Sperre

Files:

  • Create: src/roles.js, test/fixtures/workers-roster.json
  • Test: test/roles.test.js

Interfaces:

  • Produces: ROLES, FLOORS, MODELS, role(workerId), assertCap(workerId, cap) (wirft CapError), CapError, hasCap(workerId, cap) → boolean, SEARCH_ROOMS, workerForRoom(room) → workerId|null, roomLabel(room) → string (Übersicht §3, §6, E2).

  • Step 1: Fixture und Test schreiben. test/fixtures/workers-roster.json = { "floors": [10 Etagen aus §3], "roles": [19 Objekte { workerId, name, floor, room, model, role, caps }in Tabellenreihenfolge;role = Zelle „Einzige Rolle“ wörtlich] } (WOMS bekommt später dieselbe Datei). Test:

    const roster = JSON.parse(readFileSync(new URL('./fixtures/workers-roster.json', import.meta.url), 'utf8'))
    test('ROLES/FLOORS entsprechen der gemeinsamen Fixture (Spiegel in WOMS)', () => assert.deepEqual({ floors: FLOORS, roles: ROLES }, roster))
    test('19 Rollen, 10 Etagen in Reihenfolge', () => {
      assert.equal(ROLES.length, 19)
      assert.deepEqual(FLOORS, ['bruecke', 'anfragen', 'ohne_website', 'bewertung', 'gespraeche', 'werkstatt', 'pruefung', 'freigabe', 'tresor', 'keller'])
      assert.deepEqual(MODELS, { opus: 'claude-opus-5-5', sonnet: 'claude-sonnet-5-5', haiku: 'claude-haiku-5-5' })
    })
    test('Besetzung exakt nach Übersicht §3 (workerId, Etage, Raum, Modell)', () => assert.deepEqual(ROLES.map((r) => [r.workerId, r.floor, r.room, r.model]), [
      ['otto', 'bruecke', 'bruecke', 'opus'], ['rita', 'anfragen', 'netz', 'haiku'], ['kim', 'anfragen', 'kleinanzeigen', 'haiku'], ['pia', 'anfragen', 'portale', 'haiku'], ['finn', 'anfragen', 'foren', 'haiku'],
      ['ali', 'ohne_website', 'aerzte', 'haiku'], ['hanna', 'ohne_website', 'handwerk', 'haiku'], ['sven', 'ohne_website', 'selbst', 'haiku'], ['lea', 'ohne_website', 'laden', 'haiku'], ['bea', 'bewertung', 'waage', 'sonnet'],
      ['max', 'gespraeche', 'mail', 'sonnet'], ['clara', 'gespraeche', 'forum', 'sonnet'], ['nora', 'gespraeche', 'posteingang', 'haiku'], ['theo', 'gespraeche', 'abschluss', 'sonnet'],
      ['bob', 'werkstatt', 'tisch1', 'opus'], ['bianca', 'werkstatt', 'tisch2', 'opus'], ['ben', 'werkstatt', 'tisch3', 'opus'], ['paul', 'pruefung', 'pruefung', 'sonnet'], ['arne', 'keller', 'archiv', 'haiku']]))
    test('Fähigkeiten sind getrennt: send_mail nur Max, pult nur Clara und Theo', () => {
      assert.deepEqual(ROLES.filter((r) => r.caps.includes('send_mail')).map((r) => r.workerId), ['max'])
      assert.deepEqual(ROLES.filter((r) => r.caps.includes('pult')).map((r) => r.workerId), ['clara', 'theo'])
      assert.deepEqual(role('ali').caps, ['web_search', 'web_fetch', 'places', 'osm']); assert.deepEqual(role('otto').caps, ['plan', 'report'])
    })
    test('assertCap: erlaubt → still; verboten/unbekannt → CapError mit workerId und cap', () => {
      assertCap('max', 'send_mail')
      assert.throws(() => assertCap('kim', 'send_mail'), (e) => e instanceof CapError && /kim/.test(e.message) && /send_mail/.test(e.message))
      assert.throws(() => assertCap('niemand', 'plan'), CapError)
      assert.equal(hasCap('rita', 'web_fetch'), true); assert.equal(hasCap('kim', 'web_search'), false)
    })
    test('Räume', () => {
      assert.deepEqual(SEARCH_ROOMS, ['netz', 'kleinanzeigen', 'portale', 'foren', 'aerzte', 'handwerk', 'selbst', 'laden'])
      assert.equal(workerForRoom('handwerk'), 'hanna'); assert.equal(workerForRoom('gibtsnicht'), null)
      assert.equal(roomLabel('aerzte'), 'Ärzte & Praxen'); assert.equal(roomLabel('foren'), 'Foren & Fragen'); assert.equal(roomLabel('laden'), 'Läden & Gastro')
    })
    
  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/roles.test.js → FAIL ERR_MODULE_NOT_FOUND.

  • Step 3: Implementieren. ROLES (eingefroren) wörtlich aus Übersicht §3 / Fixture; SEARCH_ROOMS = Räume der Etagen anfragen und ohne_website in ROLES-Reihenfolge; roomLabel: Tabelle aus Spec §5 (netz „Netz-Radar“, kleinanzeigen „Kleinanzeigen“, portale „Portale“, foren „Foren & Fragen“, aerzte „Ärzte & Praxen“, handwerk „Handwerk“, selbst „Selbstständige“, laden „Läden & Gastro“, übrige: Raum-ID); CapError.message = Worker <id> darf <cap> nicht.

  • Step 4: Test laufen lassen. Gleicher Befehl → PASS (6 Tests).

  • Step 5: Commit. git add src/roles.js test/fixtures/workers-roster.json test/roles.test.js && git commit -m "P1-T05: Rollen-Register mit Fähigkeiten-Sperre" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


P1-T06: Ereignisstrom emit

Files:

  • Create: src/events.js
  • Test: test/events.test.js

Interfaces:

  • Consumes: deps.db, deps.clock, ROLES (T05).

  • Produces: emit(deps, { workerId, kind, text, jobId?, prospectId?, link?, to?, tokensIn?, tokensOut?, costUsd?, meta? }) → Dokument; EVENT_KINDS (26 Arten aus Übersicht §5).

  • Step 1: Test schreiben.

    const KINDS = ['auftrag', 'leerlauf', 'gelesen', 'fund', 'doppelt', 'uebergabe', 'bewertet', 'top3', 'botlead', 'entwurf', 'pult', 'gesendet', 'eingang', 'sortiert', 'bau_start', 'bau_fertig', 'pruefung_zurueck', 'pruefung_ok', 'vorschau_link', 'angebot', 'deal', 'gesperrt', 'budget_warnung', 'budget_stopp', 'pause', 'fehler']
    test('EVENT_KINDS = Übersicht §5', () => assert.deepEqual(EVENT_KINDS, KINDS))
    test('schreibt alle Felder; ts aus der Uhr; meta als JSON', async () => {
      const deps = makeDeps()
      const e = await emit(deps, { workerId: 'kim', kind: 'gelesen', text: 'Kim hat 3 Suchaufträge gelesen.', prospectId: 'p1', to: 'bea', tokensIn: 10, tokensOut: 5, costUsd: 0.002, meta: { count: 3 } })
      assert.equal(e.ts, '2026-10-19T09:00:00.000Z'); assert.equal(e.metaJson, '{"count":3}'); assert.equal(e.to, 'bea'); assert.equal(e.workerId, 'kim')
    })
    test('unbekannte Art, Worker oder Ziel → Fehler, nichts geschrieben; system ist erlaubt', async () => {
      const deps = makeDeps()
      await assert.rejects(emit(deps, { workerId: 'kim', kind: 'quatsch', text: 'x' }), /Ereignisart/)
      await assert.rejects(emit(deps, { workerId: 'niemand', kind: 'fehler', text: 'x' }), /Worker/)
      await assert.rejects(emit(deps, { workerId: 'kim', kind: 'uebergabe', text: 'x', to: 'niemand' }), /Ziel/)
      assert.equal(deps.db.all('workersEvents').length, 0)
      await emit(deps, { workerId: 'system', kind: 'pause', text: 'Pause.' })
    })
    test('text > 300 wird auf genau 300 gekürzt (… am Ende); zu große meta wird {"truncated":true}', async () => {
      const deps = makeDeps()
      const e = await emit(deps, { workerId: 'otto', kind: 'leerlauf', text: 'ä'.repeat(400), meta: { x: 'y'.repeat(5000) } })
      assert.equal(e.text.length, 300); assert.ok(e.text.endsWith('…')); assert.equal(e.metaJson, '{"truncated":true}')
    })
    test('gleiche Millisekunde → ts strikt aufsteigend (sonst verliert der Poll "after=" Ereignisse)', async () => {
      const deps = makeDeps({ now: '2026-10-19T09:00:00.000Z' })
      const ts = []; for (let i = 0; i < 3; i++) ts.push((await emit(deps, { workerId: 'otto', kind: 'leerlauf', text: 'a' })).ts)
      assert.deepEqual(ts, ['2026-10-19T09:00:00.000Z', '2026-10-19T09:00:00.001Z', '2026-10-19T09:00:00.002Z'])
      deps.clock.set('2026-10-19T09:00:05.000Z'); assert.equal((await emit(deps, { workerId: 'otto', kind: 'leerlauf', text: 'b' })).ts, '2026-10-19T09:00:05.000Z')
    })
    
  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/events.test.js → FAIL ERR_MODULE_NOT_FOUND.

  • Step 3: Implementieren. Fehlertexte enthalten Ereignisart, Worker, Ziel. ts = max(clock.now(), letztes ts dieser DB + 1) (letztes ts je deps.db in einer WeakMap); text > 300 → erste 299 Zeichen + …; link auf 1024 kürzen; metaJson nur wenn meta gesetzt und serialisiert ≤ 4000, sonst {"truncated":true}; nicht gesetzte optionale Felder weglassen; Rückgabe = angelegtes Dokument.

  • Step 4: Test laufen lassen. Gleicher Befehl → PASS (5 Tests).

  • Step 5: Commit. git add src/events.js test/events.test.js && git commit -m "P1-T06: Ereignisstrom emit mit strikt aufsteigendem ts" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


P1-T07: Fehlerklassen und Einstellungen

Files:

  • Create: src/errors.js, src/settings.js
  • Test: test/settings.test.js

Interfaces:

  • Produces: HttpError(status, message), ValidationError (400), NotFoundError (404); DEFAULTS; getSettings(deps) → Einstellungen (Standardwerte §4.1, gespeichertes Dokument darüber gelegt, ohne $-Felder); updateSettings(deps, patch, by) → Einstellungen; publicSettings(settings) (ohne previewsSent) (Übersicht §6).

  • Consumes: ROLES/SEARCH_ROOMS (T05), deps.db, deps.clock.

  • Step 1: Test schreiben.

    test('Standardwerte §4.1 ohne gespeichertes Dokument', async () => {
      const s = await getSettings(makeDeps())
      assert.deepEqual([s.paused, s.dailyBudgetUsd, s.monthlyBudgetUsd, s.warnPct, s.mailColdPerDay, s.mailReplyCapPerDay], [false, 10, 250, 80, 3, 15])
      assert.deepEqual([s.sendWindowStart, s.sendWindowEnd, s.sendWeekdaysOnly, s.runsPerNight, s.candidatesPerRun], ['09:00', '16:00', true, 1, 10])
      assert.deepEqual(s.activeRooms, ['netz', 'kleinanzeigen', 'portale', 'foren', 'aerzte', 'handwerk', 'selbst', 'laden'])
      assert.deepEqual([s.connectedRooms, s.regions], [[], ['Rheinland-Pfalz']])
      assert.deepEqual([s.previewGate, s.previewGateN, s.previewsSent, s.offerProductKey], ['erste_n', 10, 0, 'hosting'])
      assert.equal(s.offerText, 'Gratis-Vorschau, unverbindlich. Wenn sie gefällt, ist die Website im Hosting-Abo enthalten: 20 € im Monat, 12 Monate Mindestlaufzeit, ohne MwSt. nach § 19 UStG.')
      assert.deepEqual([s.pultStaleDays, s.retentionDays, s.followupDays, s.previewReminderDays, s.previewDeleteDays], [3, 90, 7, 5, 60])
      assert.equal(s.reportEmail, 'kenso.gri@gmail.com')
    })
    test('updateSettings legt Dokument "main" an und aktualisiert danach; setzt updatedBy/updatedAt', async () => {
      const deps = makeDeps(); const s = await updateSettings(deps, { dailyBudgetUsd: 12 }, 'u1')
      assert.equal(s.dailyBudgetUsd, 12); assert.equal(s.updatedBy, 'u1'); assert.equal(deps.db.all('workersSettings')[0].$id, 'main')
      await updateSettings(deps, { paused: true }, 'u2'); assert.equal(deps.db.all('workersSettings').length, 1)
      assert.deepEqual([(await getSettings(deps)).dailyBudgetUsd, (await getSettings(deps)).paused], [12, true])
    })
    test('Validierung: ungültige Patches werfen ValidationError (400, deutsch, mit Feldname) und ändern nichts', async () => {
      const deps = makeDeps()
      for (const [patch, re] of [[{ mailColdPerDay: 4 }, /mailColdPerDay/], [{ mailColdPerDay: 3.5 }, /mailColdPerDay/], [{ mailReplyCapPerDay: 16 }, /mailReplyCapPerDay/], [{ dailyBudgetUsd: 0 }, /dailyBudgetUsd/],
        [{ monthlyBudgetUsd: -1 }, /monthlyBudgetUsd/], [{ warnPct: 101 }, /warnPct/], [{ sendWindowStart: '9:00' }, /sendWindowStart/], [{ sendWindowStart: '16:00', sendWindowEnd: '09:00' }, /sendWindow/],
        [{ runsPerNight: 3 }, /runsPerNight/], [{ activeRooms: ['nirgends'] }, /activeRooms/], [{ connectedRooms: ['nirgends'] }, /connectedRooms/], [{ previewGate: 'vielleicht' }, /previewGate/],
        [{ previewsSent: 5 }, /previewsSent/], [{ unbekannt: 1 }, /unbekannt/], [{ reportEmail: 'kein-mail' }, /reportEmail/], [{ offerProductKey: 'gratis' }, /offerProductKey/], [{ regions: [] }, /regions/]])
        await assert.rejects(updateSettings(deps, patch, 'u1'), (e) => e instanceof ValidationError && e.status === 400 && re.test(e.message), JSON.stringify(patch))
      assert.equal(deps.db.all('workersSettings').length, 0)
      await updateSettings(deps, { mailColdPerDay: 3, connectedRooms: ['netz', 'bruecke', 'waage'] }, 'u1')   // 3 ist erlaubt; connectedRooms darf jeden bekannten Raum enthalten
    })
    test('getSettings begrenzt die Mail-Grenzen auch bei von Hand geändertem Dokument (E11)', async () => {
      const deps = makeDeps(); deps.db.seed('workersSettings', { mailColdPerDay: 50, mailReplyCapPerDay: 99, runsPerNight: 9 }, 'main')
      const s = await getSettings(deps); assert.deepEqual([s.mailColdPerDay, s.mailReplyCapPerDay, s.runsPerNight], [3, 15, 2])
    })
    test('publicSettings entfernt previewsSent und $-Felder', async () => { const p = publicSettings(await getSettings(makeDeps())); assert.ok(!('previewsSent' in p)); assert.ok(!Object.keys(p).some((k) => k.startsWith('$'))) })
    
  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/settings.test.js → FAIL ERR_MODULE_NOT_FOUND.

  • Step 3: Implementieren. errors.js: HttpError extends Error mit status; Unterklassen. settings.js: DEFAULTS wie im Test (Arrays je Aufruf kopieren); getSettings = db.get('workersSettings','main') über DEFAULTS gelegt (nur bekannte Schlüssel), danach mailColdPerDay auf höchstens 3, mailReplyCapPerDay auf höchstens 15 und runsPerNight auf höchstens 2 begrenzt (harte Grenzen gelten auch, wenn das Dokument in der Appwrite-Konsole geändert wurde). updateSettings: ganzen Patch zuerst validieren (erst danach schreiben), dann get: fehlt → create(…, 'main') mit allen DEFAULTS + Patch (bei ConflictError weiter mit update), sonst update. Grenzen: Budgets > 0; warnPct ganze Zahl 1–100; mailColdPerDay ganze Zahl 0–3; mailReplyCapPerDay 0–15; sendWindow* HH:MM, Start < Ende; runsPerNight 1–2; candidatesPerRun 1–20; activeRooms ⊆ SEARCH_ROOMS; connectedRooms ⊆ alle room-Werte aus ROLES; regions 1–30 Strings (je 1–64 Zeichen); previewGate ∈ {erste_n, immer, nie}; previewGateN 0–1000; offerText 1–1000 Zeichen; offerProductKey ∈ {hosting, wartung}; pultStaleDays 1–30; retentionDays 7–365; followupDays/previewReminderDays 1–60; previewDeleteDays 7–365; reportEmail einfache Adressprüfung ≤ 256; paused/sendWeekdaysOnly Boolean; previewsSent im Patch → „internes Feld“; Meldungen deutsch, beginnen mit dem Feldnamen.

  • Step 4: Test laufen lassen. Gleicher Befehl → PASS (5 Tests).

  • Step 5: Commit. git add src/errors.js src/settings.js test/settings.test.js && git commit -m "P1-T07: Einstellungen mit Validierung" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


P1-T08: Zeitplan (Berliner Zeit, Nachtfenster, Sendefenster)

Files:

  • Create: src/schedule.js
  • Test: test/schedule.test.js

Interfaces:

  • Produces: berlinNow(clock) → { ts, day:'YYYY-MM-DD', hour, minute, weekday:1..7 }; berlinStartOfDay(day) → ISO-UTC; nightKey(now) → 'YYYY-MM-DD'; runKey(kind, now) → string|null; isDue(kind, now, last) → boolean; inSendWindow(now, settings) → boolean (Übersicht §6 + E4).

  • Arten: täglich bericht 06:30, kuerzen 03:00, pultAlt 07:00; Nacht nachtplan (22:00–04:59), nacht2 (01:00–04:59); Intervall posteingang 2 min, alarme 10 min, senden 5 min, batches 5 min, aufraeumen 5 min (intern, T26).

  • Step 1: Test schreiben.

    const at = (iso) => berlinNow({ now: () => Date.parse(iso) })
    test('berlinNow: Sommer- und Winterzeit, Wochentag', () => {
      const p = ({ day, hour, minute, weekday }) => ({ day, hour, minute, weekday })
      assert.deepEqual(p(at('2026-10-17T20:00:00Z')), { day: '2026-10-17', hour: 22, minute: 0, weekday: 6 })   // Samstag, MESZ
      assert.deepEqual(p(at('2026-11-17T21:00:00Z')), { day: '2026-11-17', hour: 22, minute: 0, weekday: 2 })   // Dienstag, MEZ
    })
    test('berlinStartOfDay', () => {
      assert.equal(berlinStartOfDay('2026-10-01'), '2026-09-30T22:00:00.000Z'); assert.equal(berlinStartOfDay('2026-11-01'), '2026-10-31T23:00:00.000Z')
    })
    test('täglich: bericht ab 06:30, einmal pro Tag, auch an der Zeitumstellung', () => {
      assert.equal(isDue('bericht', at('2026-10-18T04:29:00Z'), null), false); assert.equal(isDue('bericht', at('2026-10-18T04:30:00Z'), null), true)
      assert.equal(isDue('bericht', at('2026-10-18T09:00:00Z'), '2026-10-18'), false)
      assert.equal(isDue('bericht', at('2026-10-25T05:29:00Z'), null), false); assert.equal(isDue('bericht', at('2026-10-25T05:30:00Z'), null), true)   // 25.10.: MEZ, 06:30 = 05:30Z
    })
    test('Nacht: nachtplan 22:00–04:59 mit Nacht-Tag; nacht2 ab 01:00', () => {
      assert.equal(isDue('nachtplan', at('2026-10-17T19:59:00Z'), null), false)             // 21:59
      assert.equal(isDue('nachtplan', at('2026-10-17T20:00:00Z'), null), true)
      assert.equal(isDue('nachtplan', at('2026-10-17T20:00:00Z'), '2026-10-17'), false)
      assert.equal(runKey('nachtplan', at('2026-10-17T22:30:00Z')), '2026-10-17')           // 00:30 am 18. gehört zur Nacht des 17.
      assert.equal(isDue('nachtplan', at('2026-10-17T22:30:00Z'), null), true)               // Nachholen nach Neustart
      assert.equal(isDue('nachtplan', at('2026-10-18T03:00:00Z'), null), false)              // 05:00: Fenster vorbei
      assert.equal(isDue('nacht2', at('2026-10-17T22:59:00Z'), null), false); assert.equal(isDue('nacht2', at('2026-10-17T23:00:00Z'), null), true)
    })
    test('Intervall: last ist ms-Zeitstempel', () => {
      const n = at('2026-10-19T08:00:00Z')
      assert.equal(isDue('posteingang', n, null), true); assert.equal(isDue('posteingang', n, n.ts - 119999), false); assert.equal(isDue('posteingang', n, n.ts - 120000), true)
      assert.equal(isDue('batches', n, n.ts - 299999), false); assert.equal(isDue('aufraeumen', n, n.ts - 300000), true); assert.equal(runKey('posteingang', n), null)
      assert.throws(() => isDue('quatsch', n, null), /Unbekannte Art/)
    })
    test('inSendWindow: Mo–Fr 09:00 inklusive bis 16:00 exklusive', () => {
      const s = { sendWindowStart: '09:00', sendWindowEnd: '16:00', sendWeekdaysOnly: true }
      assert.equal(inSendWindow(at('2026-10-19T06:59:00Z'), s), false); assert.equal(inSendWindow(at('2026-10-19T07:00:00Z'), s), true)   // Montag 08:59 / 09:00
      assert.equal(inSendWindow(at('2026-10-19T13:59:00Z'), s), true); assert.equal(inSendWindow(at('2026-10-19T14:00:00Z'), s), false)   // 15:59 / 16:00
      assert.equal(inSendWindow(at('2026-10-17T09:00:00Z'), s), false)                                                                   // Samstag
      assert.equal(inSendWindow(at('2026-10-17T09:00:00Z'), { ...s, sendWeekdaysOnly: false }), true)
    })
    
  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/schedule.test.js → FAIL ERR_MODULE_NOT_FOUND.

  • Step 3: Implementieren. Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin', hourCycle: 'h23', … }) wie serverpuls/src/schedule.js. berlinStartOfDay(day): UTC-Mitternacht von day minus 2 h bzw. 1 h – derjenige Kandidat, dessen berlinNow genau day 00:00 ergibt. nightKey(now) = Berliner Tag von now.ts − 5 h. runKey: täglich → now.day; Nachtarten → nightKey; Intervall → null. isDue: täglich/Nacht → Zeitfenster (in Minuten seit Mitternacht; nachtplan: ≥ 22:00 oder < 05:00; nacht2: ≥ 01:00 und < 05:00) und last !== runKey; Intervall → last == null || now.ts − last ≥ Intervall; unbekannte Art → Error('Unbekannte Art: …'). inSendWindow vergleicht HH:MM aus den Einstellungen als Minuten.

  • Step 4: Test laufen lassen. Gleicher Befehl → PASS (6 Tests).

  • Step 5: Commit. git add src/schedule.js test/schedule.test.js && git commit -m "P1-T08: Zeitplan mit Berliner Zeit und Nachtfenster" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


P1-T09: Tageszähler daily.js (serialisiert, Lauf-Marker)

Files:

  • Create: src/daily.js
  • Test: test/daily.test.js

Interfaces:

  • Consumes: deps.db, berlinStartOfDay (T08).

  • Produces: Tagesobjekt { $id, costUsd, mailsCold, mailsReply, searches, found, contacted, deals, warned, stopped, counters } (counters = geparstes countersJson, fehlt es → {}); getDaily(deps, day) → Tagesobjekt|null; updateDaily(deps, day, mutate) → Tagesobjekt (legt fehlenden Tag mit Nullwerten an, mutate(obj) ändert in place, alle Aufrufe je deps.db laufen nacheinander); monthDocs(deps, now) → Tagesobjekt[] (Tage des Berliner Monats von now.day); markRun(deps, kind, key); lastRun(deps, kind, key) → string|null (E4).

  • Step 1: Test schreiben.

    test('getDaily: fehlender Tag → null; updateDaily legt mit Nullwerten an und liefert geparste counters', async () => {
      const deps = makeDeps(); assert.equal(await getDaily(deps, '2026-10-19'), null)
      const d = await updateDaily(deps, '2026-10-19', (x) => { x.found += 2; x.counters.kim = { gelesen: 3 } })
      assert.deepEqual([d.$id, d.costUsd, d.found, d.warned, d.stopped, d.counters], ['2026-10-19', 0, 2, false, false, { kim: { gelesen: 3 } }])
      assert.equal(deps.db.all('workersDaily')[0].countersJson, '{"kim":{"gelesen":3}}')
    })
    test('vier gleichzeitige Updates verlieren nichts (Sperre je DB)', async () => {
      const deps = makeDeps()
      await Promise.all([1, 2, 3, 4].map(() => updateDaily(deps, '2026-10-19', (d) => { d.costUsd += 0.5; d.counters.kim = { gelesen: (d.counters.kim?.gelesen ?? 0) + 1 } })))
      const d = await getDaily(deps, '2026-10-19'); assert.equal(d.costUsd, 2); assert.equal(d.counters.kim.gelesen, 4)
    })
    test('ein Fehler in mutate gibt die Sperre frei und schreibt nichts', async () => {
      const deps = makeDeps(); await assert.rejects(updateDaily(deps, '2026-10-19', () => { throw new Error('kaputt') }), /kaputt/)
      await updateDaily(deps, '2026-10-19', (d) => { d.found = 1 }); assert.equal((await getDaily(deps, '2026-10-19')).found, 1)
    })
    test('Zähler-JSON über 4000 Zeichen → Fehler statt stiller Kürzung', async () => {
      await assert.rejects(updateDaily(makeDeps(), '2026-10-19', (d) => { d.counters.x = 'y'.repeat(4000) }), /countersJson/)
    })
    test('monthDocs: nur Tage des Berliner Monats (Grenze 00:00 Berlin)', async () => {
      const deps = makeDeps({ now: '2026-09-30T21:59:59.000Z' }); await updateDaily(deps, '2026-09-30', (d) => { d.costUsd = 1 })        // 23:59:59 Berlin = September
      deps.clock.set('2026-09-30T22:00:00.000Z'); await updateDaily(deps, '2026-10-01', (d) => { d.costUsd = 2 })                       // 00:00 Berlin = Oktober
      deps.clock.set('2026-10-19T09:00:00.000Z'); await updateDaily(deps, '2026-10-19', (d) => { d.costUsd = 3 })
      assert.deepEqual((await monthDocs(deps, berlinNow(deps.clock))).map((d) => d.$id).sort(), ['2026-10-01', '2026-10-19'])
    })
    test('Lauf-Marker: pro Art und Schlüssel, überlebt einen Neustart (neues deps mit gleicher DB)', async () => {
      const deps = makeDeps(); assert.equal(await lastRun(deps, 'bericht', '2026-10-19'), null)
      await markRun(deps, 'bericht', '2026-10-19'); await markRun(deps, 'nachtplan', '2026-10-19')
      const neu = { ...deps }; assert.equal(await lastRun(neu, 'bericht', '2026-10-19'), '2026-10-19'); assert.equal(await lastRun(neu, 'kuerzen', '2026-10-19'), null)
      assert.deepEqual((await getDaily(neu, '2026-10-19')).counters.__runs, { bericht: '2026-10-19', nachtplan: '2026-10-19' })
    })
    
  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/daily.test.js → FAIL ERR_MODULE_NOT_FOUND (src/daily.js).

  • Step 3: Implementieren. Sperre: WeakMap<db, Promise>-Kette, Fehler brechen die Kette nicht. updateDaily: get → fehlt: create(…, day) mit Nullwerten (countersJson: '{}', bei ConflictError neu lesen) → mutate auf dem Objekt → costUsd auf 6 Nachkommastellen runden → JSON.stringify(counters) > 4000 Zeichen wirft Error('countersJson zu groß') → update mit nur den Schema-Feldern (costUsd, mailsCold, mailsReply, searches, found, contacted, deals, countersJson, warned, stopped), nie mit $…-Feldern oder counters. monthDocs: list('workersDaily', [Query.greaterThanEqual('$createdAt', berlinStartOfDay(<Monatsersten>)), Query.limit(40)]). markRun schreibt counters.__runs[kind] = key in das Dokument key.

  • Step 4: Test laufen lassen. Gleicher Befehl → PASS (6 Tests).

  • Step 5: Commit. git add src/daily.js test/daily.test.js && git commit -m "P1-T09: Tageszähler mit Sperre und Lauf-Markern" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


P1-T10: Kostenformel costOf

Files:

  • Create: src/budget.js (zunächst nur Kostenteil)
  • Test: test/budgetCost.test.js

Interfaces:

  • Produces: costOf(model, usage, { batch = false }) → USD (Zahl) (Übersicht §6); priceOf(model) → { in, out } ($/MTok, Normaltarif); estimateUsd(model, chars, maxTokens) → USD (Eingabe grob ceil(chars/3) Tokens zum Normaltarif + maxTokens Ausgabe).

  • usage = Anthropic-Usage-Objekt (input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens, cache_creation:{ephemeral_5m_input_tokens, ephemeral_1h_input_tokens}, server_tool_use:{web_search_requests, web_fetch_requests}); fehlende Felder zählen 0.

  • Step 1: Test schreiben.

    // near aus test/support/helpers.js (T01)
    test('Grundpreise je MTok', () => {
      near(costOf('claude-opus-5-5', { input_tokens: 1e6, output_tokens: 1e5 }), 6)
      near(costOf('claude-sonnet-5-5', { input_tokens: 1e6, output_tokens: 1e6 }), 12)
      near(costOf('claude-haiku-5-5', { input_tokens: 1e6, output_tokens: 1e6 }), 0.6)
    })
    test('Cache: Lesen ×0,05 (Haiku ×0,1), Schreiben 5 min ×1,25, 1 h ×2; ohne Aufschlüsselung = 5 min', () => {
      near(costOf('claude-opus-5-5', { cache_read_input_tokens: 1e6 }), 0.2); near(costOf('claude-sonnet-5-5', { cache_read_input_tokens: 1e6 }), 0.1); near(costOf('claude-haiku-5-5', { cache_read_input_tokens: 1e6 }), 0.01)
      near(costOf('claude-opus-5-5', { cache_creation: { ephemeral_5m_input_tokens: 1e6 } }), 5); near(costOf('claude-opus-5-5', { cache_creation: { ephemeral_1h_input_tokens: 1e6 } }), 8)
      near(costOf('claude-sonnet-5-5', { cache_creation_input_tokens: 1e6 }), 2.5)
    })
    test('Batch halbiert Tokens, nicht die Suche', () => {
      near(costOf('claude-sonnet-5-5', { input_tokens: 1e6, output_tokens: 1e6 }, { batch: true }), 6)
      near(costOf('claude-haiku-5-5', { input_tokens: 1e6, server_tool_use: { web_search_requests: 2 } }, { batch: true }), 0.07)
    })
    test('Websuche 0,01 $, Web-Fetch kostenlos', () => near(costOf('claude-haiku-5-5', { input_tokens: 10000, output_tokens: 2000, server_tool_use: { web_search_requests: 3, web_fetch_requests: 5 } }), 0.032))
    test('Haiku-Langprompt: > 100 000 Prompt-Tokens (inkl. Cache) → 0,50/2,50; genau 100 000 noch Normaltarif', () => {
      near(costOf('claude-haiku-5-5', { input_tokens: 150000, output_tokens: 10000 }), 0.1); near(costOf('claude-haiku-5-5', { input_tokens: 100000, output_tokens: 10000 }), 0.015)
      near(costOf('claude-haiku-5-5', { input_tokens: 50000, cache_read_input_tokens: 60000 }), 0.028)   // Prompt 110 000 inkl. Cache → Langtarif: 50 000×0,50 + 60 000×0,05 je MTok
    })
    test('unbekanntes Modell wirft; leeres usage = 0; priceOf/estimateUsd', () => {
      assert.throws(() => costOf('gpt-x', {}), /Unbekanntes Modell/); near(costOf('claude-opus-5-5', {}), 0)
      assert.deepEqual(priceOf('claude-haiku-5-5'), { in: 0.1, out: 0.5 }); near(estimateUsd('claude-sonnet-5-5', 3000, 1000), 1000 * 2 / 1e6 + 1000 * 10 / 1e6)
    })
    
  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/budgetCost.test.js → FAIL ERR_MODULE_NOT_FOUND (src/budget.js).

  • Step 3: Implementieren. Preistabelle als Konstante (Opus 4/20, Sonnet 2/10, Haiku 0,1/0,5; Cache-Lesefaktor 0,05 bzw. 0,1 für Haiku). Haiku-Langprompt: Prompt-Tokens = input + cache_read + cache_creation (5m+1h bzw. cache_creation_input_tokens); > 100 000 ⇒ Eingabe-/Ausgabepreis 0,5/2,5 (Cache-Faktoren wirken auf diesen Eingabepreis). Batch: alle Token-Anteile ×0,5, Suchanteil nicht. Ergebnis auf 1e-9 genau (nicht runden).

  • Step 4: Test laufen lassen. Gleicher Befehl → PASS (6 Tests).

  • Step 5: Commit. git add src/budget.js test/budgetCost.test.js && git commit -m "P1-T10: Kostenformel costOf" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


P1-T11: Budget-Wächter checkBudget, book, markStopped

Files:

  • Modify: src/budget.js
  • Test: test/budget.test.js

Interfaces:

  • Consumes: getSettings (T07), getDaily/updateDaily/monthDocs (T09), berlinNow (T08), emit (T06), optional deps.notify.

  • Produces: checkBudget(deps, estUsd, { reply = false }) → { ok, reason:'tag'|'monat'|'pause'|null, warn }; book(deps, { workerId, usd, counters = {} }) → { costUsd, warned, stopped }; markStopped(deps, reason); formatUsd(n) → '8,12 $' (E5).

  • Step 1: Test schreiben. (near aus test/support/helpers.js; Standard-makeDeps = Montag 11:00 Berlin.)

    test('frisch: ok, kein warn', async () => assert.deepEqual(await checkBudget(makeDeps(), 1), { ok: true, reason: null, warn: false }))
    test('Tagesgrenze: 8,5 $ → warn; Schätzung 1,6 sprengt den Tag; reply:true darf trotzdem', async () => {
      const deps = makeDeps(); await book(deps, { workerId: 'rita', usd: 8.5 })
      assert.deepEqual(await checkBudget(deps, 1), { ok: true, reason: null, warn: true })
      assert.deepEqual(await checkBudget(deps, 1.6), { ok: false, reason: 'tag', warn: true }); assert.equal((await checkBudget(deps, 1.6, { reply: true })).ok, true)
    })
    test('Pause gewinnt immer, auch bei reply', async () => {
      const deps = makeDeps(); await updateSettings(deps, { paused: true }, 'u')
      assert.equal((await checkBudget(deps, 0)).reason, 'pause'); assert.equal((await checkBudget(deps, 0, { reply: true })).reason, 'pause')
    })
    test('Monatsgrenze 250 $ gilt auch für reply', async () => {
      const deps = makeDeps(); deps.db.seed('workersDaily', { costUsd: 245 }, '2026-10-05')
      assert.equal((await checkBudget(deps, 6)).reason, 'monat'); assert.equal((await checkBudget(deps, 6, { reply: true })).reason, 'monat'); assert.equal((await checkBudget(deps, 4)).ok, true)
    })
    test('80 %: genau eine budget_warnung (otto) und eine Mail, auch bei einer zweiten Buchung darüber', async () => {
      const mails = []; const deps = makeDeps({ notify: async (m) => { mails.push(m); return { sent: true } } })
      await book(deps, { workerId: 'rita', usd: 8.1 }); await book(deps, { workerId: 'rita', usd: 0.5 })
      const ev = deps.db.all('workersEvents').filter((e) => e.kind === 'budget_warnung')
      assert.equal(ev.length, 1); assert.equal(ev[0].workerId, 'otto'); assert.equal(mails.length, 1); assert.match(mails[0].subject, /Budget/)
    })
    test('100 %: ein budget_stopp ohne zusätzliche Warnung; danach tag-gesperrt, reply darf; keine Wiederholung', async () => {
      const deps = makeDeps(); const r = await book(deps, { workerId: 'rita', usd: 10.2 })
      assert.deepEqual(deps.db.all('workersEvents').map((e) => e.kind), ['budget_stopp']); assert.deepEqual([r.warned, r.stopped], [true, true])
      assert.deepEqual(await checkBudget(deps, 0.01), { ok: false, reason: 'tag', warn: true }); assert.equal((await checkBudget(deps, 0.01, { reply: true })).ok, true)
      await book(deps, { workerId: 'rita', usd: 0.5 }); assert.equal(deps.db.all('workersEvents').length, 1)
    })
    test('Console-Stopp: markStopped sperrt auch Antworten, Ereignis nur einmal', async () => {
      const deps = makeDeps(); await markStopped(deps, 'console'); await markStopped(deps, 'console')
      assert.equal(deps.db.all('workersEvents').filter((e) => e.kind === 'budget_stopp').length, 1); assert.equal((await checkBudget(deps, 0.01, { reply: true })).ok, false)
    })
    test('book: counters je Worker; Sonderschlüssel auch in den Tagesfeldern; costUsd je Worker', async () => {
      const deps = makeDeps(); await book(deps, { workerId: 'rita', usd: 0.02, counters: { searches: 2, gelesen: 5, tokensIn: 1000 } })
      const d = await getDaily(deps, '2026-10-19'); assert.equal(d.searches, 2); assert.deepEqual(d.counters.rita, { searches: 2, gelesen: 5, tokensIn: 1000, costUsd: 0.02 }); near(d.costUsd, 0.02)
    })
    test('vier gleichzeitige Buchungen verlieren keine Kosten; Warnung nur einmal', async () => {
      const deps = makeDeps(); await Promise.all([2.1, 2.1, 2.1, 2.1].map((usd) => book(deps, { workerId: 'kim', usd })))
      const d = await getDaily(deps, '2026-10-19'); near(d.costUsd, 8.4); near(d.counters.kim.costUsd, 8.4)
      assert.equal(deps.db.all('workersEvents').filter((e) => e.kind === 'budget_warnung').length, 1)
    })
    
  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/budget.test.js → FAIL (checkBudget is not a function bzw. fehlender Export).

  • Step 3: Implementieren. checkBudget: Reihenfolge Pause → (Console-Stopp: stopped && counters.__stop === 'console' ⇒ tag, auch für reply) → Tag (costUsd + est > dailyBudgetUsd oder stopped; entfällt bei reply) → Monat (Summe monthDocs + est > monthlyBudgetUsd); warn = Tag oder Monat ≥ warnPct %. book: ein updateDaily addiert usd (je Worker costUsd, 6 Nachkommastellen) und counters (additiv; Sonderschlüssel mailsCold, mailsReply, searches, found, contacted, deals zusätzlich in die Tagesfelder); danach entscheidet ein zweiter updateDaily anhand des dann aktuellen Tagesdokuments (nicht des Rückgabewerts des ersten Schritts; Monatssumme vorher per monthDocs gelesen), ob warned/stopped zu setzen sind – nur wenn das Flag in der Sperre wirklich von false auf true wechselt, folgt Ereignis (otto, budget_warnung/budget_stopp, deutscher Satz mit formatUsd, meta:{scope:'tag'|'monat',usd,limit}) und deps.notify?.({ subject:'Budget …', text }) (Fehler der Mail nur loggen); Stopp setzt warned mit, ohne eigene Warnung. markStopped(deps, reason): stopped = true, counters.__stop = reason, budget_stopp nur beim Wechsel. formatUsd: Intl.NumberFormat('de-DE', 2 Nachkommastellen) + ' $'.

  • Step 4: Tests laufen lassen. Gleicher Befehl und test/budgetCost.test.js → PASS (9 + 6 Tests).

  • Step 5: Commit. git add src/budget.js test/budget.test.js && git commit -m "P1-T11: Budget-Wächter mit Warnung, Stopp und Antwort-Ausnahme" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


P1-T12: Interner Mailer notify

Files:

  • Create: src/notify.js
  • Test: test/notify.test.js

Interfaces:

  • Produces: createNotifier({ smtp, getTo, createTransport = nodemailer.createTransport, clock, log }) → { notify({ subject, text }) → Promise<{ sent:boolean, reason?:string }> }; smtp = config.reportSmtp oder null; getTo() → Empfängeradresse (E3). Reason: nicht_konfiguriert, kein_empfaenger, smtp_auth, smtp_fehler.

  • Step 1: Test schreiben.

    const SMTP = { host: 'mail.example', port: 587, user: 'u', pass: 'geheim-pw', from: 'server@example.de' }
    function mk({ fail } = {}) { const t = { attempts: 0, sent: [], cfg: null }
      t.createTransport = (cfg) => { t.cfg = cfg; return { sendMail: async (m) => { t.attempts++; if (fail) throw fail; t.sent.push(m); return { messageId: 'x' } } } }; return t }
    test('sendet an getTo(): Betreff einzeilig mit [Workers]-Präfix, STARTTLS, Timeouts', async () => {
      const deps = makeDeps(); const t = mk()
      const n = createNotifier({ smtp: SMTP, getTo: async () => 'kenso@example.de', createTransport: t.createTransport, clock: deps.clock, log: deps.log })
      assert.deepEqual(await n.notify({ subject: 'Budget\nWarnung', text: 'T' }), { sent: true })
      assert.deepEqual([t.sent[0].to, t.sent[0].from, t.sent[0].subject], ['kenso@example.de', 'server@example.de', '[Workers] Budget Warnung'])
      assert.deepEqual([t.cfg.host, t.cfg.port, t.cfg.requireTLS, t.cfg.connectionTimeout, t.cfg.auth], ['mail.example', 587, true, 20000, { user: 'u', pass: 'geheim-pw' }])
    })
    test('ohne Konfiguration oder Empfänger: sent=false, kein Transport', async () => {
      const deps = makeDeps(); const t = mk(); const base = { createTransport: t.createTransport, clock: deps.clock, log: deps.log }
      assert.deepEqual(await createNotifier({ smtp: null, getTo: async () => 'k@example.de', ...base }).notify({ subject: 's', text: 't' }), { sent: false, reason: 'nicht_konfiguriert' })
      assert.deepEqual(await createNotifier({ smtp: SMTP, getTo: async () => '', ...base }).notify({ subject: 's', text: 't' }), { sent: false, reason: 'kein_empfaenger' }); assert.equal(t.attempts, 0)
    })
    test('Login-Fehler (EAUTH/535): genau ein Versuch pro Berliner Tag, Grund ohne Passwort', async () => {
      const deps = makeDeps(); const t = mk({ fail: Object.assign(new Error('Invalid login geheim-pw'), { code: 'EAUTH', responseCode: 535 }) }); const logs = []
      const n = createNotifier({ smtp: SMTP, getTo: async () => 'k@example.de', createTransport: t.createTransport, clock: deps.clock, log: (m) => logs.push(m) })
      for (let i = 0; i < 3; i++) assert.deepEqual(await n.notify({ subject: 's', text: 't' }), { sent: false, reason: 'smtp_auth' })
      assert.equal(t.attempts, 1); assert.ok(!logs.join('\n').includes('geheim-pw'))
      deps.clock.advance(24 * 3600 * 1000); await n.notify({ subject: 's', text: 't' }); assert.equal(t.attempts, 2)
    })
    test('anderer Fehler: kein internes Wiederholen, reason smtp_fehler', async () => {
      const deps = makeDeps(); const t = mk({ fail: Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' }) })
      const n = createNotifier({ smtp: SMTP, getTo: async () => 'k@example.de', createTransport: t.createTransport, clock: deps.clock, log: deps.log })
      assert.deepEqual(await n.notify({ subject: 's', text: 't' }), { sent: false, reason: 'smtp_fehler' }); assert.equal(t.attempts, 1)
    })
    
  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/notify.test.js → FAIL ERR_MODULE_NOT_FOUND.

  • Step 3: Implementieren. Transport lazy einmal erzeugen: { host, port, secure: port === 465, requireTLS: port !== 465, connectionTimeout: 20000, greetingTimeout: 20000, socketTimeout: 20000, auth: { user, pass } }. Betreff: CR/LF → Leerzeichen, Präfix [Workers] falls nicht vorhanden. Auth-Fehler (code === 'EAUTH' oder responseCode ∈ {530, 534, 535}) sperren weitere Versuche bis zum nächsten Berliner Tag (berlinNow(clock).day) — Hintergrund: wiederholte falsche SMTP-Logins sperrten am 2026-09-30 die Server-IP bei Hetzner. Logs enthalten nie Passwort oder Nachrichtentext.

  • Step 4: Test laufen lassen. Gleicher Befehl → PASS (4 Tests).

  • Step 5: Commit. git add src/notify.js test/notify.test.js && git commit -m "P1-T12: interner Mailer mit Login-Sperre pro Tag" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


P1-T13: Claude-Hülle ask mit Budget-Erkennung

Files:

  • Create: src/claude.js, test/support/fakeSdk.js
  • Test: test/claudeAsk.test.js

Interfaces:

  • Consumes: checkBudget, book, markStopped, costOf, estimateUsd (T10/T11), ROLES/MODELS (T05).

  • Produces: createClaude({ apiKey, sdk, searchModel = '' }) → { ask, search, batch } (hier zunächst ask; sdk = Client mit .messages.create, Standard new Anthropic({ apiKey, maxRetries: 2, timeout: 300000 }) – bewusst wenige Wiederholungen und ein langes Zeitlimit: Suchläufe mit Serverwerkzeugen dauern bis zu mehreren Minuten, und jede wiederholte, nicht gebuchte Anfrage wäre unbemerkte Ausgabe; die Console-Grenze fängt den Rest ab); ask(deps, { workerId, model?, system, input, schema?, images?, maxTokens, effort?, reply? }) → { data?, text, usage, costUsd, model }; BudgetStop (.reason ∈ tag|monat|pause|console); ClaudeRefusal; isBudgetError(err) → boolean.

  • fakeSdk(script) → { calls, messages:{ create, batches } } (create liefert die Einträge von script der Reihe nach, der letzte wiederholt sich; ein Error wird geworfen; calls enthält Kopien der Anfragen); msg(over) (Standard: claude-haiku-5-5, end_turn, Text ok, usage 1000/200); apiError(status, body) (Fehler mit status und error = body, Meldung <status> <JSON> wie das SDK).

  • Step 1: Tests schreiben.

    const SCHEMA_J = { type: 'object', properties: { klasse: { type: 'string' } }, required: ['klasse'], additionalProperties: false }
    test('Anfrageform: Rollenmodell, gecachter System-Block, Schema in output_config, keine Sampling-Parameter', async () => {
      const sdk = fakeSdk([msg({ content: [{ type: 'text', text: '{"klasse":"ja"}' }] })]); const deps = makeDeps()
      const r = await createClaude({ apiKey: 'k', sdk }).ask(deps, { workerId: 'nora', system: 'SYS', input: 'IN', schema: SCHEMA_J, maxTokens: 500, effort: 'low' })
      assert.deepEqual(r.data, { klasse: 'ja' }); const q = sdk.calls[0]
      assert.deepEqual([q.model, q.max_tokens], ['claude-haiku-5-5', 500])
      assert.deepEqual(q.system, [{ type: 'text', text: 'SYS', cache_control: { type: 'ephemeral' } }]); assert.deepEqual(q.messages, [{ role: 'user', content: 'IN' }])
      assert.deepEqual(q.output_config, { effort: 'low', format: { type: 'json_schema', schema: SCHEMA_J } })
      for (const k of ['temperature', 'top_p', 'top_k']) assert.ok(!(k in q))
    })
    test('Bilder stehen vor dem Text; explizites model überschreibt das Rollenmodell', async () => {
      const sdk = fakeSdk([msg()]); await createClaude({ apiKey: 'k', sdk }).ask(makeDeps(), { workerId: 'paul', model: 'claude-sonnet-5-5', system: 's', input: 'IN', images: [{ mediaType: 'image/png', data: 'AAA' }], maxTokens: 10 })
      assert.equal(sdk.calls[0].model, 'claude-sonnet-5-5'); assert.deepEqual(sdk.calls[0].messages[0].content, [{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAA' } }, { type: 'text', text: 'IN' }])
    })
    test('bucht Kosten und Tokens (Haiku 1000/200 = 0,0002 $) in Tag und Worker-Zähler', async () => {
      const deps = makeDeps(); const r = await createClaude({ apiKey: 'k', sdk: fakeSdk([msg()]) }).ask(deps, { workerId: 'nora', system: 's', input: 'i', maxTokens: 100 })
      near(r.costUsd, 0.0002); const d = await getDaily(deps, '2026-10-19'); near(d.costUsd, 0.0002); assert.deepEqual(d.counters.nora, { calls: 1, tokensIn: 1000, tokensOut: 200, costUsd: 0.0002 })
    })
    test('Verweigerung, abgeschnittene Antwort, kaputtes JSON werfen – nach dem Buchen', async () => {
      for (const [res, re] of [[msg({ stop_reason: 'refusal' }), ClaudeRefusal], [msg({ stop_reason: 'max_tokens' }), /abgeschnitten/], [msg({ content: [{ type: 'text', text: 'kein json' }] }), /JSON/]]) {
        const deps = makeDeps(); const c = createClaude({ apiKey: 'k', sdk: fakeSdk([res]) })
        await assert.rejects(c.ask(deps, { workerId: 'nora', system: 's', input: 'i', schema: SCHEMA_J, maxTokens: 10 }), re); assert.ok((await getDaily(deps, '2026-10-19')).costUsd > 0)
      }
    })
    test('Vorabprüfung: Pause und Tagesgrenze → BudgetStop ohne API-Aufruf; reply:true darf am Tageslimit', async () => {
      const sdk = fakeSdk([msg()]); const c = createClaude({ apiKey: 'k', sdk }); const ask = (deps, o = {}) => c.ask(deps, { workerId: 'nora', system: 's', input: 'i', maxTokens: 10, ...o })
      let deps = makeDeps(); await updateSettings(deps, { paused: true }, 't'); await assert.rejects(ask(deps), (e) => e instanceof BudgetStop && e.reason === 'pause')
      deps = makeDeps(); await book(deps, { workerId: 'rita', usd: 10 }); await assert.rejects(ask(deps), (e) => e instanceof BudgetStop && e.reason === 'tag')
      assert.equal(sdk.calls.length, 0); await ask(deps, { reply: true }); assert.equal(sdk.calls.length, 1)
    })
    test('API-Budgetantworten: 400-Workspace-Limit und 429 enforced_spend_limit_reached → BudgetStop(console), Tag gestoppt, Ereignis einmal, kein weiterer Aufruf', async () => {
      const b400 = { type: 'error', error: { type: 'invalid_request_error', message: 'You have reached your specified workspace API usage limits. You will regain access on 2026-11-01 at 00:00 UTC.' } }
      const b429 = { type: 'error', error: { type: 'rate_limit_error', message: 'Monthly spend limit reached', details: { error_code: 'enforced_spend_limit_reached' } } }
      for (const err of [apiError(400, b400), apiError(429, b429)]) {
        const deps = makeDeps(); const sdk = fakeSdk([err]); const c = createClaude({ apiKey: 'k', sdk })
        await assert.rejects(c.ask(deps, { workerId: 'nora', system: 's', input: 'i', maxTokens: 10 }), (e) => e instanceof BudgetStop && e.reason === 'console')
        await assert.rejects(c.ask(deps, { workerId: 'nora', system: 's', input: 'i', maxTokens: 10, reply: true }), BudgetStop)
        assert.equal(sdk.calls.length, 1); assert.equal(deps.db.all('workersEvents').filter((e) => e.kind === 'budget_stopp').length, 1)
      }
    })
    test('gewöhnliche Fehler bleiben unverändert: normales 429 und fremdes 400', async () => {
      const e429 = apiError(429, { type: 'error', error: { type: 'rate_limit_error', message: 'slow down' } }); const e400 = apiError(400, { type: 'error', error: { type: 'invalid_request_error', message: 'max_tokens too large' } })
      assert.equal(isBudgetError(e429), false); assert.equal(isBudgetError(e400), false)
      const deps = makeDeps(); await assert.rejects(createClaude({ apiKey: 'k', sdk: fakeSdk([e429]) }).ask(deps, { workerId: 'nora', system: 's', input: 'i', maxTokens: 10 }), (e) => e === e429)
      assert.equal((await getDaily(deps, '2026-10-19'))?.stopped ?? false, false)
    })
    
  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/claudeAsk.test.js → FAIL ERR_MODULE_NOT_FOUND (src/claude.js).

  • Step 3: Implementieren. ask: Modell = opts.model ?? MODELS[role(workerId).model] (unbekannter Worker → Fehler); checkBudget(deps, estimateUsd(model, system.length + input.length, maxTokens), { reply }) nicht ok ⇒ BudgetStop(reason); Anfrage genau wie im Test (output_config nur, wenn effort oder schema gesetzt); SDK-Fehler: isBudgetError(err) ⇒ markStopped(deps, 'console') und BudgetStop('console'), sonst unverändert werfen. isBudgetError: Text = err.error?.error?.message ?? err.message; Status 400 und Text enthält You have reached your specified und API usage limits, oder Status 429 und JSON.stringify(err.error ?? {}) enthält enforced_spend_limit_reached (nicht am Textanfang verankern: das SDK stellt „400 {…}“ voran). Nach der Antwort zuerst book(deps, { workerId, usd, counters:{ calls:1, tokensIn: input+cache_read+cache_creation, tokensOut: output } }), dann stop_reason prüfen (refusal → ClaudeRefusal, max_tokens → Error('Antwort abgeschnitten (max_tokens)')), dann bei schema den ersten Text-Block mit JSON.parse (Fehler → Error('Antwort war kein gültiges JSON')).

  • Step 4: Test laufen lassen. Gleicher Befehl → PASS (7 Tests).

  • Step 5: Commit. git add src/claude.js test/support/fakeSdk.js test/claudeAsk.test.js && git commit -m "P1-T13: Claude-Hülle ask mit Budget-Erkennung" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


P1-T14: Claude search (pause_turn), batch, Modell-Prüfskript

Files:

  • Modify: src/claude.js
  • Create: scripts/check-modelle.mjs
  • Test: test/claudeSearch.test.js, test/checkModelle.test.js

Interfaces:

  • Produces: search(deps, { workerId, system, task, maxUses, allowedDomains?, blockedDomains?, maxTokens = 8000, reply? }) → { text, sources:[{url,title}], usage, costUsd, incomplete, toolErrors:[{tool,code,url?}] } (Übersicht §6 + E6); batch = { create(deps, requests), retrieve(batchId), results(batchId) → [{ customId, type, text?, usage?, costUsd? }] }; BOT_VERBOT_DOMAINS = ['kleinanzeigen.de','ebay-kleinanzeigen.de','facebook.com','fb.com','instagram.com','linkedin.com','xing.com','upwork.com','myhammer.de','freelancermap.de','freelance.de'] (Export aus src/claude.js; E11); Skript scripts/check-modelle.mjs: evaluate(models) → { exit:0|1|2, searchModel:string, lines:string[] } (models = Antworten von models.retrieve je Modell-ID) und evaluateProbe(probe) → { exit:0|1, searchModel:string, lines:string[] } (probe[modellId] = { ok:boolean, reason? }, Ergebnis je einer echten Probe-Suche; models.retrieve liefert den Zweig server_tools.web_search womöglich gar nicht, dann entscheidet nur die Probe).

  • Consumes: assertCap/hasCap (T05), ask-Bausteine aus T13.

  • Step 1: Tests schreiben.

    const pause = (id) => msg({ stop_reason: 'pause_turn', content: [{ type: 'server_tool_use', id, name: 'web_search', input: { query: 'q' } }], usage: { input_tokens: 1000, output_tokens: 100, server_tool_use: { web_search_requests: 1 } } })
    const done = msg({ content: [{ type: 'text', text: 'Fund A' }, { type: 'web_search_tool_result', tool_use_id: 's1', content: [{ type: 'web_search_result', url: 'https://a.de', title: 'A' }, { type: 'web_search_result', url: 'https://a.de', title: 'A' }] }], usage: { input_tokens: 500, output_tokens: 50 } })
    test('Werkzeuge: web_search_20260318 + web_fetch_20260318 mit allowed_callers direct; Rolle ohne web_search → CapError ohne Aufruf', async () => {
      const sdk = fakeSdk([done]); const c = createClaude({ apiKey: 'k', sdk })
      await c.search(makeDeps(), { workerId: 'rita', system: 'S', task: 'T', maxUses: 3, blockedDomains: ['facebook.com'] })
      assert.deepEqual(sdk.calls[0].tools, [{ type: 'web_search_20260318', name: 'web_search', max_uses: 3, allowed_callers: ['direct'], blocked_domains: ['facebook.com'] }, { type: 'web_fetch_20260318', name: 'web_fetch', max_uses: 3, allowed_callers: ['direct'], blocked_domains: BOT_VERBOT_DOMAINS }])
      assert.ok(!('output_config' in sdk.calls[0]))
      await assert.rejects(c.search(makeDeps(), { workerId: 'kim', system: 'S', task: 'T', maxUses: 1 }), CapError)
      await assert.rejects(c.search(makeDeps(), { workerId: 'rita', system: 'S', task: 'T', maxUses: 1, allowedDomains: ['a.de'], blockedDomains: ['b.de'] }), /entweder/)
    })
    test('Plattformen mit Bot-Verbot werden nie abgerufen (Übersicht §2.11): web_fetch sperrt sie immer, auch bei allowedDomains', async () => {
      for (const d of ['kleinanzeigen.de', 'facebook.com', 'upwork.com', 'myhammer.de', 'freelancermap.de', 'freelance.de']) assert.ok(BOT_VERBOT_DOMAINS.includes(d), d)
      const run = async (extra) => { const sdk = fakeSdk([done]); await createClaude({ apiKey: 'k', sdk }).search(makeDeps(), { workerId: 'rita', system: 'S', task: 'T', maxUses: 2, ...extra }); return sdk.calls[0].tools }
      let tools = await run({}); assert.deepEqual(tools[1].blocked_domains, BOT_VERBOT_DOMAINS); assert.ok(!('blocked_domains' in tools[0]))
      tools = await run({ blockedDomains: ['x.de'] }); assert.deepEqual(tools[1].blocked_domains, [...BOT_VERBOT_DOMAINS, 'x.de'])
      tools = await run({ allowedDomains: ['a.de', 'kleinanzeigen.de', 'm.facebook.com'] }); assert.deepEqual(tools[0].allowed_domains, ['a.de', 'kleinanzeigen.de', 'm.facebook.com']); assert.deepEqual(tools[1].allowed_domains, ['a.de']); assert.ok(!('blocked_domains' in tools[1]))
      tools = await run({ allowedDomains: ['kleinanzeigen.de'] }); assert.deepEqual(tools.map((t) => t.name), ['web_search'])   // nichts Abrufbares übrig → kein web_fetch
    })
    test('pause_turn: Fortsetzungen mit angehängtem Assistenten-Inhalt, Kosten und Suchen summiert, Quellen ohne Doppelte', async () => {
      const sdk = fakeSdk([pause('s0'), pause('s1'), done]); const deps = makeDeps()
      const r = await createClaude({ apiKey: 'k', sdk }).search(deps, { workerId: 'rita', system: 'S', task: 'T', maxUses: 5 })
      assert.equal(sdk.calls.length, 3); assert.equal(sdk.calls[2].messages.length, 3); assert.equal(r.text, 'Fund A'); assert.deepEqual(r.sources, [{ url: 'https://a.de', title: 'A' }]); assert.equal(r.incomplete, false)
      const d = await getDaily(deps, '2026-10-19'); assert.equal(d.searches, 2); near(r.costUsd, d.costUsd)
    })
    test('höchstens 5 Fortsetzungen: 6 Aufrufe, incomplete=true', async () => {
      const sdk = fakeSdk([pause('s')]); const r = await createClaude({ apiKey: 'k', sdk }).search(makeDeps(), { workerId: 'rita', system: 'S', task: 'T', maxUses: 5 })
      assert.equal(sdk.calls.length, 6); assert.equal(r.incomplete, true)
    })
    test('robots/Zugriffsfehler eines Tools: toolErrors statt Wurf, kein Zusatzaufruf', async () => {
      const res = msg({ content: [{ type: 'server_tool_use', id: 'srv1', name: 'web_fetch', input: { url: 'https://x.de' } }, { type: 'web_fetch_tool_result', tool_use_id: 'srv1', content: { type: 'web_fetch_tool_result_error', error_code: 'url_not_allowed' } }, { type: 'text', text: 'nichts' }] })
      const sdk = fakeSdk([res]); const r = await createClaude({ apiKey: 'k', sdk }).search(makeDeps(), { workerId: 'rita', system: 'S', task: 'T', maxUses: 2 })
      assert.deepEqual(r.toolErrors, [{ tool: 'web_fetch', code: 'url_not_allowed', url: 'https://x.de' }]); assert.equal(sdk.calls.length, 1)
    })
    test('searchModel überschreibt das Rollenmodell und bestimmt die Kosten', async () => {
      const sdk = fakeSdk([done]); const r = await createClaude({ apiKey: 'k', sdk, searchModel: 'claude-sonnet-5-5' }).search(makeDeps(), { workerId: 'rita', system: 'S', task: 'T', maxUses: 1 })
      assert.equal(sdk.calls[0].model, 'claude-sonnet-5-5'); near(r.costUsd, costOf('claude-sonnet-5-5', done.usage))
    })
    test('Budget: Pause → BudgetStop ohne Aufruf; Console-Fehler in der Fortsetzung → Kosten der ersten Antwort bleiben gebucht', async () => {
      const deps = makeDeps(); await updateSettings(deps, { paused: true }, 't'); const sdk = fakeSdk([done])
      await assert.rejects(createClaude({ apiKey: 'k', sdk }).search(deps, { workerId: 'rita', system: 'S', task: 'T', maxUses: 1 }), BudgetStop); assert.equal(sdk.calls.length, 0)
      const d2 = makeDeps(); const b400 = apiError(400, { type: 'error', error: { type: 'invalid_request_error', message: 'You have reached your specified workspace API usage limits.' } })
      await assert.rejects(createClaude({ apiKey: 'k', sdk: fakeSdk([pause('s'), b400]) }).search(d2, { workerId: 'rita', system: 'S', task: 'T', maxUses: 1 }), (e) => e instanceof BudgetStop && e.reason === 'console')
      assert.ok((await getDaily(d2, '2026-10-19')).costUsd > 0)
    })
    test('batch: create reicht Anfragen durch (mit Budget-Vorabprüfung); results liefert Kosten mit Batch-Tarif', async () => {
      const sdk = fakeSdk([]); const created = []
      sdk.messages.batches = { create: async (b) => { created.push(b); return { id: 'b1', processing_status: 'in_progress' } }, retrieve: async () => ({ id: 'b1', processing_status: 'ended' }),
        results: async () => (async function* () { yield { custom_id: 'a', result: { type: 'succeeded', message: msg({ model: 'claude-sonnet-5-5', usage: { input_tokens: 1e6, output_tokens: 1e6 }, content: [{ type: 'text', text: 'T' }] }) } }; yield { custom_id: 'b', result: { type: 'errored' } } })() }
      const c = createClaude({ apiKey: 'k', sdk }); assert.equal((await c.batch.create(makeDeps(), [{ custom_id: 'a', params: { model: 'claude-haiku-5-5', max_tokens: 100, messages: [] } }])).id, 'b1'); assert.equal(created[0].requests.length, 1)
      const res = await c.batch.results('b1'); near(res[0].costUsd, 6); assert.deepEqual([res[0].customId, res[0].text, res[1].type, res[1].costUsd], ['a', 'T', 'errored', undefined])
    })
    

    checkModelle.test.js:

    const cap = (supported) => ({ capabilities: { server_tools: { web_search: { supported } } } })
    const all = (h, s) => ({ 'claude-haiku-5-5': h, 'claude-sonnet-5-5': s, 'claude-opus-5-5': cap(true) })
    test('Haiku kann suchen → keine Änderung', () => { const r = evaluate(all(cap(true), cap(true))); assert.deepEqual([r.exit, r.searchModel], [0, '']) })
    test('Haiku kann nicht → Fallback Sonnet mit .env-Zeile', () => {
      const r = evaluate(all(cap(false), cap(true))); assert.deepEqual([r.exit, r.searchModel], [0, 'claude-sonnet-5-5']); assert.ok(r.lines.includes('WORKERS_SEARCH_MODEL=claude-sonnet-5-5'))
    })
    test('Angabe fehlt → Exit 2 (echte Probe-Suche nötig); weder Haiku noch Sonnet → Exit 1', () => {
      assert.equal(evaluate(all({}, cap(true))).exit, 2); assert.equal(evaluate(all(cap(false), cap(false))).exit, 1)
    })
    test('Probe-Suche (--probe): Haiku ok → keine Änderung; Haiku verweigert + Sonnet ok → Fallback mit .env-Zeile; beide verweigert → Exit 1', () => {
      const h = evaluateProbe({ 'claude-haiku-5-5': { ok: true } }); assert.deepEqual([h.exit, h.searchModel], [0, ''])
      const f = evaluateProbe({ 'claude-haiku-5-5': { ok: false, reason: 'does not support web_search' }, 'claude-sonnet-5-5': { ok: true } })
      assert.deepEqual([f.exit, f.searchModel], [0, 'claude-sonnet-5-5']); assert.ok(f.lines.includes('WORKERS_SEARCH_MODEL=claude-sonnet-5-5'))
      assert.equal(evaluateProbe({ 'claude-haiku-5-5': { ok: false }, 'claude-sonnet-5-5': { ok: false } }).exit, 1)
    })
    
  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/claudeSearch.test.js test/checkModelle.test.js → FAIL (search is not a function, ERR_MODULE_NOT_FOUND für das Skript).

  • Step 3: Implementieren. search: assertCap(workerId, 'web_search'); allowedDomains und blockedDomains zusammen ⇒ Error('Entweder allowedDomains oder blockedDomains'); Modell = searchModel || MODELS[role]; web_fetch nur, wenn hasCap(workerId,'web_fetch'), und immer mit blocked_domains = BOT_VERBOT_DOMAINS + eigene blockedDomains (ohne Doppelte, Reihenfolge wie im Test); bei allowedDomains trägt web_fetch stattdessen allowed_domains = dieselbe Liste ohne Einträge, die gleich einer BOT_VERBOT_DOMAINS-Domain oder deren Subdomain sind (bleibt nichts übrig ⇒ kein web_fetch-Werkzeug; web_search behält die Liste unverändert, Suchtreffer sind erlaubt); Vorabprüfung checkBudget(deps, maxUses*0.01 + estimateUsd(...), { reply }). Schleife: Antwort sofort buchen (book, counters:{ searches, fetches, calls:1, tokensIn, tokensOut }); pause_turn ⇒ messages = [...messages, { role:'assistant', content }] und erneut, höchstens 5 Fortsetzungen, danach incomplete = true; Kosten pro Antwort mit costOf und summiert. text = alle Text-Blöcke aller Antworten; sources aus web_search_tool_result-Einträgen (web_search_result), doppelte URLs entfernt; toolErrors aus Blöcken web_search_tool_result/web_fetch_tool_result, deren content.type auf _tool_result_error endet (tool = Blocktyp ohne _tool_result, code = content.error_code, url aus dem server_tool_use-Block mit gleicher tool_use_id); SDK-Fehler wie in ask (gemeinsamer Helfer). batch.create: Budget-Vorabprüfung mit halbierter Schätzung, dann sdk.messages.batches.create({ requests }); results: for await über sdk.messages.batches.results(id), Kosten costOf(message.model, message.usage, { batch:true }), gebucht wird vom Aufrufer. scripts/check-modelle.mjs: evaluate(models) rein (Logik: Haiku supported === true ⇒ Exit 0; Angabe bei Haiku fehlt ⇒ Exit 2 „unklar – mit --probe prüfen“; Haiku false und Sonnet true ⇒ Exit 0 mit Zeile WORKERS_SEARCH_MODEL=claude-sonnet-5-5 und searchModel gesetzt; sonst Exit 1); evaluateProbe(probe) rein (Haiku ok ⇒ Exit 0, searchModel ''; Haiku nicht ok und Sonnet ok ⇒ Exit 0 mit derselben .env-Zeile; sonst Exit 1); main ruft new Anthropic({ apiKey }).models.retrieve(id) für die drei Modelle (Schlüssel aus ANTHROPIC_API_KEY, nie ausgeben) und druckt nur die Zeilen. Mit --probe (nur bei Exit 2 nötig, kostet etwa 0,02 $, wird nicht gebucht) folgt je Modell – erst Haiku, nur bei Fehlschlag Sonnet – ein echter Aufruf messages.create mit max_tokens: 300, Eingabe Suche kurz: Öffnungszeiten Rathaus Kaiserslautern. und tools: [{ type: 'web_search_20260318', name: 'web_search', max_uses: 1, allowed_callers: ['direct'] }]; ok = Antwort ohne Fehler und usage.server_tool_use.web_search_requests ≥ 1, bei einem API-Fehler ok:false mit auf 120 Zeichen gekürzter Meldung (reason), das Ergebnis geht an evaluateProbe.

  • Step 4: Tests laufen lassen. Gleicher Befehl → PASS (8 + 4 Tests).

  • Step 5: Commit. git add src/claude.js scripts/check-modelle.mjs test/claudeSearch.test.js test/checkModelle.test.js && git commit -m "P1-T14: Claude search mit pause_turn, batch, Modell-Prüfskript" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


P1-T15: Auftragswarteschlange jobs.js

Files:

  • Create: src/jobs.js
  • Test: test/jobs.test.js

Interfaces:

  • Produces: enqueue(deps, { kind, workerId, prospectId?, threadId?, previewId?, payload }) → Job; next(deps, { kinds }) → Job|null (ältester offen-Job der Arten, setzt laeuft, startedAt, attempts+1); finish(deps, jobId, result); fail(deps, jobId, error, { retry = true }); reclaimStale(deps, { olderThanMs }) → Anzahl; JOB_KINDS (aus SCHEMA); Job = { id, kind, workerId, status, prospectId, threadId, previewId, attempts, payload, createdAt } (Übersicht §6 + E8).

  • Step 1: Test schreiben.

    test('enqueue: offener Job, payload als JSON; ungültige Art/Worker/zu großes payload werfen', async () => {
      const deps = makeDeps(); const j = await enqueue(deps, { kind: 'suche', workerId: 'ali', payload: { room: 'aerzte' } })
      assert.deepEqual([j.status, j.attempts, j.payload], ['offen', 0, { room: 'aerzte' }]); assert.equal(deps.db.all('workersJobs')[0].payloadJson, '{"room":"aerzte"}')
      await assert.rejects(enqueue(deps, { kind: 'quatsch', workerId: 'ali', payload: {} }), /Auftragsart/)
      await assert.rejects(enqueue(deps, { kind: 'suche', workerId: 'niemand', payload: {} }), /Worker/)
      await assert.rejects(enqueue(deps, { kind: 'suche', workerId: 'ali', payload: { x: 'y'.repeat(16000) } }), /zu groß/)
    })
    test('next: ältester offener Job der gewünschten Arten; fremde Arten bleiben offen', async () => {
      const deps = makeDeps(); await enqueue(deps, { kind: 'bau', workerId: 'bob', payload: {} }); deps.clock.advance(1000)
      const a = await enqueue(deps, { kind: 'suche', workerId: 'ali', payload: { n: 1 } }); deps.clock.advance(1000); await enqueue(deps, { kind: 'suche', workerId: 'hanna', payload: { n: 2 } })
      const got = await next(deps, { kinds: ['suche'] }); assert.equal(got.id, a.id); assert.deepEqual([got.status, got.attempts], ['laeuft', 1])
      assert.equal((await next(deps, { kinds: ['suche'] })).payload.n, 2); assert.equal(await next(deps, { kinds: ['suche'] }), null)
      assert.equal(deps.db.all('workersJobs').find((d) => d.kind === 'bau').status, 'offen')
    })
    test('finish speichert resultJson; fail: Wiederholung bis 3 Versuche, dann fehler; retry:false sofort', async () => {
      const deps = makeDeps(); const a = await enqueue(deps, { kind: 'suche', workerId: 'ali', payload: {} })
      await next(deps, { kinds: ['suche'] }); await finish(deps, a.id, { found: 3 }); let d = deps.db.all('workersJobs')[0]; assert.deepEqual([d.status, d.resultJson], ['fertig', '{"found":3}']); assert.ok(d.finishedAt)
      const b = await enqueue(deps, { kind: 'suche', workerId: 'ali', payload: {} })
      for (let i = 1; i <= 3; i++) { await next(deps, { kinds: ['suche'] }); await fail(deps, b.id, 'x'.repeat(3000)); d = deps.db.all('workersJobs')[1]; assert.equal(d.status, i < 3 ? 'offen' : 'fehler') }
      assert.equal(d.error.length, 2000)
      const c = await enqueue(deps, { kind: 'suche', workerId: 'ali', payload: {} }); await next(deps, { kinds: ['suche'] }); await fail(deps, c.id, 'weg', { retry: false }); assert.equal(deps.db.all('workersJobs')[2].status, 'fehler')
    })
    test('reclaimStale: hängender laeuft-Job wird wieder offen, jüngerer und warten_batch nicht', async () => {
      const deps = makeDeps(); const old = await enqueue(deps, { kind: 'suche', workerId: 'ali', payload: {} }); await next(deps, { kinds: ['suche'] })
      deps.clock.advance(40 * 60000); const young = await enqueue(deps, { kind: 'suche', workerId: 'hanna', payload: {} }); await next(deps, { kinds: ['suche'] })
      deps.db.seed('workersJobs', { kind: 'bau', workerId: 'bob', status: 'warten_batch', startedAt: '2026-10-19T00:00:00.000Z' })
      assert.equal(await reclaimStale(deps, { olderThanMs: 30 * 60000 }), 1)
      const st = (id) => deps.db.all('workersJobs').find((d) => d.$id === id).status; assert.deepEqual([st(old.id), st(young.id)], ['offen', 'laeuft']); assert.equal(deps.db.all('workersJobs').find((d) => d.kind === 'bau').status, 'warten_batch')
    })
    
  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/jobs.test.js → FAIL ERR_MODULE_NOT_FOUND.

  • Step 3: Implementieren. next: list('workersJobs', [Query.equal('status',['offen']), Query.equal('kind', kinds), Query.orderAsc('$createdAt'), Query.limit(1)]) (nutzt Index idx_kind_status); Anspruch per update (ein Prozess, kein verteiltes Locking). payloadJson/resultJson > 16000 Zeichen ⇒ Error('payload zu groß'). fail: attempts < 3 && retry ⇒ offen (Fehlertext gespeichert), sonst fehler mit finishedAt. reclaimStale: laeuft-Jobs mit startedAt älter als olderThanMs ⇒ offen, error='neu gestartet nach Hänger'.

  • Step 4: Test laufen lassen. Gleicher Befehl → PASS (4 Tests).

  • Step 5: Commit. git add src/jobs.js test/jobs.test.js && git commit -m "P1-T15: Auftragswarteschlange" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


P1-T16: Normalisierung – Port aus WOMS mit „(0)“-Fix

Files:

  • Create: src/register/normalize.js
  • Test: test/normalize.test.js

Interfaces:

  • Produces: normalizeName(name), normalizePhone(phone), normalizeEmail(email), companyDomain(email), isFreemail(domain) – Kopie von /home/webklar/apps/ticket.webklar.com/dev-workers/src/lib/leadDuplicates.js (nur diese Funktionen und FREEMAIL/LEGAL_WORDS, ohne findDuplicates und Absage-Funktionen), mit Fix in normalizePhone (Übersicht §6).

  • Step 1: Test schreiben (die Fälle aus WOMS tests/unit/leadDuplicates.test.mjs plus die neuen):

    test('normalizeName wie WOMS', () => {
      assert.equal(normalizeName('Bäckerei Müller GmbH & Co. KG'), 'baeckerei mueller'); assert.equal(normalizeName('Café Rosé'), 'cafe rose')
      assert.equal(normalizeName('Friseur Amin e.K.'), 'friseur amin'); assert.equal(normalizeName('Straße-Bau UG (haftungsbeschränkt)'), 'strasse bau')
      assert.equal(normalizeName(''), ''); assert.equal(normalizeName(undefined), '')
    })
    test('companyDomain/normalizeEmail/isFreemail wie WOMS', () => {
      assert.equal(companyDomain('Info@Baecker-Mueller.de'), 'baecker-mueller.de'); assert.equal(companyDomain('x@gmail.com'), ''); assert.equal(companyDomain('x@web.de'), ''); assert.equal(companyDomain('kaputt'), '')
      assert.equal(normalizeEmail(' A@B.DE '), 'a@b.de'); assert.equal(isFreemail('gmx.de'), true); assert.equal(isFreemail('mueller.de'), false)
    })
    test('normalizePhone: alle Schreibweisen derselben Nummer ergeben dieselbe Zahl (inkl. "(0)"-Fix)', () => {
      for (const p of ['0631 123456', '0631/123456', '+49 631 123456', '0049 631 123456', '+49 (0) 631 123456', '+49(0)631/123456', '0049 (0)631 123456', '+49 0631 123456', '0631-123456'])
        assert.equal(normalizePhone(p), '0631123456', p)
      assert.equal(normalizePhone('12 34'), ''); assert.equal(normalizePhone(null), ''); assert.equal(normalizePhone('+43 1 234567'), '431234567')   // Ausland bleibt unverändert (ohne Plus)
    })
    
  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/normalize.test.js → FAIL ERR_MODULE_NOT_FOUND.

  • Step 3: Implementieren. Funktionskörper 1:1 aus leadDuplicates.js übernehmen (Kommentar mit Quelle und Stand d288301), FREEMAIL zusätzlich über isFreemail(domain) exportieren. Fix in normalizePhone: vor dem Ziffern-Ziehen (0) (/\(\s*0\s*\)/g) entfernen; nach erkannter deutscher Landesvorwahl (0049… oder +49…) den Rest nehmen, eine führende 0 davon entfernen und 0 voranstellen (damit +49 0631… nicht 00631… wird); sonst Verhalten unverändert (unter 6 Ziffern ⇒ '').

  • Step 4: Test laufen lassen. Gleicher Befehl → PASS (3 Tests).

  • Step 5: Commit. git add src/register/normalize.js test/normalize.test.js && git commit -m "P1-T16: Normalisierung aus WOMS mit (0)-Fix" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


P1-T17: Domain- und Schlüssel-Normalisierung

Files:

  • Modify: src/register/normalize.js
  • Test: test/normalizeDomain.test.js

Interfaces:

  • Produces: normalizeDomain(input) → string (eTLD+1 ohne www., '' für Müll/Verzeichnisse/Soziale/IPs; Shared-Hosting behält den vollen Host); normalizePhoneE164(input) → string; emailKey(input) → string; placeIdKey(input) → string; namePlzKey(name, plz) → string (Übersicht §6; '' = kein Schlüssel).

  • Step 1: Test schreiben.

    test('normalizeDomain: Müll, Markdown, Pfade, IDN, Subdomains', () => {
      const cases = [['https://www.Mueller-Backt.de/kontakt?x=1', 'mueller-backt.de'], ['[Müller](https://www.mueller-backt.de/)', 'mueller-backt.de'], ['mueller-backt.de', 'mueller-backt.de'], ['Mueller-Backt.DE.', 'mueller-backt.de'],
        ['shop.example.co.uk/x', 'example.co.uk'], ['http://blog.mueller-backt.de', 'mueller-backt.de'], ['keine', ''], ['nur IDENTICA-Ketten-Profilseite', ''], ['', ''], [null, ''], ['192.168.1.1', ''], ['http://localhost', '']]
      for (const [i, o] of cases) assert.equal(normalizeDomain(i), o, String(i))
      assert.match(normalizeDomain('http://bäckerei-müller.de'), /^xn--[a-z0-9-]+\.de$/)
    })
    test('Verzeichnisse und soziale Netze liefern keinen Schlüssel', () => {
      for (const u of ['https://www.facebook.com/baeckerei', 'instagram.com/x', 'https://g.page/x', 'https://www.google.com/maps/place/x', 'https://www.gelbeseiten.de/x', 'dasoertliche.de', 'https://www.11880.com/x', 'cylex.de', 'golocal.de', 'meinestadt.de', 'https://de.linkedin.com/in/x', 'xing.com/profile/x', 'youtube.com/@x'])
        assert.equal(normalizeDomain(u), '', u)
    })
    test('Shared-Hosting: volle Subdomain statt Plattform-Domain', () => {
      assert.equal(normalizeDomain('https://meinbetrieb.jimdofree.com/start'), 'meinbetrieb.jimdofree.com'); assert.equal(normalizeDomain('x.wixsite.com/shop'), 'x.wixsite.com')
      assert.equal(normalizeDomain('https://betrieb.business.site'), 'betrieb.business.site'); assert.equal(normalizeDomain('shop.myshopify.com'), 'shop.myshopify.com'); assert.equal(normalizeDomain('jimdofree.com'), '')
    })
    test('normalizePhoneE164: gleiche Nummer, gleiche Zahl', () => {
      for (const p of ['0631 123456', '0631/123456', '+49 (0) 631 123456', '0049 631 123456']) assert.equal(normalizePhoneE164(p), '+49631123456', p)
      assert.equal(normalizePhoneE164('0043 1 234567'), '+431234567'); assert.equal(normalizePhoneE164('+43 1 234567'), '+431234567'); assert.equal(normalizePhoneE164('12 34'), '')
    })
    test('emailKey', () => {
      for (const [i, o] of [[' Info@Mueller.DE ', 'info@mueller.de'], ['mailto:info@x.de', 'info@x.de'], ['Max <Max@X.de>', 'max@x.de'], ['kaputt', ''], ['a@b', ''], ['a@b@c.de', ''], [undefined, '']]) assert.equal(emailKey(i), o, String(i))
    })
    test('placeIdKey', () => {
      const id = 'ChIJN1t_tDeuEmsRUsoyG83frY4'; assert.equal(placeIdKey(id), id)
      assert.equal(placeIdKey(`https://www.google.com/maps/place/?q=place_id:${id}&x=1`), id); assert.equal(placeIdKey('https://www.google.com/maps/search/?api=1&query=Lackwerk'), ''); assert.equal(placeIdKey('abc'), '')
    })
    test('namePlzKey: nur mit Namen (mind. ein Wort) und fünfstelliger PLZ', () => {
      assert.equal(namePlzKey('Bäckerei Müller GmbH', '67655'), 'baeckerei mueller|67655'); assert.equal(namePlzKey('Bäckerei Müller', '67655 Kaiserslautern'), 'baeckerei mueller|67655')
      assert.equal(namePlzKey('Bäckerei Müller', ''), ''); assert.equal(namePlzKey('& Co. GmbH', '67655'), ''); assert.equal(namePlzKey('Bäckerei Müller', 'Kaiserslautern'), '')
    })
    
  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/normalizeDomain.test.js → FAIL does not provide an export named 'normalizeDomain'.

  • Step 3: Implementieren. normalizeDomain: Markdown [x](url) → url; Text mit Leerzeichen ohne Schema ⇒ ''; ohne Schema http:// voranstellen; new URL() (Fehler ⇒ '', Punycode kommt gratis); Host klein, www. und Schlusspunkt ab, IPv4/IPv6 und Hosts ohne Punkt ⇒ ''; Verzeichnis-/Soziale-Liste DIRECTORY (eTLD+1-Domains facebook.com, fb.com, instagram.com, g.page, gelbeseiten.de, dasoertliche.de, 11880.com, cylex.de, golocal.de, meinestadt.de, linkedin.com, xing.com, youtube.com, tiktok.com, twitter.com, x.com plus Labelregel google.*) ⇒ ''; Shared-Hosting-Suffixe SHARED_HOSTS (jimdofree.com, jimdosite.com, wixsite.com, business.site, myshopify.com, weebly.com, wordpress.com, blogspot.com, webnode.page, godaddysites.com, site123.me, bplaced.net, npage.de): Host mit Label davor bleibt voll, der nackte Suffix ⇒ ''; sonst eTLD+1 mit kleiner Mehrlabel-Suffixliste (co.uk, org.uk, ac.uk, gov.uk, com.au, co.at, or.at, co.nz, com.br, co.jp, com.tr). normalizePhoneE164: leer, wenn normalizePhone leer; + am Anfang und nicht +49 ⇒ + + Ziffern; 00… (nicht 0049) ⇒ + + Ziffern ohne 00; sonst +49 + Nummer ohne führende 0. emailKey: mailto: und <…> auflösen, trimmen, klein, genau ein @, Domain mit Punkt. placeIdKey: Regex /^ChIJ[\w-]{10,}$/ auf den Wert oder auf place_id[:=](ChIJ[^&\s]+). namePlzKey: normalizeName(name) nicht leer und erste \b\d{5}\b aus plz, Ergebnis <name>|<plz>.

  • Step 4: Test laufen lassen. Gleicher Befehl und test/normalize.test.js → PASS (7 + 3 Tests).

  • Step 5: Commit. git add src/register/normalize.js test/normalizeDomain.test.js && git commit -m "P1-T17: Domain-, Telefon-, Mail-, Place-ID- und Name+PLZ-Schlüssel" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


P1-T18: Register – keysFor, keyId, claimKeys

Files:

  • Create: src/register/keys.js
  • Test: test/keys.test.js

Interfaces:

  • Produces: keysFor(p) → [{ kind, value }] (p = { website, email, phone, placeId, adId, name, postalCode }, die ersten drei und name auch als Arrays; Reihenfolge dom (Website), dom (Mail-Domain ohne Freemail), mail, tel, pid, ad, namplz; leere und doppelte entfallen); keyId(kind, value) → 32 Hex-Zeichen; claimKeys(deps, keys, { prospectId = '', origin, blocked = false }) → { fresh, hits:[{kind,prospectId,blocked,origin}], hints:[…] } (Übersicht §6 + E7); prospectIdFor(keys) → string (E10: 'p' + erste 30 Zeichen von keyId des ersten Schlüssels mit kind ≠ namplz; ohne harten Schlüssel '').

  • Step 1: Test schreiben.

    const P1 = { website: 'https://www.mueller-backt.de/', email: 'Info@mueller-backt.de', phone: '0631 123456', placeId: 'ChIJN1t_tDeuEmsRUsoyG83frY4', name: 'Bäckerei Müller GmbH', postalCode: '67655', adId: 'kleinanzeigen:2987654321' }
    test('keyId: sha256(kind:value), 32 Hex-Zeichen', () => assert.equal(keyId('dom', 'example.de'), createHash('sha256').update('dom:example.de').digest('hex').slice(0, 32)))
    test('prospectIdFor: deterministisch aus dem ersten harten Schlüssel (31 Zeichen); ohne harten Schlüssel leer', () => {
      const id = prospectIdFor(keysFor(P1)); assert.equal(id, `p${keyId('dom', 'mueller-backt.de').slice(0, 30)}`); assert.equal(id.length, 31); assert.equal(prospectIdFor(keysFor(P1)), id)
      assert.equal(prospectIdFor(keysFor({ name: 'Bäckerei Müller', postalCode: '67655' })), ''); assert.equal(prospectIdFor([]), '')
    })
    test('keysFor: alle Arten, ohne Leere und Doppelte; Freemail liefert keine Domain; Arrays erlaubt', () => {
      assert.deepEqual(keysFor(P1), [{ kind: 'dom', value: 'mueller-backt.de' }, { kind: 'mail', value: 'info@mueller-backt.de' }, { kind: 'tel', value: '+49631123456' }, { kind: 'pid', value: 'ChIJN1t_tDeuEmsRUsoyG83frY4' }, { kind: 'ad', value: 'kleinanzeigen:2987654321' }, { kind: 'namplz', value: 'baeckerei mueller|67655' }])
      assert.deepEqual(keysFor({ email: 'x@gmail.com' }), [{ kind: 'mail', value: 'x@gmail.com' }]); assert.deepEqual(keysFor({}), [])
      assert.deepEqual(keysFor({ website: ['a.de', 'https://b.de'], phone: ['0631 1', '0631 123456'] }).map((k) => k.value), ['a.de', 'b.de', '+49631123456'])
    })
    test('claimKeys: erster gewinnt; Rest des abgelehnten Kandidaten wird trotzdem eingetragen; Dokument ohne Klartext', async () => {
      const deps = makeDeps(); const a = await claimKeys(deps, keysFor(P1), { prospectId: 'pA', origin: 'workers' })
      assert.deepEqual([a.fresh, a.hits, a.hints], [true, [], []])
      assert.deepEqual(Object.keys(deps.db.all('workersKeys')[0]).filter((k) => !k.startsWith('$')).sort(), ['blocked', 'createdAt', 'hint', 'kind', 'origin', 'prospectId'])
      const b = await claimKeys(deps, [{ kind: 'mail', value: 'info@mueller-backt.de' }, { kind: 'tel', value: '+4963199999' }], { prospectId: 'pB', origin: 'workers' })
      assert.equal(b.fresh, false); assert.deepEqual(b.hits, [{ kind: 'mail', prospectId: 'pA', blocked: false, origin: 'workers' }]); assert.ok(await deps.db.get('workersKeys', keyId('tel', '+4963199999')))
    })
    test('Wiederholung nach Absturz: gleiche prospectId ist wieder fresh, fremde prospectId bleibt Treffer', async () => {
      const deps = makeDeps(); await claimKeys(deps, keysFor(P1), { prospectId: 'pA', origin: 'workers' })
      const again = await claimKeys(deps, keysFor(P1), { prospectId: 'pA', origin: 'workers' }); assert.deepEqual([again.fresh, again.hits], [true, []])
      assert.equal((await claimKeys(deps, keysFor(P1), { prospectId: 'pZ', origin: 'workers' })).fresh, false)
    })
    test('namplz ist nur Hinweis: fresh bleibt true, hints gefüllt (auch mit blocked)', async () => {
      const deps = makeDeps(); await claimKeys(deps, [{ kind: 'namplz', value: 'baeckerei mueller|67655' }], { origin: 'seed_kunde', blocked: true })
      const c = await claimKeys(deps, [{ kind: 'namplz', value: 'baeckerei mueller|67655' }], { prospectId: 'pC', origin: 'workers' })
      assert.deepEqual([c.fresh, c.hits, c.hints.length, c.hints[0].blocked], [true, [], 1, true])
    })
    test('blocked:true hebt bestehende Schlüssel auf gesperrt (Besitzer und Origin bleiben)', async () => {
      const deps = makeDeps(); await claimKeys(deps, [{ kind: 'mail', value: 'a@b.de' }], { prospectId: 'pA', origin: 'workers' })
      const r = await claimKeys(deps, [{ kind: 'mail', value: 'a@b.de' }], { origin: 'seed_kunde', blocked: true })
      const d = await deps.db.get('workersKeys', keyId('mail', 'a@b.de')); assert.deepEqual([d.blocked, d.origin, d.prospectId], [true, 'workers', 'pA']); assert.deepEqual(r.hits, [{ kind: 'mail', prospectId: 'pA', blocked: true, origin: 'workers' }])
    })
    test('Startbestand (prospectId leer) zweimal: zweiter Lauf = nur Treffer; leere Werte entfallen; ungültige origin wirft', async () => {
      const deps = makeDeps(); const ks = [{ kind: 'mail', value: 'a@firma.de' }, { kind: 'tel', value: '' }]
      assert.equal((await claimKeys(deps, ks, { origin: 'seed_alt' })).fresh, true); assert.equal(deps.db.all('workersKeys').length, 1)
      const second = await claimKeys(deps, ks, { origin: 'seed_alt' }); assert.equal(second.fresh, false); assert.equal(second.hits.length, 1); assert.equal(deps.db.all('workersKeys').length, 1)
      await assert.rejects(claimKeys(deps, ks, { origin: 'irgendwer' }), /origin/)
    })
    
  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/keys.test.js → FAIL ERR_MODULE_NOT_FOUND (src/register/keys.js).

  • Step 3: Implementieren. claimKeys je Schlüssel nacheinander (nie abbrechen): create('workersKeys', { kind, prospectId?, origin, blocked, hint: kind === 'namplz', createdAt }, keyId(kind,value)); bei ConflictError bestehendes Dokument lesen; ist blocked angefragt und bestehend nicht gesperrt ⇒ update({ blocked:true }) (und im Ergebnis blocked:true); gehört der Schlüssel derselben nicht-leeren prospectId ⇒ kein Treffer; sonst hits.push(…) (namplz ⇒ hints). fresh = hits.length === 0. origin ∉ {workers, seed_alt, seed_kunde} ⇒ Fehler. Leere Werte überspringen; prospectId leer ⇒ Feld weglassen. prospectIdFor wie in den Interfaces.

  • Step 4: Test laufen lassen. Gleicher Befehl → PASS (8 Tests).

  • Step 5: Commit. git add src/register/keys.js test/keys.test.js && git commit -m "P1-T18: Nie-doppelt-Register mit claimKeys" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


P1-T19: Sperrliste suppress / isSuppressed

Files:

  • Modify: src/register/keys.js
  • Test: test/suppression.test.js

Interfaces:

  • Produces: suppress(deps, { email?, domain?, phone? }, reason, source) → { created: ['mail','dom','tel'…] } (idempotent; reason ∈ abmeldung, widerspruch, nein, beschwerde, bounce, kunde, manuell); isSuppressed(deps, { email?, domain?, phone? }) → { suppressed, reason } (Übersicht §6 + E7). Dokument workersSuppression mit $id = keyId(kind, wert) (mail = emailKey, dom = normalizeDomain, tel = normalizePhoneE164).

  • Step 1: Test schreiben.

    test('Freemail-Widerspruch sperrt nur die Adresse, nie gmail.com', async () => {
      const deps = makeDeps(); await suppress(deps, { email: 'Max@Gmail.com' }, 'widerspruch', 'nora')
      assert.deepEqual(deps.db.all('workersSuppression').map((d) => d.kind), ['mail'])
      assert.equal((await isSuppressed(deps, { email: 'max@gmail.com' })).suppressed, true)
      assert.equal((await isSuppressed(deps, { email: 'andere@gmail.com' })).suppressed, false); assert.equal((await isSuppressed(deps, { domain: 'gmail.com' })).suppressed, false)
    })
    test('Firmen-Adresse sperrt Adresse und Domain; Telefon in jeder Schreibweise', async () => {
      const deps = makeDeps(); const r = await suppress(deps, { email: 'info@mueller.de', phone: '0631 123456' }, 'abmeldung', 'mensch:u1')
      assert.deepEqual(r.created.sort(), ['dom', 'mail', 'tel']); assert.ok(await deps.db.get('workersSuppression', keyId('dom', 'mueller.de')))
      assert.deepEqual(await isSuppressed(deps, { email: 'chef@mueller.de' }), { suppressed: true, reason: 'abmeldung' })
      assert.equal((await isSuppressed(deps, { phone: '+49 (0) 631 123456' })).suppressed, true); assert.equal((await isSuppressed(deps, { domain: 'https://www.mueller.de/kontakt' })).suppressed, true)
    })
    test('idempotent: zweiter Aufruf legt nichts an, erster Grund bleibt', async () => {
      const deps = makeDeps(); await suppress(deps, { email: 'a@firma.de' }, 'nein', 'nora'); const r = await suppress(deps, { email: 'a@firma.de' }, 'beschwerde', 'nora')
      assert.deepEqual(r.created, []); assert.equal((await isSuppressed(deps, { email: 'a@firma.de' })).reason, 'nein')
    })
    test('Register-Sperren zählen mit: Kunde und Alt-Lead "kontaktiert" gesperrt, normaler Alt-Lead nicht', async () => {
      const deps = makeDeps()
      await claimKeys(deps, [{ kind: 'mail', value: 'kunde@firma.de' }], { origin: 'seed_kunde', blocked: true }); await claimKeys(deps, [{ kind: 'tel', value: '+49631111111' }], { origin: 'seed_alt', blocked: true })
      await claimKeys(deps, [{ kind: 'mail', value: 'neu@lead.de' }], { origin: 'seed_alt' })
      assert.deepEqual(await isSuppressed(deps, { email: 'kunde@firma.de' }), { suppressed: true, reason: 'kunde' }); assert.deepEqual(await isSuppressed(deps, { phone: '0631 111111' }), { suppressed: true, reason: 'manuell' })
      assert.equal((await isSuppressed(deps, { email: 'neu@lead.de' })).suppressed, false)
    })
    test('ungültiger Grund wirft; leere Eingabe → nicht gesperrt', async () => { await assert.rejects(suppress(makeDeps(), { email: 'a@b.de' }, 'egal', 'x'), /Grund/); assert.deepEqual(await isSuppressed(makeDeps(), {}), { suppressed: false, reason: null }) })
    
  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/suppression.test.js → FAIL (suppress is not a function).

  • Step 3: Implementieren. Schlüsselableitung: mail = emailKey(email); dom = normalizeDomain(domain) und zusätzlich normalizeDomain(companyDomain(email)) (Freemail ⇒ nichts); tel = normalizePhoneE164(phone). suppress: je Schlüssel create('workersSuppression', { kind, reason, createdAt, source }, keyId(kind, wert)), ConflictError ignorieren. isSuppressed: erst workersSuppression (Reihenfolge mail, dom, tel), dann workersKeys mit blocked === true für dieselben Schlüssel (Reason: Origin seed_kunde ⇒ kunde, sonst manuell).

  • Step 4: Test laufen lassen. Gleicher Befehl und test/keys.test.js → PASS (5 + 8 Tests).

  • Step 5: Commit. git add src/register/keys.js test/suppression.test.js && git commit -m "P1-T19: Sperrliste mit Freemail-Schutz" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


P1-T20: Otto – Nachtplan

Files:

  • Create: src/otto/nachtplan.js
  • Test: test/nachtplan.test.js

Interfaces:

  • Consumes: getSettings/updateSettings (T07), checkBudget (T11), enqueue (T15), emit (T06), assertCap/workerForRoom/roomLabel/ROLES (T05), nightKey/berlinNow (T08), Query (T04).

  • Produces: planNight(deps, { run = 1 }) → { planned:[{ room, workerId, region, jobId }], skipped:null|'pause'|'budget'|'keine_raeume' }. Job: kind 'suche', payload { night, run, room, region, candidates, entrance }; Ereignis auftrag (workerId 'otto', to, jobId, Text Otto schickt <Name> los: <Raumname>. für Anfragen-Räume, Otto schickt <Name> los: <Raumname> in <Region> (<n> Kandidaten). für Branchen-Räume).

  • Step 1: Tests schreiben.

    const logAt = (deps, room, region, at) => deps.db.seed('workersSearchLog', { workerId: workerForRoom(room), room, region, query: 'q', at, found: 10, fresh: 8 })
    const pick = (r) => r.planned.map((p) => [p.workerId, p.room, p.region])
    test('Lauf 1: Rita und Finn jede Nacht + ein Branchen-Raum nach Suchprotokoll; Lauf 2 nimmt die nächste Kombination', async () => {
      const deps = makeDeps({ now: '2026-10-19T20:00:00.000Z' })   // Montag 22:00 Berlin
      await updateSettings(deps, { connectedRooms: ['netz', 'foren', 'aerzte', 'handwerk'], regions: ['Rheinland-Pfalz', 'Saarland'], runsPerNight: 2 }, 't')
      logAt(deps, 'aerzte', 'Rheinland-Pfalz', '2026-10-18T22:00:00.000Z')
      assert.deepEqual(pick(await planNight(deps, { run: 1 })), [['rita', 'netz', ''], ['finn', 'foren', ''], ['ali', 'aerzte', 'Saarland']])
      assert.deepEqual(pick(await planNight(deps, { run: 2 })), [['hanna', 'handwerk', 'Rheinland-Pfalz']])
      const jobs = deps.db.all('workersJobs'); assert.equal(jobs.length, 4); assert.ok(jobs.every((j) => j.kind === 'suche' && j.status === 'offen'))
      assert.deepEqual(JSON.parse(jobs[2].payloadJson), { night: '2026-10-19', run: 1, room: 'aerzte', region: 'Saarland', candidates: 10, entrance: 'ohne_website' })
      assert.equal(JSON.parse(jobs[0].payloadJson).entrance, 'anfrage')
      const ev = deps.db.all('workersEvents').filter((e) => e.kind === 'auftrag'); assert.equal(ev.length, 4)
      assert.deepEqual([ev[2].workerId, ev[2].to, ev[2].jobId, ev[2].text], ['otto', 'ali', jobs[2].$id, 'Otto schickt Ali los: Ärzte & Praxen in Saarland (10 Kandidaten).'])
    })
    test('alle Kombinationen schon gesucht: die älteste gewinnt', async () => {
      const deps = makeDeps({ now: '2026-10-19T20:00:00.000Z' }); await updateSettings(deps, { connectedRooms: ['aerzte', 'handwerk'], regions: ['Rheinland-Pfalz', 'Saarland'] }, 't')
      logAt(deps, 'aerzte', 'Rheinland-Pfalz', '2026-10-10T20:00:00.000Z'); logAt(deps, 'aerzte', 'Saarland', '2026-10-12T20:00:00.000Z'); logAt(deps, 'handwerk', 'Rheinland-Pfalz', '2026-10-09T20:00:00.000Z'); logAt(deps, 'handwerk', 'Saarland', '2026-10-11T20:00:00.000Z')
      assert.deepEqual(pick(await planNight(deps, { run: 1 })), [['hanna', 'handwerk', 'Rheinland-Pfalz']])
    })
    test('nichts geplant: Pause (still), Budget erschöpft, kein angeschlossener Raum (ein leerlauf-Ereignis); Kim/Pia/inaktive Räume nie', async () => {
      let deps = makeDeps(); await updateSettings(deps, { paused: true, connectedRooms: ['netz'] }, 't')
      assert.deepEqual(await planNight(deps, { run: 1 }), { planned: [], skipped: 'pause' }); assert.equal(deps.db.all('workersJobs').length + deps.db.all('workersEvents').length, 0)
      deps = makeDeps(); await updateSettings(deps, { connectedRooms: ['netz'] }, 't'); await book(deps, { workerId: 'rita', usd: 9.9 })
      assert.equal((await planNight(deps, { run: 1 })).skipped, 'budget'); assert.equal(deps.db.all('workersJobs').length, 0)
      deps = makeDeps(); await updateSettings(deps, { connectedRooms: ['kleinanzeigen', 'portale'] }, 't')
      assert.deepEqual(await planNight(deps, { run: 1 }), { planned: [], skipped: 'keine_raeume' })
      assert.deepEqual(deps.db.all('workersEvents').map((e) => [e.workerId, e.kind]), [['otto', 'leerlauf']])
      deps = makeDeps(); await updateSettings(deps, { activeRooms: ['aerzte'], connectedRooms: ['netz', 'aerzte'] }, 't'); assert.deepEqual(pick(await planNight(deps, { run: 1 })).map((x) => x[1]), ['aerzte'])
    })
    
  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/nachtplan.test.js → FAIL ERR_MODULE_NOT_FOUND (src/otto/nachtplan.js).

  • Step 3: Implementieren. Ablauf: assertCap('otto','plan'); Einstellungen lesen, paused ⇒ { planned:[], skipped:'pause' } ohne Schreibzugriff; checkBudget(deps, 1) (Mindestrest 1 $) nicht ok ⇒ skipped:'budget'; Räume = SEARCH_ROOMS ∩ activeRooms ∩ connectedRooms. Anfragen: nur in run === 1 die Räume netz (Rita) und foren (Finn), region '', entrance 'anfrage' (Kim/Pia treibt der Tick alarme, E8). Ohne Websites: je Aufruf genau eine Kombination aus den Räumen der Etage ohne_website × settings.regions: sortiert nach „zuletzt gesucht“ aufsteigend (nie gesucht zuerst), Gleichstand nach Rollenreihenfolge, dann Regionsreihenfolge; „zuletzt gesucht“ = jüngstes at aus workersSearchLog (je Raum: Query.equal('room',[room]), orderDesc('at'), limit(100)) oder $createdAt eines suche-Jobs der letzten 48 h (Payload room+region), damit Lauf 2 nicht dieselbe Kombination plant. Je Eintrag erst enqueue, dann emit. Ist nach der Auswahl nichts geplant, genau ein leerlauf-Ereignis (Otto: Kein Raum angeschlossen, nichts zu planen.) und skipped:'keine_raeume'.

  • Step 4: Test laufen lassen. Gleicher Befehl → PASS (3 Tests).

  • Step 5: Commit. git add src/otto/nachtplan.js test/nachtplan.test.js && git commit -m "P1-T20: Otto plant die Nacht" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


P1-T21: Otto – Morgenbericht

Files:

  • Create: src/otto/bericht.js
  • Test: test/bericht.test.js

Interfaces:

  • Consumes: getSettings (T07), getDaily/monthDocs (T09), berlinNow (T08), formatUsd (T11), roomLabel (T05), emit (T06), deps.db.listAll/count, deps.notify (E3).

  • Produces: buildReport(deps) → { subject, text }; sendReport(deps) → notify-Ergebnis (bei sent:false zusätzlich Ereignis fehler, otto, Morgenbericht nicht gesendet: <reason>).

  • Step 1: Tests schreiben.

    test('Bericht: Zählzeilen, Kosten, Wartendes, Fehler – nur Ereignisse der letzten 24 h', async () => {
      const deps = makeDeps({ now: '2026-10-19T04:30:00.000Z' })   // Montag 06:30 Berlin
      const ev = (n, kind, workerId = 'kim', ts = '2026-10-19T00:30:00.000Z', text = 'x') => { for (let i = 0; i < n; i++) deps.db.seed('workersEvents', { ts, workerId, kind, text }) }
      ev(12, 'fund'); ev(5, 'doppelt', 'arne'); ev(7, 'bewertet', 'bea'); ev(3, 'top3', 'bea'); ev(4, 'botlead', 'arne'); ev(3, 'gesendet', 'max'); ev(1, 'eingang', 'nora'); ev(4, 'auftrag', 'otto')
      ev(1, 'fehler', 'kim', '2026-10-19T01:00:00.000Z', 'Kim: Postfach nicht erreichbar.'); ev(9, 'fund', 'kim', '2026-10-17T00:00:00.000Z')   // letztes: zu alt
      deps.db.seed('workersDaily', { costUsd: 6.4 }, '2026-10-18'); deps.db.seed('workersDaily', { costUsd: 0.3 }, '2026-10-19'); deps.db.seed('workersDaily', { costUsd: 34.4 }, '2026-10-05')
      for (const status of ['offen', 'in_arbeit', 'erledigt']) deps.db.seed('workersPult', { status })
      const { subject, text } = await buildReport(deps)
      assert.equal(subject, 'Nachtbericht 19.10.2026')
      for (const re of [/Gefunden: 12 · schon bekannt: 5 · bewertet: 7 · Top 3: 3 · Bot-Leads: 4/, /Gesendet: 3 · Antworten eingegangen: 1 · Deals: 0 · gesperrt: 0/, /Wartet auf dich: 2/, /Aufträge von Otto: 4/,
        /Kosten gestern: 6,40 \$ · heute bis jetzt: 0,30 \$ · Monat: 41,10 \$ von 250,00 \$ \(Tagesgrenze 10,00 \$\)/, /Fehler: 1 \(zuletzt: Kim: Postfach nicht erreichbar\.\)/,
        /Noch nicht angeschlossen: Netz-Radar, Kleinanzeigen, Portale, Foren & Fragen, Ärzte & Praxen, Handwerk, Selbstständige, Läden & Gastro/]) assert.match(text, re)
    })
    test('ruhige Nacht, Pause und Budget-Hinweise', async () => {
      const deps = makeDeps({ now: '2026-10-19T04:30:00.000Z' }); await updateSettings(deps, { paused: true, connectedRooms: ['netz'] }, 't')
      deps.db.seed('workersEvents', { ts: '2026-10-19T01:00:00.000Z', workerId: 'otto', kind: 'budget_warnung', text: 'Budget-Warnung' })
      const { text } = await buildReport(deps); assert.match(text, /Pause: aktiv/); assert.match(text, /Budget: Warnung ausgelöst/); assert.match(text, /Die Nacht war ruhig/); assert.ok(!/Netz-Radar/.test(text))
    })
    test('sendReport reicht Betreff/Text an notify; Fehlschlag wird als fehler-Ereignis sichtbar', async () => {
      const sent = []; const ok = makeDeps({ notify: async (m) => { sent.push(m); return { sent: true } } })
      assert.deepEqual(await sendReport(ok), { sent: true }); assert.match(sent[0].subject, /^Nachtbericht \d\d\.\d\d\.\d{4}$/)
      const bad = makeDeps({ notify: async () => ({ sent: false, reason: 'smtp_auth' }) }); await sendReport(bad)
      const e = bad.db.all('workersEvents').at(-1); assert.deepEqual([e.kind, e.workerId], ['fehler', 'otto']); assert.match(e.text, /Morgenbericht nicht gesendet: smtp_auth/)
    })
    
  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/bericht.test.js → FAIL ERR_MODULE_NOT_FOUND.

  • Step 3: Implementieren. Fenster: Ereignisse mit ts ≥ jetzt − 24 h (listAll mit Query.greaterThanEqual('ts', …)), gezählt nach kind; Kosten aus den Tagesdokumenten (gestern = Tag vor now.day, heute, Monat = monthDocs) und den Einstellungen; Pult = count mit status ∈ {offen, in_arbeit}; Zeilenformate wörtlich wie im Test; Zeile Fehler: n (zuletzt: <Text des neuesten fehler-Ereignisses>) nur bei n > 0; Budget: Warnung ausgelöst/Stopp ausgelöst nur bei entsprechenden Ereignissen im Fenster; Pause: aktiv nur bei paused; Noch nicht angeschlossen: … = aktive Räume (Reihenfolge SEARCH_ROOMS, Namen via roomLabel) ohne Anschluss, Zeile entfällt wenn leer; kam keine der gezählten Arten (fund, doppelt, bewertet, top3, botlead, gesendet, eingang, deal, gesperrt, fehler) vor, zusätzlich Die Nacht war ruhig: keine neuen Funde, keine Nachrichten.; Anrede Guten Morgen, hier ist Otto. Nachtbericht für den 19.10.2026.; Datum TT.MM.JJJJ aus now.day. sendReport ohne deps.notify ⇒ Ergebnis { sent:false, reason:'nicht_konfiguriert' } (und das fehler-Ereignis). Kein Modellaufruf.

  • Step 4: Test laufen lassen. Gleicher Befehl → PASS (3 Tests).

  • Step 5: Commit. git add src/otto/bericht.js test/bericht.test.js && git commit -m "P1-T21: Otto schreibt den Morgenbericht" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


P1-T22: Seed-Bausteine für Alt-Leads und Kunden

Files:

  • Create: src/register/seed.js, test/fixtures/seed-backup.json, test/fixtures/seed-master.json
  • Test: test/seed.test.js

Interfaces:

  • Consumes: keysFor (T18), normalizeDomain (T17).

  • Produces: leadEntries(backup, master) → Entry[] (backup = geparstes appwrite-backup.json, nur .leads; master = geparstes master.json, nur .leads); customerEntries(customers, projects) → Entry[]; Entry = { origin:'seed_alt'|'seed_kunde', blocked:boolean, keys:[{kind,value}] }.

  • Step 1: Fixtures und Test schreiben. Fixtures mit erfundenen Daten: seed-backup.json = { "leads": [A, B, C], "customers": [ { "name": "Fake", "portalPassword": "GEHEIM-PW-3" } ] } mit A = { leadId:'lack-demo-eins', firma:'Lack Demo Eins GmbH', email:'info@lack-demo-eins.de', telefon:'+49 (0) 631 111111', website:'[lack-demo-eins.de](https://www.lack-demo-eins.de/)', adresse:'Musterstr. 1, 67655 Kaiserslautern', googleMapsLink:'https://www.google.com/maps/search/?api=1&query=Lack+Demo', templateData:'{"GOOGLE_PLACE_ID":"ChIJdemoPlaceId0001","PLZ":"67655"}', crmStatus:'neu', portalLogin:'x', portalPasswort:'GEHEIM-PW-1' }, B = { leadId:'lack-demo-zwei', firma:'Zwei Lackierer', email:'zwei@gmx.de', telefon:'0631 222222', website:'keine', adresse:'Weg 2, 80331 München', templateData:'{}', crmStatus:'kontaktiert', portalPasswort:'GEHEIM-PW-2' }, C = { leadId:'lack-demo-drei', firma:'Drei Lack', email:'', telefon:'', website:'https://drei-lack.jimdofree.com/start', adresse:'Allee 3, 85551 Kirchheim', templateData:'{"PLZ":"85551"}', crmStatus:'neu' }; seed-master.json = { "leads": [ …dieselben drei als snake_case (lead_id, firma, email, telefon, website, adresse, google_maps_link, template_data als Objekt, portal_passwort:'GEHEIM-PW-5'), wobei der dritte email:'kontakt@drei-lack.de', telefon:'08991 333333' hat ] }.

    test('Alt-Leads: nur "kontaktiert" blockiert; Schlüssel bereinigt; Lücken aus master gefüllt; backup.customers ignoriert', () => {
      const e = leadEntries(backup, master)
      assert.deepEqual(e.map((x) => [x.origin, x.blocked]), [['seed_alt', false], ['seed_alt', true], ['seed_alt', false]])
      assert.deepEqual(e[0].keys, [{ kind: 'dom', value: 'lack-demo-eins.de' }, { kind: 'mail', value: 'info@lack-demo-eins.de' }, { kind: 'tel', value: '+49631111111' }, { kind: 'pid', value: 'ChIJdemoPlaceId0001' }, { kind: 'namplz', value: 'lack demo eins|67655' }])
      assert.deepEqual(e[1].keys, [{ kind: 'mail', value: 'zwei@gmx.de' }, { kind: 'tel', value: '+49631222222' }, { kind: 'namplz', value: 'zwei lackierer|80331' }])
      assert.deepEqual(e[2].keys, [{ kind: 'dom', value: 'drei-lack.jimdofree.com' }, { kind: 'dom', value: 'drei-lack.de' }, { kind: 'mail', value: 'kontakt@drei-lack.de' }, { kind: 'tel', value: '+498991333333' }, { kind: 'namplz', value: 'drei lack|85551' }])
    })
    test('unbekannter crmStatus gilt als gesperrt (nur leer oder "neu" ist frei)', () => {
      const b = structuredClone(backup); b.leads[0].crmStatus = 'verloren'; assert.equal(leadEntries(b, master)[0].blocked, true); b.leads[0].crmStatus = ''; assert.equal(leadEntries(b, master)[0].blocked, false)
    })
    const customers = [
      { $id: 'c1', name: 'Bäckerei Müller', companyName: 'Bäckerei Müller GmbH', email: 'info@mueller-backt.de; chef@gmail.com', phone: '0631 123456', postalCode: '67655', customerStatus: 'customer', portalPassword: 'GEHEIM-PW-4' },
      { $id: 'c2', name: 'Alter Lead', email: 'alt@lead.de', customerStatus: 'lead' }, { $id: 'c3', name: 'Abgesagt', email: 'weg@abgesagt.de', customerStatus: 'lost', archived: true }]
    const projects = [{ customerId: 'c1', liveDomain: 'mueller-backt.de' }, { customerId: 'c1', liveDomain: 'baeckerei.project.webklar.com' }]
    test('Kunden: alle Status (auch lost, archiviert, Lead) gesperrt; Mehrfachadressen; Vorschau-Hosts und eigene Domain ausgelassen', () => {
      const e = customerEntries(customers, projects)
      assert.equal(e.length, 3); assert.ok(e.every((x) => x.origin === 'seed_kunde' && x.blocked === true))
      assert.deepEqual(e[0].keys, [{ kind: 'dom', value: 'mueller-backt.de' }, { kind: 'mail', value: 'info@mueller-backt.de' }, { kind: 'mail', value: 'chef@gmail.com' }, { kind: 'tel', value: '+49631123456' }, { kind: 'namplz', value: 'baeckerei mueller|67655' }])
      assert.deepEqual(e[1].keys, [{ kind: 'dom', value: 'lead.de' }, { kind: 'mail', value: 'alt@lead.de' }])
    })
    test('Passwörter tauchen in keinem Ergebnis auf', () => { assert.ok(!JSON.stringify([leadEntries(backup, master), customerEntries(customers, projects)]).includes('GEHEIM')) })
    
  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/seed.test.js → FAIL ERR_MODULE_NOT_FOUND (src/register/seed.js).

  • Step 3: Implementieren. Felder nur per Whitelist lesen, nie das Objekt weiterreichen. Lead: je leadId (Backup zuerst, danach nur in master vorhandene) aus Backup firma, email, telefon, website, adresse, googleMapsLink, templateData (JSON-String, bei Parse-Fehler {}), crmStatus mit Lücken (leer) aus master (firma, email, telefon, website, adresse, google_maps_link, template_data-Objekt); placeId = templateData.GOOGLE_PLACE_ID sonst placeIdKey(googleMapsLink); PLZ = templateData.PLZ sonst erste \b\d{5}\b aus adresse; Schlüssel über keysFor({ website, email, phone, placeId, name:firma, postalCode }); blocked = crmStatus ∉ {'', 'neu', undefined}. Kunde: email an ;/, teilen, name: [name, companyName], website = alle liveDomain des Kunden aus projects (nach customerId), danach dom-Schlüssel webklar.com (eigene Vorschau-Hosts) entfernen; jeder Kunde (egal welcher customerStatus, auch archived) ⇒ blocked: true, origin: 'seed_kunde'; Einträge ohne Schlüssel entfallen nicht (leere keys erlaubt).

  • Step 4: Test laufen lassen. Gleicher Befehl → PASS (4 Tests).

  • Step 5: Commit. git add src/register/seed.js test/fixtures/seed-backup.json test/fixtures/seed-master.json test/seed.test.js && git commit -m "P1-T22: Seed-Bausteine für Alt-Leads und Kunden" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


P1-T23: Seed-Skript seed-register.mjs

Files:

  • Create: scripts/seed-register.mjs
  • Test: test/seedRunner.test.js

Interfaces:

  • Consumes: leadEntries/customerEntries (T22), claimKeys/keyId (T18), createDb/GESPERRTE_SAMMLUNGEN/Query (T04).

  • Produces: main({ argv, deps, readJson, log }) → { created, existing }; CLI node scripts/seed-register.mjs [--dry-run] [--backup <pfad>] [--master <pfad>] (Standard /root/reports/leads-auf-eis-2026-09-30/appwrite-backup.json, /opt/webklar-automation/master.json). Ausgabezeilen: TROCKENLAUF – es wird nichts geschrieben., Alte Leads: n (blockiert: m), Kunden: n (customer a, lead b, lost c, archiviert d), Schlüssel neu: n · schon vorhanden: m – nie Adressen, Nummern oder Namen.

  • Step 1: Tests schreiben (backup/master = die Fixtures aus T22, per readFileSync + JSON.parse geladen; Kunden und websiteProjects kommen über deps.db.seed, readJson liefert die Fixtures):

    const prepareCustomers = (deps) => {
      deps.db.seed('customers', { name: 'Bäckerei Müller', email: 'info@mueller-backt.de', phone: '0631 123456', postalCode: '67655', customerStatus: 'customer', portalPassword: 'GEHEIM-PW-4' }, 'c1')
      deps.db.seed('customers', { name: 'Altes Lead', email: 'alt@lead.de', customerStatus: 'lead' }, 'c2'); deps.db.seed('websiteProjects', { customerId: 'c1', liveDomain: 'mueller-backt.de' }, 'p1')
    }
    const run = async (deps, argv = []) => { const out = []; const files = { '/b.json': backup, '/m.json': master }
      const r = await main({ argv: ['--backup', '/b.json', '--master', '/m.json', ...argv], deps, readJson: async (p) => files[p], log: (l) => out.push(l) }); return { r, text: out.join('\n') } }
    test('Trockenlauf schreibt nichts; echter Lauf legt an; zweiter Lauf legt nichts mehr an; Ausgabe ohne Adressen', async () => {
      const deps = makeDeps(); prepareCustomers(deps)
      const dry = await run(deps, ['--dry-run']); assert.equal(deps.db.all('workersKeys').length, 0); assert.match(dry.text, /TROCKENLAUF/); assert.match(dry.text, /^Alte Leads: 3 \(blockiert: 1\)$/m)
      const real = await run(deps); const n = deps.db.all('workersKeys').length; assert.ok(n > 0); assert.equal(real.r.created, n); assert.equal(dry.r.created, n)
      const again = await run(deps); assert.equal(again.r.created, 0); assert.equal(deps.db.all('workersKeys').length, n)
      assert.ok(![dry.text, real.text, again.text].join('\n').includes('@'))
    })
    test('Kunden zuerst: gemeinsamer Schlüssel von Kunde und Alt-Lead bleibt gesperrt mit Origin seed_kunde', async () => {
      const deps = makeDeps(); deps.db.seed('customers', { name: 'Drei Lack GmbH', email: 'kontakt@drei-lack.de', customerStatus: 'customer' }, 'c9'); await run(deps)
      const d = await deps.db.get('workersKeys', keyId('mail', 'kontakt@drei-lack.de')); assert.deepEqual([d.blocked, d.origin], [true, 'seed_kunde'])
    })
    test('Kunden werden nur mit select-Whitelist geladen (portalPassword nie abgefragt) und nie beschrieben', async () => {
      const deps = makeDeps(); prepareCustomers(deps); await run(deps)
      const sel = deps.db.calls.find((c) => c.col === 'customers' && c.queries).queries.map((s) => JSON.parse(s)).find((q) => q.method === 'select')
      assert.ok(sel.values.includes('email') && sel.values.includes('customerStatus')); assert.ok(!sel.values.includes('portalPassword'))
      assert.ok(deps.db.calls.every((c) => !GESPERRTE_SAMMLUNGEN.includes(c.col) || ['get', 'list', 'listAll', 'count'].includes(c.op)))   // Übersicht §2.13: nur lesen
    })
    test('fehlende Datei → Fehler mit Pfad, nichts geschrieben', async () => {
      const deps = makeDeps(); const readJson = async (p) => { throw Object.assign(new Error('ENOENT'), { path: p }) }
      await assert.rejects(main({ argv: ['--backup', '/weg.json', '--master', '/m.json'], deps, readJson, log: () => {} }), /\/weg\.json/); assert.equal(deps.db.all('workersKeys').length, 0)
    })
    
  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/seedRunner.test.js → FAIL ERR_MODULE_NOT_FOUND (scripts/seed-register.mjs).

  • Step 3: Implementieren. main: Argumente parsen; Dateien per readJson (CLI: JSON.parse(readFile)); Kunden: listAll('customers', [Query.select(['name','companyName','contactName','email','phone','postalCode','customerStatus','archived'])]), Projekte: listAll('websiteProjects', [Query.select(['customerId','liveDomain'])]); Reihenfolge Kunden zuerst, dann Alt-Leads; je Eintrag claimKeys(deps, keys, { prospectId:'', origin, blocked }). Trockenlauf: keine Schreibzugriffe, stattdessen je Schlüssel get('workersKeys', keyId(…)) plus ein Set der in diesem Lauf schon gesehenen Schlüssel (zählt als „vorhanden“). created = Schlüssel − Treffer − Hinweise (nur neu angelegte). Ausgabe nur Zähler (Format siehe Interfaces); nicht lesbare Datei ⇒ Error('Datei nicht lesbar: <pfad>'), bevor irgendetwas geschrieben wird. CLI-Rahmen (nur bei direktem Start): Konfiguration APPWRITE_ENDPOINT (Standard https://appwrite.webklar.com/v1), APPWRITE_PROJECT_ID (Standard 6a1058610003c5a13a05), APPWRITE_API_KEY (fehlt ⇒ Name nennen, Exit 2), deps = { db: createDb(…), clock: { now: () => Date.now() }, log: () => {} }, Exit 1 bei Fehler.

  • Step 4: Test laufen lassen. Gleicher Befehl → PASS (4 Tests).

  • Step 5: Commit. git add scripts/seed-register.mjs test/seedRunner.test.js && git commit -m "P1-T23: Seed-Skript für den Startbestand" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


P1-T24: HTTP-API – Kern (Auth, /health, /pause, /settings)

Files:

  • Create: src/api.js, test/support/apiBoot.js
  • Test: test/apiAuth.test.js

Interfaces:

  • Consumes: getSettings/updateSettings/publicSettings (T07), emit (T06), HttpError (T07).

  • Produces: createApi({ deps, appwrite:{endpoint,projectId}, version, fetchImpl = fetch, authTtlMs = 60000 }) → http.Server; Basis /api/workers; Routen laut Übersicht §6.2: GET /health (offen), POST /pause, GET|PUT /settings (Admin); Fehler { error } (E9). boot({ labels = ['mitarbeiter'], status = 200, fetchThrows = false, deps = makeDeps() }) (Testhelfer in test/support/apiBoot.js, Version 0.1.0) → { req(path, { method = 'GET', jwt = 'a.b.c', body }), calls, deps, close() } mit Fake-/account ({ $id:'u1', name:'Kenso', labels }).

  • Step 1: Tests schreiben (apiBoot.js: Server auf Port 0, fetchImpl liefert { $id:'u1', name:'Kenso', labels } bzw. Status/Fehler).

    test('/health ohne JWT', async () => { const t = await boot(); const r = await t.req('/health', { jwt: null }); assert.deepEqual([r.status, await r.json()], [200, { ok: true, version: '0.1.0' }]); await t.close() })
    test('ohne oder mit unlesbarem JWT → 401, Appwrite wird nicht gefragt', async () => {
      const t = await boot(); let r = await t.req('/settings', { jwt: null }); assert.deepEqual([r.status, (await r.json()).error], [401, 'Nicht angemeldet.'])
      r = await t.req('/pause', { method: 'POST', jwt: 'abc', body: { paused: true } }); assert.equal(r.status, 401); assert.equal(t.calls.length, 0); await t.close()
    })
    test('Portalkunde (Label fehlt oder customer) bekommt 403 ohne Daten', async () => {
      for (const labels of [[], ['customer']]) { const t = await boot({ labels })
        for (const [p, m] of [['/settings', 'GET'], ['/pause', 'POST'], ['/settings', 'PUT']]) { const r = await t.req(p, { method: m, body: m === 'GET' ? undefined : { paused: true } }); assert.equal(r.status, 403); assert.deepEqual(await r.json(), { error: 'Kein Zugriff.' }) }
        assert.equal(t.deps.db.all('workersSettings').length, 0); await t.close() }
    })
    test('Mitarbeiter: /pause ja, /settings nur für Admin', async () => {
      const t = await boot({ labels: ['mitarbeiter'] }); assert.equal((await t.req('/pause', { method: 'POST', body: { paused: true } })).status, 200)
      for (const m of ['GET', 'PUT']) { const r = await t.req('/settings', { method: m, body: m === 'PUT' ? { warnPct: 70 } : undefined }); assert.deepEqual([r.status, (await r.json()).error], [403, 'Nur für Admins.']) }; await t.close()
    })
    test('Admin: Einstellungen lesen (ohne previewsSent), ändern, Validierungsfehler 400 mit deutschem Text', async () => {
      const t = await boot({ labels: ['admin'] }); const g = await (await t.req('/settings')).json(); assert.equal(g.dailyBudgetUsd, 10); assert.ok(!('previewsSent' in g))
      const p = await t.req('/settings', { method: 'PUT', body: { dailyBudgetUsd: 12 } }); assert.equal((await p.json()).dailyBudgetUsd, 12)
      const bad = await t.req('/settings', { method: 'PUT', body: { mailColdPerDay: 4 } }); assert.equal(bad.status, 400); assert.match((await bad.json()).error, /mailColdPerDay/); await t.close()
    })
    test('Appwrite: 401 → "Sitzung abgelaufen.", nicht erreichbar → 502; Cache: gleiches JWT = ein /account-Aufruf', async () => {
      let t = await boot({ status: 401 }); let r = await t.req('/settings'); assert.deepEqual([r.status, (await r.json()).error], [401, 'Sitzung abgelaufen.']); await t.close()
      t = await boot({ fetchThrows: true }); r = await t.req('/settings'); assert.deepEqual([r.status, (await r.json()).error], [502, 'Anmeldedienst nicht erreichbar.']); await t.close()
      t = await boot({ labels: ['admin'] }); await t.req('/settings'); await t.req('/settings'); assert.equal(t.calls.length, 1); await t.req('/settings', { jwt: 'x.y.z' }); assert.equal(t.calls.length, 2); await t.close()
    })
    test('/pause: setzt, Ereignis nur bei Änderung; ungültiger Body 400, Riesen-Body 413; unbekannter Pfad 404, falsche Methode 405', async () => {
      const t = await boot({ labels: ['mitarbeiter'] }); await t.req('/pause', { method: 'POST', body: { paused: true } }); await t.req('/pause', { method: 'POST', body: { paused: true } })
      const ev = t.deps.db.all('workersEvents'); assert.equal(ev.length, 1); assert.deepEqual([ev[0].workerId, ev[0].kind], ['system', 'pause']); assert.match(ev[0].text, /Kenso/); assert.equal(JSON.parse(ev[0].metaJson).paused, true)
      assert.equal((await t.req('/pause', { method: 'POST', body: { paused: 'ja' } })).status, 400); assert.equal((await t.req('/pause', { method: 'POST', body: { x: 'y'.repeat(20000) } })).status, 413)
      assert.equal((await t.req('/gibtsnicht')).status, 404); assert.equal((await t.req('/pause', { method: 'DELETE' })).status, 405); await t.close()
    })
    
  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/apiAuth.test.js → FAIL ERR_MODULE_NOT_FOUND (src/api.js).

  • Step 3: Implementieren. Nach dem Muster /home/webklar/apps/serverpuls/src/api.js (HttpError, sendJson mit Cache-Control: no-store, JWT-Form ^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$ und ≤ 2048 Zeichen, Cache jwt → Promise 60 s mit Teilen laufender Anfragen, höchstens 500 Einträge, AbortSignal.timeout(5000)), aber: labels muss mitarbeiter oder admin enthalten (sonst 403 Kein Zugriff.), /settings verlangt admin (403 Nur für Admins.); Appwrite 401/403/404 auf /account ⇒ 401 Sitzung abgelaufen., sonstige Fehler/Netz ⇒ 502 Anmeldedienst nicht erreichbar.. Antworttexte: fehlendes JWT Nicht angemeldet., unbekannter Pfad Nicht gefunden., falsche Methode 405 Methode nicht erlaubt., ungültiges JSON/Feld 400 (Ungültige Anfrage. bzw. Text der ValidationError), Body > 16 KB 413 Anfrage zu groß., sonst 500 Interner Fehler. (Details nur ins Log). POST /pause: Body { paused: boolean }; nur bei Änderung updateSettings(deps, { paused }, user.$id) und emit (system, pause, Text Alle Workers pausiert (<Name>). bzw. Pause aufgehoben (<Name>)., meta:{ paused, by: user.$id }); Antwort { paused }. Routen-Tabelle so bauen, dass T25 weitere Pfade ergänzt.

  • Step 4: Test laufen lassen. Gleicher Befehl → PASS (7 Tests).

  • Step 5: Commit. git add src/api.js test/support/apiBoot.js test/apiAuth.test.js && git commit -m "P1-T24: HTTP-API mit Mitarbeiter-/Admin-Prüfung" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


P1-T25: API-Sichten /state, /events, /worker/:id/trace

Files:

  • Create: src/view.js
  • Modify: src/api.js
  • Test: test/view.test.js (nutzt boot aus test/support/apiBoot.js, T24)

Interfaces:

  • Consumes: getSettings, getDaily/monthDocs, ROLES/MODELS, Query, NotFoundError/ValidationError.

  • Produces (Übersicht §6.2, E9): buildState(deps) → { paused, workers:[{ workerId, status:'arbeitet'|'wartet'|'leerlauf'|'grau'|'fehler', task, counters, connected }], kpis:{ newAbosMonth, mrrNewEur, dealsMonth, openThreads, pultOpen, botLeads, apiTodayUsd, apiMonthUsd, dailyBudgetUsd, monthlyBudgetUsd }, lastEventTs }; buildEvents(deps, { after?, limit? }) → { events:[{ id, ts, workerId, kind, text, link, to, prospectId, costUsd, meta }] } (aufsteigend; Limit 1–200, Standard 100); buildTrace(deps, workerId, { limit? }) → { worker:{ …ROLES-Eintrag, modelId }, today:{ counters, tokens:{in,out}, costUsd }, events } (neueste zuerst, Limit 1–50, Standard 20). Routen GET /state, GET /events?after=&limit=, GET /worker/:workerId/trace?limit= für Team.

  • Step 1: Tests schreiben (test/view.test.js; Welt per deps.db.seed(...)):

    async function world(deps) {
      await updateSettings(deps, { connectedRooms: ['netz', 'foren', 'aerzte'] }, 't')
      deps.db.seed('workersJobs', { workerId: 'rita', kind: 'suche', status: 'laeuft' }); deps.db.seed('workersJobs', { workerId: 'ali', kind: 'suche', status: 'offen' }); deps.db.seed('workersJobs', { workerId: 'otto', kind: 'bericht', status: 'offen' })
      deps.db.seed('workersEvents', { ts: '2026-10-19T08:55:00.000Z', workerId: 'ali', kind: 'fehler', text: 'Ali: Quelle nicht erreichbar.' }); deps.db.seed('workersEvents', { ts: '2026-10-19T08:50:00.000Z', workerId: 'sven', kind: 'fehler', text: 'Sven: Fehler.' })
      deps.db.seed('workersDaily', { costUsd: 2.5, deals: 1, countersJson: JSON.stringify({ rita: { gelesen: 5, tokensIn: 100, tokensOut: 50, costUsd: 0.1, calls: 2 } }) }, '2026-10-19'); deps.db.seed('workersDaily', { costUsd: 6.4, deals: 2 }, '2026-10-18')
      for (const status of ['offen', 'in_arbeit', 'erledigt']) deps.db.seed('workersPult', { status }); for (let i = 0; i < 3; i++) deps.db.seed('workersProspects', { status: 'botlead' }); deps.db.seed('workersProspects', { status: 'gefunden' })
      deps.db.seed('workersThreads', { status: 'offen' }); deps.db.seed('workersThreads', { status: 'abgeschlossen' })
      deps.db.seed('workersOffers', { status: 'bezahlt', productKey: 'hosting', paidAt: '2026-10-10T10:00:00.000Z' }); deps.db.seed('workersOffers', { status: 'offen' })
    }
    test('buildState: Rangfolge grau > fehler > arbeitet > wartet > leerlauf; Zähler ohne Token-/Kostenfelder; Kennzahlen', async () => {
      const deps = makeDeps(); await world(deps); const s = await buildState(deps); const w = Object.fromEntries(s.workers.map((x) => [x.workerId, x]))
      assert.equal(s.workers.length, 19); assert.equal(s.paused, false)
      assert.deepEqual([w.rita.status, w.rita.connected, w.rita.counters], ['arbeitet', true, { gelesen: 5 }])
      assert.deepEqual([w.ali.status, w.ali.task], ['fehler', 'Ali: Quelle nicht erreichbar.']); assert.equal(w.sven.status, 'grau'); assert.equal(w.sven.task, 'noch nicht angeschlossen')
      assert.equal(w.finn.status, 'leerlauf'); assert.deepEqual([w.otto.status, w.otto.task, w.otto.connected], ['wartet', 'Wartet auf Start: Bericht', true])
      assert.deepEqual(s.kpis, { newAbosMonth: 1, mrrNewEur: 20, dealsMonth: 3, openThreads: 1, pultOpen: 2, botLeads: 3, apiTodayUsd: 2.5, apiMonthUsd: 8.9, dailyBudgetUsd: 10, monthlyBudgetUsd: 250 })
      assert.equal(s.lastEventTs, '2026-10-19T08:55:00.000Z')
    })
    test('buildState auf leerer DB: nichts angeschlossen = alles grau außer Otto, Kennzahlen 0, lastEventTs null', async () => {
      const s = await buildState(makeDeps()); assert.equal(s.workers.filter((w) => w.status === 'grau').length, 18); assert.equal(s.workers.find((w) => w.workerId === 'otto').status, 'leerlauf')
      assert.deepEqual(s.kpis, { newAbosMonth: 0, mrrNewEur: 0, dealsMonth: 0, openThreads: 0, pultOpen: 0, botLeads: 0, apiTodayUsd: 0, apiMonthUsd: 0, dailyBudgetUsd: 10, monthlyBudgetUsd: 250 }); assert.equal(s.lastEventTs, null)
    })
    test('buildEvents: aufsteigend ab after (strikt größer), Limit geklemmt, ohne after die letzten N aufsteigend, meta geparst; ungültiges Datum → ValidationError', async () => {
      const deps = makeDeps(); for (let i = 1; i <= 5; i++) deps.db.seed('workersEvents', { ts: `2026-10-19T08:00:0${i}.000Z`, workerId: 'otto', kind: 'leerlauf', text: `e${i}`, metaJson: `{"n":${i}}` }, `e${i}`)
      assert.deepEqual((await buildEvents(deps, { after: '2026-10-19T08:00:02.000Z' })).events.map((e) => e.id), ['e3', 'e4', 'e5'])
      assert.deepEqual((await buildEvents(deps, { after: '2026-10-19T08:00:02.000+00:00' })).events.map((e) => e.id), ['e3', 'e4', 'e5'])   // Offset-Schreibweise gleichwertig
      const last2 = (await buildEvents(deps, { limit: 2 })).events; assert.deepEqual(last2.map((e) => e.id), ['e4', 'e5']); assert.deepEqual(last2[1].meta, { n: 5 })
      assert.equal((await buildEvents(deps, { limit: 999 })).events.length, 5); await assert.rejects(buildEvents(deps, { after: 'gestern' }), ValidationError)
    })
    test('Zeitstempel werden immer als ISO mit Z ausgegeben (Appwrite liefert "+00:00")', async () => {
      const deps = makeDeps(); deps.db.seed('workersEvents', { ts: '2026-10-19T08:00:06.000+00:00', workerId: 'otto', kind: 'leerlauf', text: 'x' })
      assert.equal((await buildEvents(deps, {})).events[0].ts, '2026-10-19T08:00:06.000Z'); assert.equal((await buildState(deps)).lastEventTs, '2026-10-19T08:00:06.000Z')
    })
    test('buildTrace: unbekannter Worker → NotFoundError; Worker-Block mit modelId; today; neueste zuerst', async () => {
      const deps = makeDeps(); await world(deps)
      deps.db.seed('workersEvents', { ts: '2026-10-19T08:01:00.000Z', workerId: 'rita', kind: 'gelesen', text: 'alt' }); deps.db.seed('workersEvents', { ts: '2026-10-19T08:02:00.000Z', workerId: 'rita', kind: 'fund', text: 'neu' })
      const t = await buildTrace(deps, 'rita', {})
      assert.deepEqual([t.worker.workerId, t.worker.model, t.worker.modelId], ['rita', 'haiku', 'claude-haiku-5-5']); assert.deepEqual(t.today, { counters: { gelesen: 5 }, tokens: { in: 100, out: 50 }, costUsd: 0.1 }); assert.deepEqual(t.events.map((e) => e.text), ['neu', 'alt'])
      await assert.rejects(buildTrace(deps, 'niemand', {}), NotFoundError)
    })
    test('Routen: Team 200, Portalkunde 403, unbekannter Worker 404, ungültiges after 400', async () => {
      const t = await boot({ labels: ['mitarbeiter'] }); await world(t.deps)
      for (const p of ['/state', '/events?limit=999', '/worker/rita/trace?limit=999']) assert.equal((await t.req(p)).status, 200)
      assert.equal((await (await t.req('/state')).json()).workers.length, 19); assert.equal((await t.req('/worker/niemand/trace')).status, 404); assert.equal((await t.req('/events?after=gestern')).status, 400); await t.close()
      const k = await boot({ labels: ['customer'] }); assert.equal((await k.req('/state')).status, 403); await k.close()
    })
    
  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/view.test.js → FAIL ERR_MODULE_NOT_FOUND (src/view.js).

  • Step 3: Implementieren. buildState: Einstellungen; Aufträge status ∈ {laeuft, offen} (limit 200); Ereignisse der letzten 30 min (ts absteigend, limit 200) – „aktuell fehlerhaft“ = jüngstes Ereignis des Workers innerhalb 15 min ist fehler; connected = room === 'bruecke' || connectedRooms.includes(room); Status in der Rangfolge grau (nicht angeschlossen) > fehler > arbeitet (Job laeuft) > wartet (Job offen) > leerlauf; task: grau ⇒ noch nicht angeschlossen, fehler ⇒ Text des Fehler-Ereignisses, arbeitet ⇒ Text des jüngsten Ereignisses des Workers (sonst JOB_LABEL), wartet ⇒ Wartet auf Start: <JOB_LABEL>, sonst ''; JOB_LABEL = deutsche Substantive für alle 15 Auftragsarten (suche → Suche, bericht → Bericht, alarm_lesen → Alarm-Postfach lesen, …); counters aus getDaily(heute).counters[workerId] ohne tokensIn, tokensOut, costUsd, calls. Kennzahlen: newAbosMonth/mrrNewEur aus workersOffers (Abfrage nur über den Index status = 'bezahlt', limit 500; paidAt ≥ Monatsanfang Berlin wird im Code gefiltert; Preise { hosting: 20, wartung: 50 }), dealsMonth = Summe deals aus monthDocs, openThreads = count Fäden mit Status offen/wartet_auf_kunde/wartet_auf_uns, pultOpen = count offen+in_arbeit, botLeads = count Interessenten botlead, apiTodayUsd/apiMonthUsd aus den Tagesdokumenten (alle Geldwerte auf 6 Nachkommastellen runden). buildEvents/buildTrace mit den Indizes ts bzw. workerId+ts; meta = geparstes metaJson oder null; ungültiges after (Date.parse = NaN) ⇒ ValidationError('Ungültiges Datum.'), gültiges wird als new Date(after).toISOString() in die Query gegeben; jedes ausgegebene ts/lastEventTs läuft durch new Date(x).toISOString() (E9); Limits klemmen statt ablehnen. Routen in api.js für Team (Pfadparameter URL-dekodieren, unbekannter Worker ⇒ 404 Unbekannter Worker.).

  • Step 4: Tests laufen lassen. Gleicher Befehl und test/apiAuth.test.js → PASS (6 + 7 Tests).

  • Step 5: Commit. git add src/view.js src/api.js test/view.test.js && git commit -m "P1-T25: API-Sichten state, events, trace" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


P1-T26: Tick, main.js, Abnahme-Skript jetzt.mjs

Files:

  • Create: src/tick.js, src/main.js, scripts/jetzt.mjs
  • Test: test/tick.test.js, test/main.test.js

Interfaces:

  • Consumes: isDue/runKey/berlinNow (T08), markRun/lastRun (T09), getSettings, emit, reclaimStale (T15), planNight, sendReport, createApi, createDb, createClaude, createNotifier, loadConfig.

  • Produces: createTick({ deps, jobs }) → { tick() } mit jobs: [{ kind, workerId, pauseSafe, enabled?(settings), run(deps, { now, settings }) }] (spätere Pläne hängen weitere Einträge an); DEFAULT_JOBS = nachtplan (planNight run 1), nacht2 (run 2, nur runsPerNight ≥ 2), bericht (pauseSafe), aufraeumen (Intervall 5 min, pauseSafe: reclaimStale 30 min); buildDeps(config, over = {}) → { db, clock, log, claude, notify }; start(env = process.env, over = {}) → Promise<{ server, stop(), tick }> (over: config, deps, port); runJetzt(art, deps) (Export aus scripts/jetzt.mjs, Arten bericht, nachtplan, nachtplan2; sonst Error('Unbekannte Art: …')). Imports in den Tests: createTick, DEFAULT_JOBS aus src/tick.js, start aus src/main.js, runJetzt aus scripts/jetzt.mjs, pkg = geparste package.json.

  • Step 1: Tests schreiben.

    const mkJobs = (log) => [
      { kind: 'nachtplan', workerId: 'otto', pauseSafe: false, run: async () => log.push('nachtplan') },
      { kind: 'nacht2', workerId: 'otto', pauseSafe: false, enabled: (s) => s.runsPerNight >= 2, run: async () => log.push('nacht2') },
      { kind: 'bericht', workerId: 'otto', pauseSafe: true, run: async () => log.push('bericht') },
      { kind: 'posteingang', workerId: 'nora', pauseSafe: true, run: async () => log.push('posteingang') }]
    const count = (log, k) => log.filter((x) => x === k).length
    test('Nachtplan läuft pro Nacht genau einmal – auch nach Neustart mitten in der Nacht', async () => {
      const log = []; const deps = makeDeps({ now: '2026-10-19T19:59:00Z' }); const t1 = createTick({ deps, jobs: mkJobs(log) })   // 21:59 Berlin
      await t1.tick(); assert.equal(count(log, 'nachtplan'), 0)
      deps.clock.set('2026-10-19T20:00:00Z'); await t1.tick(); deps.clock.advance(60000); await t1.tick(); assert.equal(count(log, 'nachtplan'), 1)
      const t2 = createTick({ deps, jobs: mkJobs(log) }); deps.clock.set('2026-10-19T21:30:00Z'); await t2.tick(); assert.equal(count(log, 'nachtplan'), 1)   // Neustart 23:30
      deps.clock.set('2026-10-20T20:00:00Z'); await t2.tick(); assert.equal(count(log, 'nachtplan'), 2)
    })
    test('Pause: nur pauseSafe-Arten laufen; Bericht ab 06:30 einmal pro Tag; nacht2 nur bei runsPerNight 2', async () => {
      const log = []; const deps = makeDeps({ now: '2026-10-20T04:29:00Z' }); await updateSettings(deps, { paused: true }, 't'); const t = createTick({ deps, jobs: mkJobs(log) })
      await t.tick(); assert.equal(count(log, 'bericht'), 0); deps.clock.set('2026-10-20T04:30:00Z'); await t.tick(); await t.tick(); assert.equal(count(log, 'bericht'), 1); assert.equal(count(log, 'nachtplan'), 0)
      await updateSettings(deps, { paused: false }, 't'); deps.clock.set('2026-10-20T23:30:00Z'); await t.tick(); assert.equal(count(log, 'nacht2'), 0)   // 01:30, runsPerNight 1
      await updateSettings(deps, { runsPerNight: 2 }, 't'); await t.tick(); assert.equal(count(log, 'nacht2'), 1)
    })
    test('Intervallart läuft sofort und danach erst nach dem Intervall (2 min)', async () => {
      const log = []; const deps = makeDeps({ now: '2026-10-19T08:00:00Z' }); const t = createTick({ deps, jobs: mkJobs(log) })
      await t.tick(); deps.clock.advance(60000); await t.tick(); assert.equal(count(log, 'posteingang'), 1); deps.clock.advance(60000); await t.tick(); assert.equal(count(log, 'posteingang'), 2)
    })
    test('überlappende Ticks laufen nicht parallel', async () => {
      let running = 0; let max = 0; const jobs = [{ kind: 'posteingang', workerId: 'nora', pauseSafe: true, run: async () => { running++; max = Math.max(max, running); await new Promise((r) => setTimeout(r, 20)); running-- } }]
      const t = createTick({ deps: makeDeps(), jobs }); await Promise.all([t.tick(), t.tick()]); assert.equal(max, 1)
    })
    test('Fehler eines Jobs: andere laufen weiter, fehler-Ereignis mit Text, kein Absturz', async () => {
      const log = []; const deps = makeDeps(); const jobs = [{ kind: 'bericht', workerId: 'otto', pauseSafe: true, run: async () => { throw new Error('Postfach kaputt') } }, mkJobs(log)[3]]
      await createTick({ deps, jobs }).tick(); assert.equal(count(log, 'posteingang'), 1)
      const e = deps.db.all('workersEvents'); assert.deepEqual([e[0].kind, e[0].workerId, e[0].text], ['fehler', 'otto', 'Fehler bei bericht: Postfach kaputt'])
    })
    test('DEFAULT_JOBS: nachtplan, nacht2, bericht, aufraeumen mit den erwarteten pauseSafe-Werten', () => assert.deepEqual(DEFAULT_JOBS.map((j) => [j.kind, j.pauseSafe]), [['nachtplan', false], ['nacht2', false], ['bericht', true], ['aufraeumen', true]]))
    

    main.test.js:

    test('start: /health antwortet, Tick lief, stop() beendet sauber; ohne Pflicht-Env ConfigError', async () => {
      const deps = makeDeps(); const app = await start({}, { config: { appwrite: { endpoint: 'http://aw/v1', projectId: 'p', apiKey: 'k' }, api: { port: 0 } }, deps, port: 0 })
      const r = await fetch(`http://127.0.0.1:${app.server.address().port}/api/workers/health`); assert.deepEqual(await r.json(), { ok: true, version: pkg.version }); await app.stop()
      await assert.rejects(start({}), (e) => e.missing.includes('APPWRITE_API_KEY'))
    })
    test('jetzt: bericht ruft notify, nachtplan plant (hier: leerlauf), unbekannte Art wirft', async () => {
      const sent = []; const deps = makeDeps({ notify: async (m) => { sent.push(m); return { sent: true } } })
      await runJetzt('bericht', deps); assert.equal(sent.length, 1); assert.deepEqual(await runJetzt('nachtplan', deps), { planned: [], skipped: 'keine_raeume' }); await assert.rejects(runJetzt('quatsch', deps), /Unbekannte Art/)
    })
    
  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/tick.test.js test/main.test.js → FAIL ERR_MODULE_NOT_FOUND (src/tick.js).

  • Step 3: Implementieren. tick(): Guard gegen Überlappung; now = berlinNow(clock); Einstellungen laden (Fehler ⇒ loggen, Abbruch); je Job in Tabellenreihenfolge: übersprungen bei paused && !pauseSafe oder enabled(settings) === false; key = runKey(kind, now); last = key ? await lastRun(deps, kind, key) : intervalLast.get(kind) ?? null; nicht fällig ⇒ weiter; Marker vor dem Lauf setzen (markRun bzw. intervalLast.set(kind, now.ts)), damit ein fehlschlagender Lauf nicht jede Minute wiederholt wird; run in try/catch: Log + emit({ workerId: job.workerId, kind:'fehler', text:'Fehler bei <kind>: <Message>' }) (selbst abgesichert, Text ohne Secrets/Stack). DEFAULT_JOBS (Tick-Tabelle, exportiert aus tick.js) wörtlich wie unter „Produces“; workerId otto (bei aufraeumen: system). buildDeps: db = createDb(config.appwrite), clock = { now: () => Date.now() }, log = console.log mit ISO-Zeitstempel, claude = createClaude({ apiKey: config.anthropicKey, searchModel: config.searchModel }), notify = createNotifier({ smtp: config.reportSmtp, getTo: async () => (await getSettings(base)).reportEmail, clock, log }).notify; over überschreibt jedes dieser Teile. start: loadConfig (oder over.config), createApi({ deps, appwrite: config.appwrite, version: package.json-Version }), server.listen(over.port ?? config.api.port), sofort ein Tick, danach setInterval(tick, 60000).unref(), unhandledRejection loggen statt abstürzen, stop() = Intervall löschen, server.close, nach höchstens 5 s Ausstieg; Signale SIGTERM/SIGINT rufen stop und beenden den Prozess (nur wenn direkt gestartet). Logzeile workers listening on <port>. scripts/jetzt.mjs <bericht|nachtplan|nachtplan2>: loadConfig(process.env) → buildDeps → runJetzt → druckt das Ergebnis als JSON (ohne Secrets); nur für die Abnahme, solange connectedRooms leer ist.

  • Step 4: Tests laufen lassen. Gleicher Befehl → PASS (6 + 2 Tests).

  • Step 5: Commit. git add src/tick.js src/main.js scripts/jetzt.mjs test/tick.test.js test/main.test.js && git commit -m "P1-T26: Tick, Dienststart und Abnahme-Skript" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


P1-T27: Container- und Betriebsbausteine

Files:

  • Create: Dockerfile, .dockerignore, deploy/compose-workers.yml, deploy/nginx-workers.conf, deploy/serverpuls-apps-eintrag.json
  • Test: test/deploy.test.js

Interfaces:

  • Produces: Text-Bausteine, die Kenso nach der Betriebsanleitung einfügt (Compose-Dienst workers, nginx-location, Serverpuls-Gruppe); Werte exakt Übersicht §9.

  • Step 1: Test schreiben.

    const read = (p) => readFileSync(new URL(`../${p}`, import.meta.url), 'utf8')
    test('Compose-Block: gehärtet nach Übersicht §9, nicht öffentlich, ohne Geheimnisse', () => {
      const c = read('deploy/compose-workers.yml')
      for (const l of ['container_name: webklar-workers', 'image: webklar-workers:latest', 'build: ./workers', 'restart: unless-stopped', 'init: true', 'mem_limit: 768m', 'memswap_limit: 768m', 'cpus: 1.0', 'user: node', 'read_only: true', '- /tmp', '- ALL', 'no-new-privileges:true',
        'max-size: "5m"', 'max-file: "2"', '- TZ=Europe/Berlin', '- NODE_OPTIONS=--max-old-space-size=384', '- APPWRITE_ENDPOINT=http://webklar-appwrite/v1', '- APPWRITE_PROJECT_ID=6a1058610003c5a13a05',
        '- APPWRITE_API_KEY=${WORKERS_APPWRITE_API_KEY:-}', '- ANTHROPIC_API_KEY=${WORKERS_ANTHROPIC_API_KEY:-}', '- WORKERS_REPORT_SMTP_PASS=${WORKERS_REPORT_SMTP_PASS:-}', '- WORKERS_UNSUB_BASE=https://entwurf.project.webklar.com/abmelden', 'workers_data:/data', '- webklar-network']) assert.ok(c.includes(l), l)
      assert.ok(!/^\s*ports:/m.test(c)); assert.ok(!/traefik/i.test(c)); assert.ok(!/sk-ant|=\s*[A-Za-z0-9]{30,}/.test(c))
    })
    test('nginx-Block: /api/workers/ → webklar-workers:8092 mit Resolver (wie /api/serverpuls/)', () => {
      const n = read('deploy/nginx-workers.conf'); for (const l of ['location /api/workers/ {', 'resolver 127.0.0.11 valid=30s ipv6=off;', 'set $workers_upstream webklar-workers;', 'proxy_pass http://$workers_upstream:8092;', 'client_max_body_size 64k;']) assert.ok(n.includes(l), l)
    })
    test('Dockerfile: node:22-alpine, npm ci --omit=dev, läuft als node, kein Chromium (kommt mit P4)', () => {
      const d = read('Dockerfile'); assert.match(d, /^FROM node:22-alpine$/m); assert.match(d, /npm ci --omit=dev/); assert.match(d, /^USER node$/m); assert.match(d, /chown node:node \/data/); assert.match(d, /^CMD \["node", "src\/main\.js"\]$/m); assert.ok(!/chromium/.test(d.replace(/#.*$/gm, '')))
    })
    test('Serverpuls-Eintrag', () => assert.deepEqual(JSON.parse(read('deploy/serverpuls-apps-eintrag.json')), { app: 'Workers', match: ['webklar-workers*'], folders: ['workers'] }))
    
  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/deploy.test.js → FAIL ENOENT … deploy/compose-workers.yml.

  • Step 3: Bausteine anlegen (wörtlicher Inhalt): Dockerfile: FROM node:22-alpine · ENV NODE_ENV=production · WORKDIR /app · COPY package.json package-lock.json ./ · RUN npm ci --omit=dev && npm cache clean --force · COPY src ./src · COPY scripts ./scripts · RUN mkdir -p /data && chown node:node /data · Kommentar # Chromium (apk add chromium) kommt erst mit P4 (Paul) · USER node · EXPOSE 8092 · CMD ["node", "src/main.js"]. .dockerignore: node_modules, test, .git, .env, deploy, docs. deploy/compose-workers.yml (Einrückung wie unter services: in /home/webklar/apps/docker-compose.yml, 2 Leerzeichen):

      workers:
        build: ./workers
        image: webklar-workers:latest
        container_name: webklar-workers
        restart: unless-stopped
        init: true
        mem_limit: 768m
        memswap_limit: 768m
        cpus: 1.0
        user: node
        read_only: true
        tmpfs:
          - /tmp
        cap_drop:
          - ALL
        security_opt:
          - no-new-privileges:true
        logging:
          driver: json-file
          options:
            max-size: "5m"
            max-file: "2"
        environment:
          - TZ=Europe/Berlin
          - NODE_OPTIONS=--max-old-space-size=384
          - APPWRITE_ENDPOINT=http://webklar-appwrite/v1
          - APPWRITE_PROJECT_ID=6a1058610003c5a13a05
          - APPWRITE_API_KEY=${WORKERS_APPWRITE_API_KEY:-}
          - ANTHROPIC_API_KEY=${WORKERS_ANTHROPIC_API_KEY:-}
          - WORKERS_SEARCH_MODEL=${WORKERS_SEARCH_MODEL:-}
          - WORKERS_REPORT_SMTP_HOST=${WORKERS_REPORT_SMTP_HOST:-}
          - WORKERS_REPORT_SMTP_PORT=${WORKERS_REPORT_SMTP_PORT:-}
          - WORKERS_REPORT_SMTP_USER=${WORKERS_REPORT_SMTP_USER:-}
          - WORKERS_REPORT_SMTP_PASS=${WORKERS_REPORT_SMTP_PASS:-}
          - WORKERS_REPORT_SMTP_FROM=${WORKERS_REPORT_SMTP_FROM:-}
          # ab P3/P4/P6 genutzt, hier leer vorbereitet (ein Eingriff in die Compose-Datei genügt)
          - WORKERS_SMTP_HOST=${WORKERS_SMTP_HOST:-}
          - WORKERS_SMTP_PORT=${WORKERS_SMTP_PORT:-}
          - WORKERS_SMTP_USER=${WORKERS_SMTP_USER:-}
          - WORKERS_SMTP_PASS=${WORKERS_SMTP_PASS:-}
          - WORKERS_SMTP_FROM=${WORKERS_SMTP_FROM:-}
          - WORKERS_IMAP_HOST=${WORKERS_IMAP_HOST:-}
          - WORKERS_IMAP_PORT=${WORKERS_IMAP_PORT:-}
          - WORKERS_IMAP_USER=${WORKERS_IMAP_USER:-}
          - WORKERS_IMAP_PASS=${WORKERS_IMAP_PASS:-}
          - WORKERS_ALERT_IMAP_HOST=${WORKERS_ALERT_IMAP_HOST:-}
          - WORKERS_ALERT_IMAP_PORT=${WORKERS_ALERT_IMAP_PORT:-}
          - WORKERS_ALERT_IMAP_USER=${WORKERS_ALERT_IMAP_USER:-}
          - WORKERS_ALERT_IMAP_PASS=${WORKERS_ALERT_IMAP_PASS:-}
          - WORKERS_PLACES_API_KEY=${WORKERS_PLACES_API_KEY:-}
          - WORKERS_UNSUB_BASE=https://entwurf.project.webklar.com/abmelden
          - WORKERS_UNSUB_SECRET=${WORKERS_UNSUB_SECRET:-}
        volumes:
          - workers_data:/data
        networks:
          - webklar-network
    

    dazu im Kommentar der Datei die Zeile # Unter "volumes:" ergänzen: workers_data:. deploy/nginx-workers.conf:

        # Workers (Kundenfindung, nur Mitarbeiter) - Same-Origin Proxy
        location /api/workers/ {
            resolver 127.0.0.11 valid=30s ipv6=off;
            set $workers_upstream webklar-workers;
            proxy_pass http://$workers_upstream:8092;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_read_timeout 60s;
            client_max_body_size 64k;
        }
    

    deploy/serverpuls-apps-eintrag.json: { "app": "Workers", "match": ["webklar-workers*"], "folders": ["workers"] }.

  • Step 4: Tests laufen lassen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/deploy.test.js → PASS (4 Tests); danach der Gesamtlauf timeout 300 node --max-old-space-size=1024 --test test/*.test.js → alle Tests grün, 0 fail.

  • Step 5: Commit. git add Dockerfile .dockerignore deploy/compose-workers.yml deploy/nginx-workers.conf deploy/serverpuls-apps-eintrag.json test/deploy.test.js && git commit -m "P1-T27: Container-, nginx- und Serverpuls-Bausteine" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"


Betriebsanleitung (Kenso)

Reihenfolge einhalten. [Claude] = Schritt, den der ausführende Agent mit dem Gitea-MCP erledigt; [Kenso] = deine Eingabe auf dem Server. Geheimnisse nie in den Chat und nie in Befehle schreiben, die sie ausgeben; ANTHROPIC_API_KEY nie in der Shell von root exportieren. Alle Skripte laufen auf dem Host aus dem Dev-Checkout (Node 24, node_modules aus T01) und bekommen den Schlüssel nur für diesen einen Befehl (Subshell).

  1. [Claude] Repo anlegen. Gitea-MCP create_repo mit name: "workers-dienst", private: true, auto_init: false, default_branch: "main", ohne Organisation (Besitzer knso, nicht Org WEBklar: deren Webhook würde jeden Push als Kundenprojekt in WOMS anlegen). Prüfen: list_my_repos zeigt knso/workers-dienst als privat.
  2. [Kenso] Sicherungen.
    cd /home/webklar/apps
    cp .env .env.bak-$(date +%F)-workers
    cp docker-compose.yml docker-compose.yml.bak-$(date +%F)-workers
    cp ticket.webklar.com/nginx/ticket.webklar.com.conf ticket.webklar.com/nginx/ticket.webklar.com.conf.bak-$(date +%F)-workers
    
    Prüfen: ls -l .env.bak-*-workers docker-compose.yml.bak-*-workers ticket.webklar.com/nginx/*.bak-*-workers zeigt drei Dateien von heute.
  3. [Kenso] Appwrite-Schlüssel. Appwrite-Konsole → Projekt 6a1058610003c5a13a05 → Integrationen → API-Schlüssel → Name workers, kein Ablauf, Bereiche databases.read/write, collections.read/write, attributes.read/write, indexes.read/write, documents.read/write. Dann: read -rs -p 'Appwrite-Key workers: ' K; echo; printf 'WORKERS_APPWRITE_API_KEY=%s\n' "$K" >> /home/webklar/apps/.env; unset K Prüfen: command grep -c '^WORKERS_APPWRITE_API_KEY=.' /home/webklar/apps/.env → 1.
  4. [Kenso] Anthropic. Console: Workspace „Workers“ anlegen, dort Ausgabenlimit 250 $/Monat setzen und einen API-Schlüssel erzeugen (Spec §12). Dann read -rs -p 'Anthropic-Key Workers: ' K; echo; printf 'WORKERS_ANTHROPIC_API_KEY=%s\n' "$K" >> /home/webklar/apps/.env; unset K. Prüfen: command grep -c '^WORKERS_ANTHROPIC_API_KEY=.' /home/webklar/apps/.env → 1.
  5. [Kenso] Berichtskonto (intern, kein Kaltkontakt; Passwort wird aus Serverpuls übernommen, ohne es zu zeigen):
    cd /home/webklar/apps
    printf 'WORKERS_REPORT_SMTP_HOST=mail.your-server.de\nWORKERS_REPORT_SMTP_PORT=587\nWORKERS_REPORT_SMTP_USER=server@webklar.com\nWORKERS_REPORT_SMTP_FROM=server@webklar.com\nWORKERS_REPORT_SMTP_PASS=%s\n' "$(sed -n 's/^SERVERPULS_SMTP_PASS=//p' .env | tail -n1)" >> .env
    
    Prüfen: command grep -c '^WORKERS_REPORT_SMTP_' .env → 5. Der Bericht geht an kenso.gri@gmail.com (Standard von reportEmail); anderer Empfänger später per PUT /settings oder im WOMS-Dialog (P2).
  6. [Kenso] Push und Produktions-Checkout.
    cd /home/webklar/apps/workers-dev
    git remote add origin https://git.webklar.com/knso/workers-dienst.git     # Anmeldung wie bei deinen anderen Gitea-Repos (Token)
    git push -u origin p1-fundament:main
    git clone --branch main https://git.webklar.com/knso/workers-dienst.git /home/webklar/apps/workers
    
    Prüfen: git ls-remote origin main | cut -c1-12 und git rev-parse HEAD | cut -c1-12 sind gleich; ls /home/webklar/apps/workers/Dockerfile existiert. (Scheitert die Anmeldung beim Klonen: git clone --branch p1-fundament /home/webklar/apps/workers-dev /home/webklar/apps/workers.)
  7. [Kenso] Sammlungen anlegen (erst prüfen).
    cd /home/webklar/apps/workers-dev
    ( export APPWRITE_API_KEY="$(sed -n 's/^WORKERS_APPWRITE_API_KEY=//p' /home/webklar/apps/.env | tail -n1)"; timeout 900 node --max-old-space-size=1024 scripts/setup-collections.mjs --check; echo "Exit $?" )
    
    Erwartet: je Sammlung Zeilen mit FEHLT, Exit 1. Dann derselbe Befehl ohne --check (Ende Fertig.; es sind rund 180 Attribute, Appwrite legt sie nacheinander an, das dauert einige Minuten; bei einem Abbruch durch timeout den Befehl einfach wiederholen, er zieht Fehlendes nach), danach wieder mit --check → Alles vorhanden., Exit 0. Erscheint „Row width limit reached“, in src/schema.js die Größe des genannten Felds auf TEXT_SIZE setzen und neu laufen lassen (Skript ist idempotent).
  8. [Kenso] Modell-Prüfung (manuell, kein Test). ( ANTHROPIC_API_KEY="$(sed -n 's/^WORKERS_ANTHROPIC_API_KEY=//p' /home/webklar/apps/.env | tail -n1)" timeout 120 node --max-old-space-size=1024 scripts/check-modelle.mjs; echo "Exit $?" ) Erwartet: Haiku-Zeile „web_search: unterstützt“, Exit 0. Meldet das Skript den Fallback, die Zeile WORKERS_SEARCH_MODEL=claude-sonnet-5-5 an /home/webklar/apps/.env anhängen (die Compose-Datei reicht sie schon durch); Exit 2 heißt „unklar“ (die Modell-Auskunft nennt Websuche vermutlich gar nicht): denselben Befehl mit --probe wiederholen (eine echte Probe-Suche, etwa 0,02 $) und dessen Ergebnis wie oben behandeln; Exit 1: Suche ist mit diesen Modellen nicht möglich, P3 nicht starten.
  9. [Kenso] Startbestand. Erst --dry-run: ( export APPWRITE_API_KEY="$(sed -n 's/^WORKERS_APPWRITE_API_KEY=//p' /home/webklar/apps/.env | tail -n1)"; timeout 900 node --max-old-space-size=1024 scripts/seed-register.mjs --dry-run ) Erwartet: TROCKENLAUF …, Alte Leads: 95 (blockiert: 2), Kunden: <Zahl> (soll zur WOMS-Kundenliste inkl. Abgesagt/Archiviert passen), Schlüssel neu: N · schon vorhanden: M. Stimmt das, derselbe Befehl ohne --dry-run; ein zweiter Lauf meldet Schlüssel neu: 0. Prüfen: Appwrite-Konsole → workersKeys hat N Dokumente. Meldet dir jemand eine Antwort/einen Widerspruch eines der 95 alten Lackierer, sag Claude Bescheid (Eintrag in die Sperrliste, Spec §12).
  10. [Kenso] Container starten.
    cd /home/webklar/apps
    # Inhalt von /home/webklar/apps/workers/deploy/compose-workers.yml unter "services:" einfügen (z. B. direkt nach dem Block "serverpuls:"),
    # und unter dem Top-Level-"volumes:" die Zeile "  workers_data:" ergänzen
    docker compose config -q && echo KONFIG-OK
    free -m | sed -n 2p
    docker compose build workers
    docker compose up -d --no-deps workers
    
    Prüfen: docker logs --tail 20 webklar-workers enthält workers listening on 8092 und keine Zeile mit dem Wort „fehlt“; docker stats --no-stream webklar-workers unter 768 MiB (erwartet < 150 MiB); docker exec webklar-ticket-integrations wget -qO- http://webklar-workers:8092/api/workers/health → {"ok":true,"version":"0.1.0"}. Nie ein nacktes docker compose up -d (startet Gestopptes).
  11. [Kenso] nginx. In /home/webklar/apps/ticket.webklar.com/nginx/ticket.webklar.com.conf den Inhalt von deploy/nginx-workers.conf direkt nach dem Serverpuls-Block (location /api/serverpuls/ { … }) einfügen, dann docker exec webklar-ticket nginx -t && docker exec webklar-ticket nginx -s reload. Prüfen: curl -s https://ticket.webklar.com/api/workers/health → {"ok":true,"version":"0.1.0"}; curl -s -o /dev/null -w '%{http_code}\n' https://ticket.webklar.com/api/workers/state → 401.
  12. [Kenso] Abnahme Bericht und Nachtplan. Nach 06:30 Berlin holt der erste Tick den heutigen Bericht sofort nach: innerhalb von 2 Minuten nach dem Start kommt [Workers] Nachtbericht TT.MM.JJJJ bei kenso.gri@gmail.com an (auch Spam prüfen). Vor 06:30 oder zur Wiederholung: timeout 120 docker exec webklar-workers node scripts/jetzt.mjs bericht und timeout 120 docker exec webklar-workers node scripts/jetzt.mjs nachtplan (nur solange connectedRooms leer ist). Erwartet: die Mail; nachtplan druckt {"planned":[],"skipped":"keine_raeume"} und erzeugt ein leerlauf-Ereignis von Otto in workersEvents. Meldet der Bericht smtp_auth im Ereignis „Morgenbericht nicht gesendet“: nicht wiederholen (der Dienst versucht es erst morgen wieder), Passwort prüfen – wiederholte falsche Logins sperren die Server-IP bei Hetzner.
  13. [Kenso] Abnahme API im Browser (eingeloggt auf https://ticket.webklar.com, Entwicklerkonsole):
    const j = await (await fetch('/v1/account/jwts', { method: 'POST', credentials: 'include', headers: { 'X-Appwrite-Project': '6a1058610003c5a13a05' } })).json()
    for (const p of ['state', 'events?limit=5', 'settings']) { const r = await fetch('/api/workers/' + p, { headers: { 'X-Appwrite-JWT': j.jwt } }); console.log(p, r.status, await r.json()) }
    
    Erwartet: state 200 mit 19 Workern (nur Otto nicht grau), events 200 mit dem leerlauf-Ereignis, settings 200 (du bist Admin). Schlägt schon /v1/account/jwts fehl, genügt der Test mit der WOMS-Oberfläche in P2. Antwortet die API trotz Login mit 502 Anmeldedienst nicht erreichbar. oder 401 Sitzung abgelaufen., im Compose-Block APPWRITE_ENDPOINT=https://appwrite.webklar.com/v1 setzen (wie Serverpuls; der interne Endpunkt hat die JWT-Prüfung nicht bestanden) und docker compose up -d --no-deps workers wiederholen.
  14. [Kenso] Serverpuls kennt die App. Eintrag aus deploy/serverpuls-apps-eintrag.json in /home/webklar/apps/serverpuls/config/apps.json ergänzen (neben „Traefik“), dann cd /home/webklar/apps/serverpuls && git add config/apps.json && git commit -m "apps.json: Workers" && cd /home/webklar/apps && docker compose build serverpuls && docker compose up -d --no-deps serverpuls. Prüfen: docker logs --tail 5 webklar-serverpuls ohne Fehler; nach dem nächsten Größenlauf (03:00 Berlin) steht „Workers“ in der App-Tabelle der Serverpuls-Seite.

Update später: cd /home/webklar/apps/workers && git pull --ff-only && cd .. && docker compose build workers && docker compose up -d --no-deps workers. Rückbau: docker compose stop workers; nginx-Sicherung zurückkopieren und nginx -s reload; die Daten in Appwrite bleiben (nichts wird gelöscht). Fertig, wenn: Schritte 7, 9, 10, 11, 12 ihre „Erwartet“-Zeilen erfüllen und ab dem nächsten Morgen um 06:30 der Nachtbericht automatisch kommt. In WOMS ist noch nichts zu sehen (kommt mit P2).