193 KiB
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, Benutzerknso, nicht Org WEBklar); Dev-Checkout/home/webklar/apps/workers-dev, Branchp1-fundament; Betrieb/home/webklar/apps/workers, Branchmain; 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änzenimapflow@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
fetchkommen per Parameter (deps,sdk,createTransport,fetchImpl). - Appwrite: Projekt
6a1058610003c5a13a05, Datenbankwoms-database, Rechte[],documentSecurity=false, IDs ≤ 36 Zeichen, kein Felddata, JSON-Felder als Text, Zeiten ISO-8601 UTC. - Dienst: Port 8092 nur intern (Basis
/api/workers), in Compose wederports:noch Traefik-Label; Zugang ausschließlich über nginx vonticket.webklar.com. - Auth:
X-Appwrite-JWT→ AppwriteGET /account, 60 s Cache; Labelmitarbeiteroderadminist Pflicht (Kunden-Konten teilen das Appwrite-Projekt),adminfür/settings. - Budget: 10 $/Tag, 250 $/Monat, Warnung bei 80 %, Tag gestoppt bei 100 %;
mailColdPerDay≤ 3 ohne Ausnahme,mailReplyCapPerDay≤ 15,runsPerNight1–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; keintemperature/top_p/top_k; Suche (Werkzeuge) und Strukturierung (Schema) sind getrennte Aufrufe;pause_turnhö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):
nachtplanab 22:00 (Fenster bis 05:00),nacht2ab 01:00,berichtab 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;namplzist nur Hinweis (hint=true). - Alt-Passwörter (
portalPasswort,portal_passwort,portalPassword) werden nie gelesen oder übernommen; der Seed liestcustomers/websiteProjectsnur mitQuery.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 chromiumkommt 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/stateoderPUT /settings→ 403{error}ohne Daten; Mitarbeiter ohneadmin→PUT /settings403,POST /pause200. → Test in P1-T24. - Widerspruch von einer Freemail-Adresse (
x@gmail.com) → nur diese Adresse gesperrt, niegmail.com; voninfo@mueller.de→ Adresse und Domainmueller.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; Websitekeine, Facebook-Link, Verzeichnis → kein Domain-Schlüssel;betrieb.jimdofree.com→ volle Subdomain stattjimdofree.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 überprospectIdFor, E10) → wiederfresh; 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 mitsize: 20000angelegt (Appwrite-TEXT; sonst „Row width limit reached“, 65 535 Byte je Zeile). Das §4-Maximum steht alsmaxim Schema und wird von den Schreibern eingehalten. Zusätzliche Indizes (Zählabfragen und Auftragssuche):workersJobs.idx_kind_status(kind+status) undworkersThreads.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; Fixtureworkers-roster.json={ "floors": FLOORS, "roles": ROLES };MODELS={ opus:'claude-opus-5-5', sonnet:'claude-sonnet-5-5', haiku:'claude-haiku-5-5' }; ZusätzeSEARCH_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 ansettings.reportEmailüberWORKERS_REPORT_SMTP_*);db.get→ Dokument odernull;db.list/listAll→ Array; neudb.count(col, queries) → number;Querykommt aussrc/db.js.- E4 Tageszähler/Zeit: neue Datei
src/daily.jsist der einzige Schreiber vonworkersDaily(serialisiert);bookaddiertcountersje Worker, die SchlüsselmailsCold, mailsReply, searches, found, contacted, dealszusätzlich in die Tagesfelder; Marker incountersJson.__runs.isDue(kind, now, last):now= Ergebnis vonberlinNow(clock);last= Lauf-Schlüssel (runKey: Tag, beinachtplan/nacht2der 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/searchnehmenreply; neumarkStopped(deps, reason);costOfkennt 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 });searchliefert zusätzlichincomplete(Fortsetzungsgrenze erreicht) undtoolErrors: [{ tool, code, url? }](z. B.url_not_allowedbei robots.txt ⇒ Quelle auslassen); EnvWORKERS_SEARCH_MODEL(optional; Fallbackclaude-sonnet-5-5, falls Haiku keinweb_searchkann).- E7 Register:
keysForakzeptiert fürwebsite/phone/nameauch Arrays;claimKeys(…, { blocked:true })setztblockedauch auf bereits vorhandenen Schlüsseln; gleicheprospectIdauf vorhandenem Schlüssel ist kein Treffer (Wiederholung nach Absturz);isSuppressedprüft zusätzlichworkersKeysmitblocked=true(Kunden, 2 kontaktierte Alt-Leads);suppress/isSuppressedleiten die Domain aus einer Firmen-Adresse ab, nie aus einer Freemail-Adresse.- E8 Jobs:
fail(deps, jobId, error, { retry = true })(nach 3 Versuchenfehler),reclaimStale(deps, { olderThanMs })(hängendelaeuft-Jobs zurück aufoffen); Otto plant nursuche-Jobs (Rita, Finn, Ali, Hanna, Sven, Lea); Kim und Pia treibt der Tickalarme(P3).- E9 API:
trace.eventsneueste zuerst,trace.today.tokens={ in, out };GET /eventsohneafterliefert die letztenlimitEreignisse aufsteigend; Zeitstempel (ts,lastEventTs) immer als ISO mitZ(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.“;connectedeines Workers = sein Raum istbrueckeoder steht insettings.connectedRooms(alle Räume, nicht nur Suchräume).- E10 Interessenten-ID:
prospectIdFor(keys)(src/register/keys.js) ='p'+ erste 30 Zeichen vonkeyIddes ersten harten Schlüssels (kind≠namplz),''ohne harten Schlüssel (dannID.unique()). P3/P6 benutzen sie alsworkersProspects.$idundclaimKeys-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/removedarauf wirft (Übersicht §2.13);BOT_VERBOT_DOMAINS(src/claude.js) –searchsetzt sie immer alsblocked_domainsdesweb_fetch-Werkzeugs (Übersicht §2.11: Plattformen mit Bot-Verbot nie direkt abrufen; Treffer der Websuche bleiben erlaubt);getSettingsbegrenztmailColdPerDayauf 3 undmailReplyCapPerDayauf 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ächstfakeClockundnear),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/. DannNODE_OPTIONS=--max-old-space-size=1024 timeout 300 npm install --ignore-scripts(erzeugtpackage-lock.json; prüfen:rg -m1 '"version"' node_modules/node-appwrite/package.jsonzeigt14.1.0).test/support/helpers.js:fakeClock(iso = '2026-10-19T09:00:00.000Z')mitnow()(ms),set(isoOrMs),advance(ms), dazunear(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→ FAILERR_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 oderPORTkeine ganze Zahl 1–65535 →ConfigErrormit.missingin genannter Reihenfolge, Pflichtnamen zuerst;ConfigError.message=Konfiguration unvollständig:+ Namenliste.searchModel=WORKERS_SEARCH_MODELoder''. -
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→ FAILERR_MODULE_NOT_FOUND(src/schema.js). -
Step 3: Implementieren.
src/schema.js: Mini-DSLs(key,size,opts),i,f,b,dt,e(key,elements,opts)undidx(attrs…);SCHEMASammlung für Sammlung aus Übersicht §4 abschreiben (Mappings(n)→string+size,i→integer,f→float,b→boolean,dt→datetime,e[…]→enum+elements,[]→array:true,*→required:true, sonstrequired:false; Reihenfolge wie §4; Indizes aus §4 mit Schlüsselidx_<attr>_<attr>,tokenHashalsunique; Zusatzindizes aus E1 mit den Schlüsselnidx_kind_statusundidx_status).name= Sammlungs-ID. Felder aus E1 mitsize: TEXT_SIZEundmax.rowBytes(c): je Attribut Array → 20;string> 16381 → 20, sonstsize*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,argsbeginnt 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 incalls.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.jsentsteht 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→ FAILERR_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> FEHLTund danach je Attribut/Index eineFEHLT-Zeile); mitcheckhier zurückkehren (SchlusszeileAlles vorhanden.bzw.<n> Eintrag/Einträge fehlen.); sonst je SammlungcreateCollection(id, id), fehlende Attribute anlegen, danach für jedes neue AttributwaitForAttribute, erst dann fehlende Indizes; SchlusszeileFertig..attributeCallnach den Signaturen innode_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/arraydefaultfalse.createSetupClientmitsdk.Client/Databases,listAttributes/listIndexesmitQuery.limit(200),createCollection(DB, id, name, [], false, true),waitForAttributepolltgetAttributejede Sekunde bisavailable(60 s,failedwirft). CLI (nur wenn direkt gestartet):--check; EnvAPPWRITE_ENDPOINT(Standardhttps://appwrite.webklar.com/v1),APPWRITE_PROJECT_ID(Standard6a1058610003c5a13a05),APPWRITE_API_KEY(fehlt → Meldung mit dem Namen, Exit 2); ExitexitCodeFor. -
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änztmakeDeps) - 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/removedarauf werfenError('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; nurseeddarf in lose Sammlungen schreiben) plusseed(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? }(listAllprotokolliert 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 ausfnsprotokolliert ihre Argumente incalls):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(seedist 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→ FAILERR_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 mitcode === 409→new ConflictError(...)(Ursache alscause);getfängt nur 404;listAllhängt selbstQuery.limit(100)undQuery.cursorAfter(lastId)an, bis eine Seite < 100 liefert (Sicherung: höchstens 1000 Seiten), eigenelimit/cursor-Queries → Fehler;count=totaleinerlimit(1)-Abfrage.create/update/removeprüfen zuerstGESPERRTE_SAMMLUNGEN(FehlertextSchreiben in <col> ist für Workers gesperrt). Kein Retry in dieser Schicht.fakeDb.js: Dokumente in Maps; jeder Aufruf wird incallsprotokolliert (Form siehe Interfaces); Queries perJSON.parseder SDK-Query-Strings;$id(ohne IDfake000001…),$createdAt/$updatedAtausclock; Prüfung gegenSCHEMA(unbekannte Felder – auch jeder$…-Schlüssel indata, Typen,datetime= vonDate.parselesbarer ISO-String, Offset erlaubt, Enum, Länge nachmax ?? size, Pflichtfelder nur beicreate,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 werfenQuery-Methode nicht unterstützt: <name>;listAllruft die eigenelist-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)(wirftCapError),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→ FAILERR_MODULE_NOT_FOUND. -
Step 3: Implementieren.
ROLES(eingefroren) wörtlich aus Übersicht §3 / Fixture;SEARCH_ROOMS= Räume der Etagenanfragenundohne_websiteinROLES-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→ FAILERR_MODULE_NOT_FOUND. -
Step 3: Implementieren. Fehlertexte enthalten
Ereignisart,Worker,Ziel.ts=max(clock.now(), letztes ts dieser DB + 1)(letztestsjedeps.dbin einerWeakMap);text> 300 → erste 299 Zeichen +…;linkauf 1024 kürzen;metaJsonnur wennmetagesetzt 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)(ohnepreviewsSent) (Ü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→ FAILERR_MODULE_NOT_FOUND. -
Step 3: Implementieren.
errors.js:HttpError extends Errormitstatus; Unterklassen.settings.js:DEFAULTSwie im Test (Arrays je Aufruf kopieren);getSettings=db.get('workersSettings','main')überDEFAULTSgelegt (nur bekannte Schlüssel), danachmailColdPerDayauf höchstens 3,mailReplyCapPerDayauf höchstens 15 undrunsPerNightauf 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), dannget: fehlt →create(…, 'main')mit allenDEFAULTS+ Patch (beiConflictErrorweiter mitupdate), sonstupdate. Grenzen: Budgets > 0;warnPctganze Zahl 1–100;mailColdPerDayganze Zahl 0–3;mailReplyCapPerDay0–15;sendWindow*HH:MM, Start < Ende;runsPerNight1–2;candidatesPerRun1–20;activeRooms⊆SEARCH_ROOMS;connectedRooms⊆ alleroom-Werte ausROLES;regions1–30 Strings (je 1–64 Zeichen);previewGate∈ {erste_n, immer, nie};previewGateN0–1000;offerText1–1000 Zeichen;offerProductKey∈ {hosting, wartung};pultStaleDays1–30;retentionDays7–365;followupDays/previewReminderDays1–60;previewDeleteDays7–365;reportEmaileinfache Adressprüfung ≤ 256;paused/sendWeekdaysOnlyBoolean;previewsSentim 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
bericht06:30,kuerzen03:00,pultAlt07:00; Nachtnachtplan(22:00–04:59),nacht2(01:00–04:59); Intervallposteingang2 min,alarme10 min,senden5 min,batches5 min,aufraeumen5 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→ FAILERR_MODULE_NOT_FOUND. -
Step 3: Implementieren.
Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin', hourCycle: 'h23', … })wieserverpuls/src/schedule.js.berlinStartOfDay(day): UTC-Mitternacht vondayminus 2 h bzw. 1 h – derjenige Kandidat, dessenberlinNowgenauday 00:00ergibt.nightKey(now)= Berliner Tag vonnow.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) undlast !== runKey; Intervall →last == null || now.ts − last ≥ Intervall; unbekannte Art →Error('Unbekannte Art: …').inSendWindowvergleichtHH:MMaus 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= geparstescountersJson, 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 jedeps.dblaufen nacheinander);monthDocs(deps, now) → Tagesobjekt[](Tage des Berliner Monats vonnow.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→ FAILERR_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: '{}', beiConflictErrorneu lesen) →mutateauf dem Objekt →costUsdauf 6 Nachkommastellen runden →JSON.stringify(counters)> 4000 Zeichen wirftError('countersJson zu groß')→updatemit nur den Schema-Feldern (costUsd, mailsCold, mailsReply, searches, found, contacted, deals, countersJson, warned, stopped), nie mit$…-Feldern odercounters.monthDocs:list('workersDaily', [Query.greaterThanEqual('$createdAt', berlinStartOfDay(<Monatsersten>)), Query.limit(40)]).markRunschreibtcounters.__runs[kind] = keyin das Dokumentkey. -
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 grobceil(chars/3)Tokens zum Normaltarif +maxTokensAusgabe). -
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→ FAILERR_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), optionaldeps.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. (
nearaustest/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 functionbzw. fehlender Export). -
Step 3: Implementieren.
checkBudget: Reihenfolge Pause → (Console-Stopp:stopped && counters.__stop === 'console'⇒tag, auch fürreply) → Tag (costUsd + est > dailyBudgetUsdoderstopped; entfällt beireply) → Monat (SummemonthDocs+ est >monthlyBudgetUsd);warn= Tag oder Monat ≥warnPct%.book: einupdateDailyaddiertusd(je WorkercostUsd, 6 Nachkommastellen) undcounters(additiv; SonderschlüsselmailsCold, mailsReply, searches, found, contacted, dealszusätzlich in die Tagesfelder); danach entscheidet ein zweiterupdateDailyanhand des dann aktuellen Tagesdokuments (nicht des Rückgabewerts des ersten Schritts; Monatssumme vorher permonthDocsgelesen), obwarned/stoppedzu setzen sind – nur wenn das Flag in der Sperre wirklich vonfalseauftruewechselt, folgt Ereignis (otto,budget_warnung/budget_stopp, deutscher Satz mitformatUsd,meta:{scope:'tag'|'monat',usd,limit}) unddeps.notify?.({ subject:'Budget …', text })(Fehler der Mail nur loggen); Stopp setztwarnedmit, ohne eigene Warnung.markStopped(deps, reason):stopped = true,counters.__stop = reason,budget_stoppnur 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.reportSmtpodernull;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→ FAILERR_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'oderresponseCode∈ {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ächstask;sdk= Client mit.messages.create, Standardnew 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 } }(createliefert die Einträge vonscriptder Reihe nach, der letzte wiederholt sich; einErrorwird geworfen;callsenthält Kopien der Anfragen);msg(over)(Standard:claude-haiku-5-5,end_turn, Textok, usage 1000/200);apiError(status, body)(Fehler mitstatusunderror = 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→ FAILERR_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_confignur, wenneffortoderschemagesetzt); SDK-Fehler:isBudgetError(err)⇒markStopped(deps, 'console')undBudgetStop('console'), sonst unverändert werfen.isBudgetError: Text =err.error?.error?.message ?? err.message; Status 400 und Text enthältYou have reached your specifiedundAPI usage limits, oder Status 429 undJSON.stringify(err.error ?? {})enthältenforced_spend_limit_reached(nicht am Textanfang verankern: das SDK stellt „400 {…}“ voran). Nach der Antwort zuerstbook(deps, { workerId, usd, counters:{ calls:1, tokensIn: input+cache_read+cache_creation, tokensOut: output } }), dannstop_reasonprüfen (refusal→ClaudeRefusal,max_tokens→Error('Antwort abgeschnitten (max_tokens)')), dann beischemaden ersten Text-Block mitJSON.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 aussrc/claude.js; E11); Skriptscripts/check-modelle.mjs:evaluate(models) → { exit:0|1|2, searchModel:string, lines:string[] }(models= Antworten vonmodels.retrieveje Modell-ID) undevaluateProbe(probe) → { exit:0|1, searchModel:string, lines:string[] }(probe[modellId] = { ok:boolean, reason? }, Ergebnis je einer echten Probe-Suche;models.retrieveliefert den Zweigserver_tools.web_searchwomö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_FOUNDfür das Skript). -
Step 3: Implementieren.
search:assertCap(workerId, 'web_search');allowedDomainsundblockedDomainszusammen ⇒Error('Entweder allowedDomains oder blockedDomains'); Modell =searchModel || MODELS[role];web_fetchnur, wennhasCap(workerId,'web_fetch'), und immer mitblocked_domains=BOT_VERBOT_DOMAINS+ eigeneblockedDomains(ohne Doppelte, Reihenfolge wie im Test); beiallowedDomainsträgtweb_fetchstattdessenallowed_domains= dieselbe Liste ohne Einträge, die gleich einerBOT_VERBOT_DOMAINS-Domain oder deren Subdomain sind (bleibt nichts übrig ⇒ keinweb_fetch-Werkzeug;web_searchbehält die Liste unverändert, Suchtreffer sind erlaubt); VorabprüfungcheckBudget(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, danachincomplete = true; Kosten pro Antwort mitcostOfund summiert.text= alle Text-Blöcke aller Antworten;sourcesausweb_search_tool_result-Einträgen (web_search_result), doppelte URLs entfernt;toolErrorsaus Blöckenweb_search_tool_result/web_fetch_tool_result, derencontent.typeauf_tool_result_errorendet (tool= Blocktyp ohne_tool_result,code=content.error_code,urlaus demserver_tool_use-Block mit gleichertool_use_id); SDK-Fehler wie inask(gemeinsamer Helfer).batch.create: Budget-Vorabprüfung mit halbierter Schätzung, dannsdk.messages.batches.create({ requests });results:for awaitübersdk.messages.batches.results(id), KostencostOf(message.model, message.usage, { batch:true }), gebucht wird vom Aufrufer.scripts/check-modelle.mjs:evaluate(models)rein (Logik: Haikusupported === true⇒ Exit 0; Angabe bei Haiku fehlt ⇒ Exit 2 „unklar – mit--probeprüfen“; Haikufalseund Sonnettrue⇒ Exit 0 mit ZeileWORKERS_SEARCH_MODEL=claude-sonnet-5-5undsearchModelgesetzt; sonst Exit 1);evaluateProbe(probe)rein (Haikuok⇒ Exit 0,searchModel ''; Haiku nicht ok und Sonnet ok ⇒ Exit 0 mit derselben.env-Zeile; sonst Exit 1);mainruftnew Anthropic({ apiKey }).models.retrieve(id)für die drei Modelle (Schlüssel ausANTHROPIC_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 Aufrufmessages.createmitmax_tokens: 300, EingabeSuche kurz: Öffnungszeiten Rathaus Kaiserslautern.undtools: [{ type: 'web_search_20260318', name: 'web_search', max_uses: 1, allowed_callers: ['direct'] }];ok= Antwort ohne Fehler undusage.server_tool_use.web_search_requests ≥ 1, bei einem API-Fehlerok:falsemit auf 120 Zeichen gekürzter Meldung (reason), das Ergebnis geht anevaluateProbe. -
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(ältesteroffen-Job der Arten, setztlaeuft,startedAt,attempts+1);finish(deps, jobId, result);fail(deps, jobId, error, { retry = true });reclaimStale(deps, { olderThanMs }) → Anzahl;JOB_KINDS(ausSCHEMA);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→ FAILERR_MODULE_NOT_FOUND. -
Step 3: Implementieren.
next:list('workersJobs', [Query.equal('status',['offen']), Query.equal('kind', kinds), Query.orderAsc('$createdAt'), Query.limit(1)])(nutzt Indexidx_kind_status); Anspruch perupdate(ein Prozess, kein verteiltes Locking).payloadJson/resultJson> 16000 Zeichen ⇒Error('payload zu groß').fail:attempts < 3 && retry⇒offen(Fehlertext gespeichert), sonstfehlermitfinishedAt.reclaimStale:laeuft-Jobs mitstartedAtälter alsolderThanMs⇒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 undFREEMAIL/LEGAL_WORDS, ohnefindDuplicatesund Absage-Funktionen), mit Fix innormalizePhone(Übersicht §6). -
Step 1: Test schreiben (die Fälle aus WOMS
tests/unit/leadDuplicates.test.mjsplus 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→ FAILERR_MODULE_NOT_FOUND. -
Step 3: Implementieren. Funktionskörper 1:1 aus
leadDuplicates.jsübernehmen (Kommentar mit Quelle und Standd288301),FREEMAILzusätzlich überisFreemail(domain)exportieren. Fix innormalizePhone: vor dem Ziffern-Ziehen(0)(/\(\s*0\s*\)/g) entfernen; nach erkannter deutscher Landesvorwahl (0049…oder+49…) den Rest nehmen, eine führende0davon entfernen und0voranstellen (damit+49 0631…nicht00631…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 ohnewww.,''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→ FAILdoes not provide an export named 'normalizeDomain'. -
Step 3: Implementieren.
normalizeDomain: Markdown[x](url)→url; Text mit Leerzeichen ohne Schema ⇒''; ohne Schemahttp://voranstellen;new URL()(Fehler ⇒'', Punycode kommt gratis); Host klein,www.und Schlusspunkt ab, IPv4/IPv6 und Hosts ohne Punkt ⇒''; Verzeichnis-/Soziale-ListeDIRECTORY(eTLD+1-Domainsfacebook.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.complus Labelregelgoogle.*) ⇒''; Shared-Hosting-SuffixeSHARED_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, wennnormalizePhoneleer;+am Anfang und nicht+49⇒++ Ziffern;00…(nicht0049) ⇒++ Ziffern ohne00; 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 aufplace_id[:=](ChIJ[^&\s]+).namePlzKey:normalizeName(name)nicht leer und erste\b\d{5}\bausplz, 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 undnameauch als Arrays; Reihenfolgedom(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 vonkeyIddes ersten Schlüssels mitkind≠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→ FAILERR_MODULE_NOT_FOUND(src/register/keys.js). -
Step 3: Implementieren.
claimKeysje Schlüssel nacheinander (nie abbrechen):create('workersKeys', { kind, prospectId?, origin, blocked, hint: kind === 'namplz', createdAt }, keyId(kind,value)); beiConflictErrorbestehendes Dokument lesen; istblockedangefragt und bestehend nicht gesperrt ⇒update({ blocked:true })(und im Ergebnisblocked:true); gehört der Schlüssel derselben nicht-leerenprospectId⇒ kein Treffer; sonsthits.push(…)(namplz⇒hints).fresh = hits.length === 0.origin∉ {workers, seed_alt, seed_kunde} ⇒ Fehler. Leere Werte überspringen;prospectIdleer ⇒ Feld weglassen.prospectIdForwie 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). DokumentworkersSuppressionmit$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ätzlichnormalizeDomain(companyDomain(email))(Freemail ⇒ nichts);tel=normalizePhoneE164(phone).suppress: je Schlüsselcreate('workersSuppression', { kind, reason, createdAt, source }, keyId(kind, wert)),ConflictErrorignorieren.isSuppressed: erstworkersSuppression(Reihenfolge mail, dom, tel), dannworkersKeysmitblocked === truefür dieselben Schlüssel (Reason: Originseed_kunde⇒kunde, sonstmanuell). -
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 }; Ereignisauftrag(workerId 'otto',to,jobId, TextOtto 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→ FAILERR_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 inrun === 1die Räumenetz(Rita) undforen(Finn),region '',entrance 'anfrage'(Kim/Pia treibt der Tickalarme, E8). Ohne Websites: je Aufruf genau eine Kombination aus den Räumen der Etageohne_website×settings.regions: sortiert nach „zuletzt gesucht“ aufsteigend (nie gesucht zuerst), Gleichstand nach Rollenreihenfolge, dann Regionsreihenfolge; „zuletzt gesucht“ = jüngstesatausworkersSearchLog(je Raum:Query.equal('room',[room]),orderDesc('at'),limit(100)) oder$createdAteinessuche-Jobs der letzten 48 h (Payloadroom+region), damit Lauf 2 nicht dieselbe Kombination plant. Je Eintrag erstenqueue, dannemit. Ist nach der Auswahl nichts geplant, genau einleerlauf-Ereignis (Otto: Kein Raum angeschlossen, nichts zu planen.) undskipped:'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(beisent:falsezusätzlich Ereignisfehler,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→ FAILERR_MODULE_NOT_FOUND. -
Step 3: Implementieren. Fenster: Ereignisse mit
ts ≥ jetzt − 24 h(listAllmitQuery.greaterThanEqual('ts', …)), gezählt nachkind; Kosten aus den Tagesdokumenten (gestern = Tag vornow.day, heute, Monat =monthDocs) und den Einstellungen; Pult =countmitstatus∈ {offen, in_arbeit}; Zeilenformate wörtlich wie im Test; ZeileFehler: n (zuletzt: <Text des neuesten fehler-Ereignisses>)nur bei n > 0;Budget: Warnung ausgelöst/Stopp ausgelöstnur bei entsprechenden Ereignissen im Fenster;Pause: aktivnur beipaused;Noch nicht angeschlossen: …= aktive Räume (ReihenfolgeSEARCH_ROOMS, Namen viaroomLabel) 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ätzlichDie Nacht war ruhig: keine neuen Funde, keine Nachrichten.; AnredeGuten Morgen, hier ist Otto. Nachtbericht für den 19.10.2026.; DatumTT.MM.JJJJausnow.day.sendReportohnedeps.notify⇒ Ergebnis{ sent:false, reason:'nicht_konfiguriert' }(und dasfehler-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= geparstesappwrite-backup.json, nur.leads;master= geparstesmaster.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→ FAILERR_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 inmastervorhandene) aus Backupfirma, email, telefon, website, adresse, googleMapsLink, templateData (JSON-String, bei Parse-Fehler {}), crmStatusmit Lücken (leer) ausmaster(firma, email, telefon, website, adresse, google_maps_link, template_data-Objekt);placeId=templateData.GOOGLE_PLACE_IDsonstplaceIdKey(googleMapsLink); PLZ =templateData.PLZsonst erste\b\d{5}\bausadresse; Schlüssel überkeysFor({ website, email, phone, placeId, name:firma, postalCode });blocked = crmStatus ∉ {'', 'neu', undefined}. Kunde:emailan;/,teilen,name: [name, companyName],website= alleliveDomaindes Kunden ausprojects(nachcustomerId), danachdom-Schlüsselwebklar.com(eigene Vorschau-Hosts) entfernen; jeder Kunde (egal welchercustomerStatus, aucharchived) ⇒blocked: true,origin: 'seed_kunde'; Einträge ohne Schlüssel entfallen nicht (leerekeyserlaubt). -
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 }; CLInode 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, perreadFileSync+JSON.parsegeladen; Kunden undwebsiteProjectskommen überdeps.db.seed,readJsonliefert 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→ FAILERR_MODULE_NOT_FOUND(scripts/seed-register.mjs). -
Step 3: Implementieren.
main: Argumente parsen; Dateien perreadJson(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 EintragclaimKeys(deps, keys, { prospectId:'', origin, blocked }). Trockenlauf: keine Schreibzugriffe, stattdessen je Schlüsselget('workersKeys', keyId(…))plus einSetder 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): KonfigurationAPPWRITE_ENDPOINT(Standardhttps://appwrite.webklar.com/v1),APPWRITE_PROJECT_ID(Standard6a1058610003c5a13a05),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 intest/support/apiBoot.js, Version0.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,fetchImplliefert{ $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→ FAILERR_MODULE_NOT_FOUND(src/api.js). -
Step 3: Implementieren. Nach dem Muster
/home/webklar/apps/serverpuls/src/api.js(HttpError,sendJsonmitCache-Control: no-store, JWT-Form^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$und ≤ 2048 Zeichen, Cachejwt → Promise60 s mit Teilen laufender Anfragen, höchstens 500 Einträge,AbortSignal.timeout(5000)), aber:labelsmussmitarbeiteroderadminenthalten (sonst 403Kein Zugriff.),/settingsverlangtadmin(403Nur für Admins.); Appwrite 401/403/404 auf/account⇒ 401Sitzung abgelaufen., sonstige Fehler/Netz ⇒ 502Anmeldedienst nicht erreichbar.. Antworttexte: fehlendes JWTNicht angemeldet., unbekannter PfadNicht gefunden., falsche Methode 405Methode nicht erlaubt., ungültiges JSON/Feld 400 (Ungültige Anfrage.bzw. Text derValidationError), Body > 16 KB 413Anfrage zu groß., sonst 500Interner Fehler.(Details nur ins Log).POST /pause: Body{ paused: boolean }; nur bei ÄnderungupdateSettings(deps, { paused }, user.$id)undemit(system,pause, TextAlle 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(nutztbootaustest/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). RoutenGET /state,GET /events?after=&limit=,GET /worker/:workerId/trace?limit=für Team. -
Step 1: Tests schreiben (
test/view.test.js; Welt perdeps.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→ FAILERR_MODULE_NOT_FOUND(src/view.js). -
Step 3: Implementieren.
buildState: Einstellungen; Aufträgestatus ∈ {laeuft, offen}(limit 200); Ereignisse der letzten 30 min (tsabsteigend,limit 200) – „aktuell fehlerhaft“ = jüngstes Ereignis des Workers innerhalb 15 min istfehler;connected=room === 'bruecke' || connectedRooms.includes(room); Status in der Rangfolgegrau(nicht angeschlossen) >fehler>arbeitet(Joblaeuft) >wartet(Joboffen) >leerlauf;task:grau⇒noch nicht angeschlossen,fehler⇒ Text des Fehler-Ereignisses,arbeitet⇒ Text des jüngsten Ereignisses des Workers (sonstJOB_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, …);countersausgetDaily(heute).counters[workerId]ohnetokensIn, tokensOut, costUsd, calls. Kennzahlen:newAbosMonth/mrrNewEurausworkersOffers(Abfrage nur über den Indexstatus = 'bezahlt',limit 500;paidAt≥ Monatsanfang Berlin wird im Code gefiltert; Preise{ hosting: 20, wartung: 50 }),dealsMonth= SummedealsausmonthDocs,openThreads=countFäden mit Status offen/wartet_auf_kunde/wartet_auf_uns,pultOpen=countoffen+in_arbeit,botLeads=countInteressentenbotlead,apiTodayUsd/apiMonthUsdaus den Tagesdokumenten (alle Geldwerte auf 6 Nachkommastellen runden).buildEvents/buildTracemit den Indizestsbzw.workerId+ts;meta= geparstesmetaJsonodernull; ungültigesafter(Date.parse= NaN) ⇒ValidationError('Ungültiges Datum.'), gültiges wird alsnew Date(after).toISOString()in die Query gegeben; jedes ausgegebenets/lastEventTsläuft durchnew Date(x).toISOString()(E9); Limits klemmen statt ablehnen. Routen inapi.jsfür Team (Pfadparameter URL-dekodieren, unbekannter Worker ⇒ 404Unbekannter 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() }mitjobs: [{ 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, nurrunsPerNight ≥ 2),bericht(pauseSafe),aufraeumen(Intervall 5 min, pauseSafe:reclaimStale30 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 ausscripts/jetzt.mjs, Artenbericht,nachtplan,nachtplan2; sonstError('Unbekannte Art: …')). Imports in den Tests:createTick, DEFAULT_JOBSaussrc/tick.js,startaussrc/main.js,runJetztausscripts/jetzt.mjs,pkg= geparstepackage.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→ FAILERR_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 beipaused && !pauseSafeoderenabled(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 (markRunbzw.intervalLast.set(kind, now.ts)), damit ein fehlschlagender Lauf nicht jede Minute wiederholt wird;runintry/catch: Log +emit({ workerId: job.workerId, kind:'fehler', text:'Fehler bei <kind>: <Message>' })(selbst abgesichert, Text ohne Secrets/Stack).DEFAULT_JOBS(Tick-Tabelle, exportiert austick.js) wörtlich wie unter „Produces“;workerIdotto(beiaufraeumen:system).buildDeps:db = createDb(config.appwrite),clock = { now: () => Date.now() },log=console.logmit 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(oderover.config),createApi({ deps, appwrite: config.appwrite, version: package.json-Version }),server.listen(over.port ?? config.api.port), sofort ein Tick, danachsetInterval(tick, 60000).unref(),unhandledRejectionloggen statt abstürzen,stop()= Intervall löschen,server.close, nach höchstens 5 s Ausstieg; SignaleSIGTERM/SIGINTrufenstopund beenden den Prozess (nur wenn direkt gestartet). Logzeileworkers 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, solangeconnectedRoomsleer 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→ FAILENOENT … 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 unterservices: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-networkdazu 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 Gesamtlauftimeout 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).
- [Claude] Repo anlegen. Gitea-MCP
create_repomitname: "workers-dienst",private: true,auto_init: false,default_branch: "main", ohne Organisation (Besitzerknso, nicht Org WEBklar: deren Webhook würde jeden Push als Kundenprojekt in WOMS anlegen). Prüfen:list_my_reposzeigtknso/workers-dienstals privat. - [Kenso] Sicherungen.
Prüfen:
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)-workersls -l .env.bak-*-workers docker-compose.yml.bak-*-workers ticket.webklar.com/nginx/*.bak-*-workerszeigt drei Dateien von heute. - [Kenso] Appwrite-Schlüssel. Appwrite-Konsole → Projekt
6a1058610003c5a13a05→ Integrationen → API-Schlüssel → Nameworkers, kein Ablauf, Bereichedatabases.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 KPrüfen:command grep -c '^WORKERS_APPWRITE_API_KEY=.' /home/webklar/apps/.env→1. - [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. - [Kenso] Berichtskonto (intern, kein Kaltkontakt; Passwort wird aus Serverpuls übernommen, ohne es zu zeigen):
Prüfen:
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)" >> .envcommand grep -c '^WORKERS_REPORT_SMTP_' .env→5. Der Bericht geht ankenso.gri@gmail.com(Standard vonreportEmail); anderer Empfänger später perPUT /settingsoder im WOMS-Dialog (P2). - [Kenso] Push und Produktions-Checkout.
Prüfen:
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/workersgit ls-remote origin main | cut -c1-12undgit rev-parse HEAD | cut -c1-12sind gleich;ls /home/webklar/apps/workers/Dockerfileexistiert. (Scheitert die Anmeldung beim Klonen:git clone --branch p1-fundament /home/webklar/apps/workers-dev /home/webklar/apps/workers.) - [Kenso] Sammlungen anlegen (erst prüfen).
Erwartet: je Sammlung Zeilen mit
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 $?" )FEHLT,Exit 1. Dann derselbe Befehl ohne--check(EndeFertig.; es sind rund 180 Attribute, Appwrite legt sie nacheinander an, das dauert einige Minuten; bei einem Abbruch durchtimeoutden Befehl einfach wiederholen, er zieht Fehlendes nach), danach wieder mit--check→Alles vorhanden.,Exit 0. Erscheint „Row width limit reached“, insrc/schema.jsdie Größe des genannten Felds aufTEXT_SIZEsetzen und neu laufen lassen (Skript ist idempotent). - [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 ZeileWORKERS_SEARCH_MODEL=claude-sonnet-5-5an/home/webklar/apps/.envanhängen (die Compose-Datei reicht sie schon durch);Exit 2heißt „unklar“ (die Modell-Auskunft nennt Websuche vermutlich gar nicht): denselben Befehl mit--probewiederholen (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. - [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 meldetSchlüssel neu: 0. Prüfen: Appwrite-Konsole →workersKeyshat N Dokumente. Meldet dir jemand eine Antwort/einen Widerspruch eines der 95 alten Lackierer, sag Claude Bescheid (Eintrag in die Sperrliste, Spec §12). - [Kenso] Container starten.
Prüfen:
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 workersdocker logs --tail 20 webklar-workersenthältworkers listening on 8092und keine Zeile mit dem Wort „fehlt“;docker stats --no-stream webklar-workersunter 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 nacktesdocker compose up -d(startet Gestopptes). - [Kenso] nginx. In
/home/webklar/apps/ticket.webklar.com/nginx/ticket.webklar.com.confden Inhalt vondeploy/nginx-workers.confdirekt nach dem Serverpuls-Block (location /api/serverpuls/ { … }) einfügen, danndocker 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. - [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.JJJJbeikenso.gri@gmail.coman (auch Spam prüfen). Vor 06:30 oder zur Wiederholung:timeout 120 docker exec webklar-workers node scripts/jetzt.mjs berichtundtimeout 120 docker exec webklar-workers node scripts/jetzt.mjs nachtplan(nur solangeconnectedRoomsleer ist). Erwartet: die Mail;nachtplandruckt{"planned":[],"skipped":"keine_raeume"}und erzeugt einleerlauf-Ereignis von Otto inworkersEvents. Meldet der Berichtsmtp_authim 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. - [Kenso] Abnahme API im Browser (eingeloggt auf
https://ticket.webklar.com, Entwicklerkonsole):Erwartet: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()) }state200 mit 19 Workern (nur Otto nicht grau),events200 mit demleerlauf-Ereignis,settings200 (du bist Admin). Schlägt schon/v1/account/jwtsfehl, genügt der Test mit der WOMS-Oberfläche in P2. Antwortet die API trotz Login mit502 Anmeldedienst nicht erreichbar.oder401 Sitzung abgelaufen., im Compose-BlockAPPWRITE_ENDPOINT=https://appwrite.webklar.com/v1setzen (wie Serverpuls; der interne Endpunkt hat die JWT-Prüfung nicht bestanden) unddocker compose up -d --no-deps workerswiederholen. - [Kenso] Serverpuls kennt die App. Eintrag aus
deploy/serverpuls-apps-eintrag.jsonin/home/webklar/apps/serverpuls/config/apps.jsonergänzen (neben „Traefik“), danncd /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-serverpulsohne 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).