diff --git a/docs/superpowers/plans/2026-10-17-workers-p1-dienst-fundament.md b/docs/superpowers/plans/2026-10-17-workers-p1-dienst-fundament.md new file mode 100644 index 0000000..785ec0f --- /dev/null +++ b/docs/superpowers/plans/2026-10-17-workers-p1-dienst-fundament.md @@ -0,0 +1,1726 @@ +# Workers P1 – Dienst-Fundament – Umsetzungsplan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ein eigenständiger Node-Dienst `webklar-workers` mit Appwrite-Sammlungen, Nie-doppelt-Register samt Startbestand, Ereignisstrom, Budget-Wächter, Claude-Hülle, Otto (Nachtplan + Morgenbericht) und JWT-geschützter HTTP-API, auf dem P2–P6 aufbauen. +**Architecture:** Reine Module mit injizierten Abhängigkeiten (`deps = { db, clock, log, claude, notify }`) ohne Netz in Tests; Appwrite ist die einzige Datenhaltung (13 Sammlungen `workers*`, nur Server-Schlüssel), ein Tick alle 60 s mit neustartfesten Tagesmarkern treibt Otto, die API (Port 8092) liest daraus Zustand, Ereignisse und Spur. Betrieb als gehärteter Container `webklar-workers` hinter dem nginx von `ticket.webklar.com`. +**Tech Stack:** Node 22 (ESM, `node:test`, `node:http`), `node-appwrite@14.1.0`, `@anthropic-ai/sdk@0.135.0`, `nodemailer@10.1.2`, Docker Compose. +**Spec:** /home/webklar/apps/ticket.webklar.com/dev-workers/docs/superpowers/specs/2026-10-17-workers-design.md · **Schnittstellen:** /home/webklar/apps/ticket.webklar.com/dev-workers/docs/superpowers/plans/2026-10-17-workers-00-uebersicht.md (verbindlich) + +## Global Constraints + +- Alle Regeln aus Übersicht §2 gelten. +- Orte: Repo `knso/workers-dienst` (privat, Benutzer `knso`, **nicht** Org WEBklar); Dev-Checkout `/home/webklar/apps/workers-dev`, Branch `p1-fundament`; Betrieb `/home/webklar/apps/workers`, Branch `main`; Agenten committen lokal und pushen nie. +- Befehle laufen im Dev-Checkout; Tests: `timeout 300 node --max-old-space-size=1024 --test test/.test.js`, Gesamtlauf `… --test test/*.test.js`; ESM, keine Semikolons, deutsche Kommentare. +- Abhängigkeiten in P1 exakt `@anthropic-ai/sdk@0.135.0`, `node-appwrite@14.1.0`, `nodemailer@10.1.2`; spätere Pläne ergänzen `imapflow@2.5.2`, `mailparser@3.9.40`, `playwright-core@1.62.1` (in P1 nicht installieren). +- Keine echten Anthropic-, SMTP- oder Appwrite-Aufrufe in Tests; DB, Uhr, SDK, Transport und `fetch` kommen per Parameter (`deps`, `sdk`, `createTransport`, `fetchImpl`). +- Appwrite: Projekt `6a1058610003c5a13a05`, Datenbank `woms-database`, Rechte `[]`, `documentSecurity=false`, IDs ≤ 36 Zeichen, kein Feld `data`, JSON-Felder als Text, Zeiten ISO-8601 UTC. +- Dienst: Port 8092 nur intern (Basis `/api/workers`), in Compose weder `ports:` noch Traefik-Label; Zugang ausschließlich über nginx von `ticket.webklar.com`. +- Auth: `X-Appwrite-JWT` → Appwrite `GET /account`, 60 s Cache; Label `mitarbeiter` oder `admin` ist Pflicht (Kunden-Konten teilen das Appwrite-Projekt), `admin` für `/settings`. +- Budget: 10 $/Tag, 250 $/Monat, Warnung bei 80 %, Tag gestoppt bei 100 %; `mailColdPerDay` ≤ 3 ohne Ausnahme, `mailReplyCapPerDay` ≤ 15, `runsPerNight` 1–2. +- Preise je MTok: Opus 4/20, Sonnet 2/10, Haiku 0,10/0,50 (Prompt > 100 000 Tokens: 0,50/2,50); Cache-Lesen ×0,05 (Haiku ×0,1), Cache-Schreiben 5 min ×1,25, 1 h ×2; Batch: Tokens ×0,5; Websuche 0,01 $ je Suche (nie rabattiert). +- Modelle `claude-opus-5-5`, `claude-sonnet-5-5`, `claude-haiku-5-5`; kein `temperature`/`top_p`/`top_k`; Suche (Werkzeuge) und Strukturierung (Schema) sind getrennte Aufrufe; `pause_turn` höchstens 5 Fortsetzungen. +- API-Budgetantworten (HTTP 400 „You have reached your specified … API usage limits“, HTTP 429 `enforced_spend_limit_reached`) ⇒ `BudgetStop('console')`, Tag gestoppt, nie wiederholen. +- Zeit (Europe/Berlin, Tick 60 s): `nachtplan` ab 22:00 (Fenster bis 05:00), `nacht2` ab 01:00, `bericht` ab 06:30; Sendefenster 09:00–16:00 Mo–Fr, Feiertage werden nicht berücksichtigt. +- Register: `$id` = `sha256(kind + ":" + value)` als Hex, erste 32 Zeichen; Werte werden nie gespeichert; `namplz` ist nur Hinweis (`hint=true`). +- Alt-Passwörter (`portalPasswort`, `portal_passwort`, `portalPassword`) werden nie gelesen oder übernommen; der Seed liest `customers`/`websiteProjects` nur mit `Query.select`-Whitelist. +- Keine Schreibzugriffe auf `customers`, `workorders`, `websiteProjects`, `customerPortalAccess`, Appwrite-Benutzer (Übersicht §2.13); der Seed liest dort nur. +- Secrets (Schlüssel, Passwörter, Tokens) erscheinen nie in Logs, Fehlermeldungen, Ereignissen oder Skriptausgaben; Skripte drucken nur Zähler. +- Otto macht in P1 keinen Modellaufruf: Nachtplan und Bericht sind deterministischer Code. +- Container-Werte exakt Übersicht §9 (`mem_limit 768m`, `memswap_limit 768m`, `cpus 1.0`, `NODE_OPTIONS=--max-old-space-size=384`, …); `apk add chromium` kommt erst mit P4. +- Jede Commit-Nachricht endet mit der Zeile `Co-Authored-By: Claude Opus 5.5 `. + +## Review Focus + +- Portalkunde mit gültigem Appwrite-JWT (Label fehlt oder `customer`) ruft `/state` oder `PUT /settings` → 403 `{error}` ohne Daten; Mitarbeiter ohne `admin` → `PUT /settings` 403, `POST /pause` 200. → Test in **P1-T24**. +- Widerspruch von einer Freemail-Adresse (`x@gmail.com`) → nur diese Adresse gesperrt, nie `gmail.com`; von `info@mueller.de` → Adresse und Domain `mueller.de`. → Test in **P1-T19**. +- Dieselbe Firma in Mischschreibweisen (Telefon `+49 (0) 631 123456` / `0631/123456` / `0049 631 123456`; Website `[x](https://www.x.de/)` / `x.de/kontakt`) → je ein identischer Schlüssel; Website `keine`, Facebook-Link, Verzeichnis → kein Domain-Schlüssel; `betrieb.jimdofree.com` → volle Subdomain statt `jimdofree.com`. → Tests in **P1-T16** und **P1-T17**. +- Absturz zwischen Register-Eintrag und Anlage des Interessenten, Neustart, dieselbe Suche liefert denselben Fund mit derselben `prospectId` (Aufrufer vergeben sie über `prospectIdFor`, E10) → wieder `fresh`; ein anderer Interessent mit gleichem Schlüssel → Treffer. → Test in **P1-T18**. +- Vier gleichzeitige Claude-Aufrufe (parallele Scouts) und ein Neustart mitten in der Nacht → alle Kosten gebucht (kein verlorenes Update), Warnung/Stopp genau einmal, Nachtplan nicht doppelt. → Tests in **P1-T09**, **P1-T11**, **P1-T26**. + +## File Structure + +Repo `/home/webklar/apps/workers-dev/` (Zweck je Datei): + +| Datei | Verantwortung | +|---|---| +| `package.json`, `package-lock.json`, `.gitignore`, `.dockerignore`, `Dockerfile` | exakte Pins, Image `node:22-alpine` | +| `src/config.js` | Env → Konfiguration; Fehler nennen nur Variablennamen | +| `src/errors.js` | `HttpError`, `ValidationError`, `NotFoundError` mit `status` | +| `src/db.js` | Appwrite-Hülle (`node-appwrite`), `ConflictError`, `Query`-Re-Export, Schreibsperre `GESPERRTE_SAMMLUNGEN` | +| `src/schema.js` | `SCHEMA` (13 Sammlungen nach Übersicht §4), `rowBytes` | +| `src/roles.js` | `ROLES`, `FLOORS`, `assertCap`, `CapError`, Raum-Helfer | +| `src/events.js` | `emit` mit Art-/Rollen-Prüfung, strikt aufsteigendem `ts` | +| `src/settings.js` | Standardwerte §4.1, `getSettings`, `updateSettings` mit Validierung | +| `src/schedule.js` | `berlinNow`, `runKey`, `isDue`, `inSendWindow`, `berlinStartOfDay` | +| `src/daily.js` | einziger, serialisierter Schreiber von `workersDaily`, Lauf-Marker | +| `src/budget.js` | `costOf`, `checkBudget`, `book`, `markStopped`, `formatUsd` | +| `src/notify.js` | interne Mails an `settings.reportEmail` (Berichtskonto) | +| `src/claude.js` | `createClaude` mit `ask`, `search`, `batch`; `BudgetStop`; `BOT_VERBOT_DOMAINS` | +| `src/jobs.js` | Auftragswarteschlange `workersJobs` | +| `src/register/normalize.js` | Normalisierung (Port aus WOMS `leadDuplicates.js` + neue Funktionen) | +| `src/register/keys.js` | `keysFor`, `keyId`, `claimKeys`, `isSuppressed`, `suppress` | +| `src/register/seed.js` | reine Seed-Bausteine für Alt-Leads und Kunden | +| `src/otto/nachtplan.js` | `planNight`: Räume/Regionen wählen, `suche`-Jobs, `auftrag`-Ereignisse | +| `src/otto/bericht.js` | `buildReport`, `sendReport` (Morgenbericht) | +| `src/view.js` | `buildState`, `buildEvents`, `buildTrace` für die API | +| `src/api.js` | HTTP-Server, JWT-Prüfung, Routen | +| `src/tick.js` | `createTick`: Job-Tabelle, Marker, Pause-Regeln | +| `src/main.js` | `buildDeps`, `start`, Graceful Shutdown | +| `scripts/setup-collections.mjs` | Sammlungen anlegen, `--check` | +| `scripts/seed-register.mjs` | Startbestand, `--dry-run` | +| `scripts/check-modelle.mjs` | manuelle Prüfung `web_search`-Fähigkeit | +| `scripts/jetzt.mjs` | Bericht/Nachtplan sofort auslösen (Abnahme) | +| `deploy/compose-workers.yml`, `deploy/nginx-workers.conf`, `deploy/serverpuls-apps-eintrag.json` | Text-Bausteine für Kenso | +| `test/*.test.js`, `test/support/{helpers,fakeDb,fakeSdk,apiBoot}.js`, `test/fixtures/*` | Tests, Fakes, Fixtures | + +## Ergänzung zur Übersicht + +> Diese Punkte fehlen in `…-00-uebersicht.md`, P1 braucht sie und legt sie so fest. Der Plan-Verantwortliche übernimmt sie in die Übersicht; P2–P6 dürfen sich darauf stützen. +> +> - **E1 Schema:** §4-Felder mit `s(n)`, n ≥ 8000 (`workersJobs.payloadJson/resultJson`, `workersMessages.body`, `workersPult.originalText/draft`, `workersPreviews.reviewNotes`) werden mit `size: 20000` angelegt (Appwrite-TEXT; sonst „Row width limit reached“, 65 535 Byte je Zeile). Das §4-Maximum steht als `max` im Schema und wird von den Schreibern eingehalten. Zusätzliche Indizes (Zählabfragen und Auftragssuche): `workersJobs.idx_kind_status` (`kind`+`status`) und `workersThreads.idx_status` (`status`). +> - **E2 Rollen:** `ROLES` = Array in Tabellenreihenfolge §3 aus `{ workerId, name, floor, room, model:'opus'|'sonnet'|'haiku', role, caps }`; `FLOORS` = Array der 10 Etagen-Strings; Fixture `workers-roster.json` = `{ "floors": FLOORS, "roles": ROLES }`; `MODELS` = `{ opus:'claude-opus-5-5', sonnet:'claude-sonnet-5-5', haiku:'claude-haiku-5-5' }`; Zusätze `SEARCH_ROOMS`, `workerForRoom(room)`, `roomLabel(room)`, `hasCap`. `trace.worker` = ROLES-Eintrag + `modelId` (voller Modellname). +> - **E3 deps/DB:** `clock = { now(): number }` (ms); `log(msg)`; `notify({ subject, text }) → { sent, reason? }` (neu, `src/notify.js`, sendet an `settings.reportEmail` über `WORKERS_REPORT_SMTP_*`); `db.get` → Dokument oder `null`; `db.list`/`listAll` → Array; neu `db.count(col, queries) → number`; `Query` kommt aus `src/db.js`. +> - **E4 Tageszähler/Zeit:** neue Datei `src/daily.js` ist der einzige Schreiber von `workersDaily` (serialisiert); `book` addiert `counters` je Worker, die Schlüssel `mailsCold, mailsReply, searches, found, contacted, deals` zusätzlich in die Tagesfelder; Marker in `countersJson.__runs`. `isDue(kind, now, last)`: `now` = Ergebnis von `berlinNow(clock)`; `last` = Lauf-Schlüssel (`runKey`: Tag, bei `nachtplan`/`nacht2` der Tag des 22:00-Starts) bzw. ms-Zeitstempel bei Intervallarten. +> - **E5 Budget:** `checkBudget(deps, estUsd, { reply = false })`; `reply:true` (Gespräche: Nora, Max, Clara, Theo) ignoriert nur die Tagesgrenze (Spec 8.10), nie Monat, Pause oder Console-Stopp; `ask`/`search` nehmen `reply`; neu `markStopped(deps, reason)`; `costOf` kennt den Haiku-Langprompt-Tarif. Folge für P4: Die Pause blockiert auch Noras Einordnung (Modellaufruf); der Posteingang wird in der Pause nur abgelegt und nach Aufhebung **vor jedem Versand** eingeordnet. +> - **E6 Claude:** `createClaude({ apiKey, sdk, searchModel })`; `search` liefert zusätzlich `incomplete` (Fortsetzungsgrenze erreicht) und `toolErrors: [{ tool, code, url? }]` (z. B. `url_not_allowed` bei robots.txt ⇒ Quelle auslassen); Env `WORKERS_SEARCH_MODEL` (optional; Fallback `claude-sonnet-5-5`, falls Haiku kein `web_search` kann). +> - **E7 Register:** `keysFor` akzeptiert für `website`/`email`/`phone`/`name` auch Arrays; `claimKeys(…, { blocked:true })` setzt `blocked` auch auf bereits vorhandenen Schlüsseln; gleiche `prospectId` auf vorhandenem Schlüssel ist kein Treffer (Wiederholung nach Absturz); `isSuppressed` prüft zusätzlich `workersKeys` mit `blocked=true` (Kunden, 2 kontaktierte Alt-Leads); `suppress`/`isSuppressed` leiten die Domain aus einer Firmen-Adresse ab, nie aus einer Freemail-Adresse. +> - **E8 Jobs:** `fail(deps, jobId, error, { retry = true })` (nach 3 Versuchen `fehler`), `reclaimStale(deps, { olderThanMs })` (hängende `laeuft`-Jobs zurück auf `offen`); Otto plant nur `suche`-Jobs (Rita, Finn, Ali, Hanna, Sven, Lea); Kim und Pia treibt der Tick `alarme` (P3). +> - **E9 API:** `trace.events` **neueste zuerst**, `trace.today.tokens` = `{ in, out }`; `GET /events` ohne `after` liefert die letzten `limit` Ereignisse aufsteigend; Zeitstempel (`ts`, `lastEventTs`) immer als ISO mit `Z` (Appwrite liefert `…+00:00`, ein ungeschütztes `+` in `?after=` würde zum Leerzeichen); Fehlertexte: 401 „Nicht angemeldet.“ / „Sitzung abgelaufen.“, 403 „Kein Zugriff.“ / „Nur für Admins.“, 404 „Nicht gefunden.“, 502 „Anmeldedienst nicht erreichbar.“; `connected` eines Workers = sein Raum ist `bruecke` oder steht in `settings.connectedRooms` (alle Räume, nicht nur Suchräume). +> - **E10 Interessenten-ID:** `prospectIdFor(keys)` (`src/register/keys.js`) = `'p'` + erste 30 Zeichen von `keyId` des ersten harten Schlüssels (`kind` ≠ `namplz`), `''` ohne harten Schlüssel (dann `ID.unique()`). P3/P6 benutzen sie als `workersProspects.$id` und `claimKeys`-`prospectId`, damit eine Wiederholung nach einem Absturz dieselbe ID liefert (E7). +> - **E11 Harte Sperren im Code:** `GESPERRTE_SAMMLUNGEN` (`src/db.js`: `customers`, `workorders`, `websiteProjects`, `customerPortalAccess`) – `db.create/update/remove` darauf wirft (Übersicht §2.13); `BOT_VERBOT_DOMAINS` (`src/claude.js`) – `search` setzt sie immer als `blocked_domains` des `web_fetch`-Werkzeugs (Übersicht §2.11: Plattformen mit Bot-Verbot nie direkt abrufen; Treffer der Websuche bleiben erlaubt); `getSettings` begrenzt `mailColdPerDay` auf 3 und `mailReplyCapPerDay` auf 15 auch bei von Hand geändertem Dokument. + +--- + +### P1-T01: Gerüst, Testhelfer, Config + +**Files:** +- Create: `/home/webklar/apps/workers-dev/package.json`, `.gitignore`, `src/config.js`, `test/support/helpers.js` (zunächst `fakeClock` und `near`), `package-lock.json` (generiert) +- Test: `test/config.test.js` + +**Interfaces:** +- Produces: `loadConfig(env) → { appwrite:{endpoint,projectId,apiKey}, anthropicKey, searchModel, api:{port:8092}, vorschau:{port:8093,root:'/data/vorschau',baseUrl:'https://entwurf.project.webklar.com'}, smtp, imap, alertImap, reportSmtp, places, tz:'Europe/Berlin' }`, `ConfigError` (`.missing: string[]`), `fakeClock(iso) → { now(), set(isoOrMs), advance(ms) }`, `near(a, b)` (wirft, wenn `|a − b| ≥ 1e-9`; gemeinsam für alle Kostentests). +- Consumes: — + +- [ ] **Step 1: Gerüst anlegen und Test schreiben.** + `mkdir -p /home/webklar/apps/workers-dev && cd /home/webklar/apps/workers-dev && git init -b p1-fundament`. `package.json`: + ```json + { "name": "workers-dienst", "version": "0.1.0", "private": true, "type": "module", "engines": { "node": ">=22" }, + "scripts": { "test": "node --max-old-space-size=1024 --test test/*.test.js", "start": "node src/main.js" }, + "dependencies": { "@anthropic-ai/sdk": "0.135.0", "node-appwrite": "14.1.0", "nodemailer": "10.1.2" } } + ``` + `.gitignore`: `node_modules`, `.env`, `data/`. Dann `NODE_OPTIONS=--max-old-space-size=1024 timeout 300 npm install --ignore-scripts` (erzeugt `package-lock.json`; prüfen: `rg -m1 '"version"' node_modules/node-appwrite/package.json` zeigt `14.1.0`). `test/support/helpers.js`: `fakeClock(iso = '2026-10-19T09:00:00.000Z')` mit `now()` (ms), `set(isoOrMs)`, `advance(ms)`, dazu `near(a, b) = assert.ok(Math.abs(a - b) < 1e-9, `${a} != ${b}`)`. Test: + ```js + import test from 'node:test' + import assert from 'node:assert/strict' + import { loadConfig, ConfigError } from '../src/config.js' + const BASE = { APPWRITE_ENDPOINT: 'http://webklar-appwrite/v1', APPWRITE_PROJECT_ID: '6a1058610003c5a13a05', APPWRITE_API_KEY: 'aw-geheim-1', ANTHROPIC_API_KEY: 'sk-geheim-2' } + const REPORT = { WORKERS_REPORT_SMTP_HOST: 'mail.example', WORKERS_REPORT_SMTP_PORT: '587', WORKERS_REPORT_SMTP_USER: 'u', WORKERS_REPORT_SMTP_PASS: 'p', WORKERS_REPORT_SMTP_FROM: 'a@example.de' } + + test('fehlende Pflichtnamen: Fehler nennt nur Namen, nie Werte', () => { + assert.throws(() => loadConfig({ ...BASE, APPWRITE_API_KEY: '', ANTHROPIC_API_KEY: ' ' }), (e) => { + assert.ok(e instanceof ConfigError) + assert.deepEqual(e.missing, ['APPWRITE_API_KEY', 'ANTHROPIC_API_KEY']) + assert.ok(!e.message.includes('geheim')) + return true + }) + }) + test('Festwerte; nicht gesetzte Gruppen sind null', () => { + const c = loadConfig(BASE) + assert.equal(c.api.port, 8092) + assert.deepEqual(c.vorschau, { port: 8093, root: '/data/vorschau', baseUrl: 'https://entwurf.project.webklar.com' }) + assert.equal(c.tz, 'Europe/Berlin'); assert.equal(c.searchModel, ''); assert.equal(c.anthropicKey, 'sk-geheim-2') + for (const g of ['smtp', 'imap', 'alertImap', 'reportSmtp', 'places']) assert.equal(c[g], null) + }) + test('halb gesetzte Gruppe: Fehler nennt die fehlenden Namen der Gruppe', () => { + assert.throws(() => loadConfig({ ...BASE, WORKERS_REPORT_SMTP_HOST: 'mail.example', WORKERS_REPORT_SMTP_USER: 'u' }), + (e) => { assert.deepEqual(e.missing, ['WORKERS_REPORT_SMTP_PORT', 'WORKERS_REPORT_SMTP_PASS', 'WORKERS_REPORT_SMTP_FROM']); return true }) + }) + test('vollständige Gruppe: Port als Zahl; ungültiger Port nennt den Namen', () => { + assert.deepEqual(loadConfig({ ...BASE, ...REPORT }).reportSmtp, { host: 'mail.example', port: 587, user: 'u', pass: 'p', from: 'a@example.de' }) + assert.throws(() => loadConfig({ ...BASE, ...REPORT, WORKERS_REPORT_SMTP_PORT: 'abc' }), (e) => e.missing.includes('WORKERS_REPORT_SMTP_PORT')) + }) + ``` +- [ ] **Step 2: Test laufen lassen, Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/config.test.js` → FAIL `ERR_MODULE_NOT_FOUND` (`src/config.js`). +- [ ] **Step 3: Implementieren.** `src/config.js`: `loadConfig(env = process.env)`; Werte werden getrimmt, leer = fehlt. Pflicht: `APPWRITE_ENDPOINT`, `APPWRITE_PROJECT_ID`, `APPWRITE_API_KEY`, `ANTHROPIC_API_KEY`. Gruppen (Reihenfolge je Gruppe: HOST, PORT, USER, PASS, FROM): `smtp` = `WORKERS_SMTP_*` (mit FROM), `imap` = `WORKERS_IMAP_*` (ohne FROM), `alertImap` = `WORKERS_ALERT_IMAP_*` (ohne FROM), `reportSmtp` = `WORKERS_REPORT_SMTP_*` (mit FROM), `places` = `WORKERS_PLACES_API_KEY` → `{ apiKey }`. Komplett ungesetzt → `null`; teilweise gesetzt oder `PORT` keine ganze Zahl 1–65535 → `ConfigError` mit `.missing` in genannter Reihenfolge, Pflichtnamen zuerst; `ConfigError.message` = `Konfiguration unvollständig: ` + Namenliste. `searchModel` = `WORKERS_SEARCH_MODEL` oder `''`. +- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (4 Tests). +- [ ] **Step 5: Commit.** `git add package.json package-lock.json .gitignore src/config.js test/support/helpers.js test/config.test.js && git commit -m "P1-T01: Gerüst, Config, Testhelfer" -m "Co-Authored-By: Claude Opus 5.5 "` + +--- + +### 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.** + ```js + import { SCHEMA, rowBytes, TEXT_SIZE, collectionDef } from '../src/schema.js' + const IDS = ['workersSettings', 'workersProspects', 'workersKeys', 'workersSuppression', 'workersSearchLog', 'workersJobs', 'workersEvents', 'workersDaily', 'workersThreads', 'workersMessages', 'workersPult', 'workersOffers', 'workersPreviews'] + const attr = (c, k) => collectionDef(c).attributes.find((a) => a.key === k) + + test('13 Sammlungen in Übersichts-Reihenfolge', () => assert.deepEqual(SCHEMA.map((c) => c.id), IDS)) + test('Attribut-Schlüssel je Sammlung exakt nach §4', () => { + const KEYS = { + workersSettings: ['paused', 'dailyBudgetUsd', 'monthlyBudgetUsd', 'warnPct', 'mailColdPerDay', 'mailReplyCapPerDay', 'sendWindowStart', 'sendWindowEnd', 'sendWeekdaysOnly', 'runsPerNight', 'candidatesPerRun', 'activeRooms', 'connectedRooms', 'regions', 'previewGate', 'previewGateN', 'previewsSent', 'offerText', 'offerProductKey', 'pultStaleDays', 'retentionDays', 'followupDays', 'previewReminderDays', 'previewDeleteDays', 'reportEmail', 'updatedAt', 'updatedBy'], + workersProspects: ['entrance', 'room', 'foundBy', 'source', 'sourceUrl', 'foundAt', 'name', 'contactName', 'email', 'emailSource', 'phone', 'phoneSource', 'website', 'street', 'postalCode', 'city', 'placeId', 'requestText', 'requestedAt', 'score', 'scoreReason', 'status', 'botLeadReason', 'threadId', 'previewId', 'offerId', 'customerId', 'isHealth', 'purgeAfter', 'purgedAt', 'updatedAt'], + workersKeys: ['kind', 'prospectId', 'origin', 'blocked', 'hint', 'createdAt'], + workersSuppression: ['kind', 'reason', 'createdAt', 'source'], + workersSearchLog: ['workerId', 'room', 'region', 'query', 'at', 'found', 'fresh'], + workersJobs: ['kind', 'workerId', 'status', 'prospectId', 'threadId', 'previewId', 'attempts', 'batchId', 'customId', 'payloadJson', 'resultJson', 'error', 'createdAt', 'startedAt', 'finishedAt'], + workersEvents: ['ts', 'workerId', 'kind', 'jobId', 'prospectId', 'text', 'link', 'to', 'tokensIn', 'tokensOut', 'costUsd', 'metaJson'], + workersDaily: ['costUsd', 'mailsCold', 'mailsReply', 'searches', 'found', 'contacted', 'deals', 'countersJson', 'warned', 'stopped'], + workersThreads: ['prospectId', 'channel', 'platform', 'platformUrl', 'toEmail', 'subject', 'status', 'lastInboundAt', 'lastOutboundAt', 'followupDueAt', 'followupSent', 'reminderDueAt', 'reminderSent', 'createdAt'], + workersMessages: ['threadId', 'direction', 'author', 'subject', 'body', 'messageId', 'inReplyTo', 'referencesJson', 'sentAt', 'channel', 'contactSource', 'classification', 'classificationConfidence', 'countsAsCold', 'createdAt'], + workersPult: ['kind', 'prospectId', 'threadId', 'previewId', 'title', 'originalText', 'draft', 'sourceUrl', 'status', 'claimedBy', 'claimedAt', 'doneBy', 'doneAt', 'createdAt', 'staleAt'], + workersOffers: ['tokenHash', 'prospectId', 'previewId', 'productKey', 'businessName', 'email', 'previewUrl', 'status', 'expiresAt', 'stripeSessionId', 'customerId', 'paidAt', 'createdAt'], + workersPreviews: ['token', 'prospectId', 'builder', 'status', 'round', 'reviewNotes', 'filesJson', 'createdAt', 'deleteAfter', 'convertedProjectId'] } + for (const [id, keys] of Object.entries(KEYS)) assert.deepEqual(collectionDef(id).attributes.map((a) => a.key), keys, id) + }) + test('Regeln: id ≤ 36, kein Feld data oder $…, Index zeigt auf vorhandene Felder, Indexschlüssel ≤ 36, Pflichtfeld ohne default', () => { + for (const c of SCHEMA) { + assert.ok(c.id.length <= 36) + const keys = new Set(c.attributes.map((a) => a.key)) + for (const a of c.attributes) { assert.ok(a.key !== 'data' && !a.key.startsWith('$')); assert.ok(!(a.required && a.default !== undefined), `${c.id}.${a.key}`) } + for (const i of c.indexes) { assert.ok(i.key.length <= 36); for (const k of i.attributes) assert.ok(keys.has(k), `${c.id}.${i.key}`) } + } + }) + test('Spotchecks §4', () => { + assert.equal(attr('workersProspects', 'status').elements.length, 12) + assert.deepEqual(attr('workersJobs', 'kind').elements.length, 15) + assert.deepEqual(collectionDef('workersOffers').indexes.find((i) => i.attributes.join() === 'tokenHash').type, 'unique') + assert.ok(collectionDef('workersPult').indexes.some((i) => i.attributes.join() === 'status,createdAt')) + assert.ok(collectionDef('workersJobs').indexes.some((i) => i.key === 'idx_kind_status' && i.attributes.join() === 'kind,status')) // E1 + assert.ok(collectionDef('workersThreads').indexes.some((i) => i.key === 'idx_status' && i.attributes.join() === 'status')) // E1 + assert.equal(attr('workersSettings', 'mailColdPerDay').type, 'integer') + assert.equal(attr('workersSettings', 'activeRooms').array, true) + }) + test('E1: lange Felder sind physisch TEXT, §4-Maximum bleibt als max', () => { + for (const [c, k, max] of [['workersJobs', 'payloadJson', 16000], ['workersJobs', 'resultJson', 16000], ['workersMessages', 'body', 16000], ['workersPult', 'originalText', 8000], ['workersPult', 'draft', 8000], ['workersPreviews', 'reviewNotes', 8000]]) { + assert.equal(attr(c, k).size, TEXT_SIZE); assert.equal(attr(c, k).max, max) + } + assert.equal(TEXT_SIZE, 20000) + }) + test('rowBytes: Formel und Grenze', () => { + assert.equal(rowBytes({ attributes: [{ key: 'a', type: 'string', size: 256 }] }), 1026) // 4*256+2 + assert.equal(rowBytes({ attributes: [{ key: 'a', type: 'string', size: 64 }] }), 257) // 4*64+1 + assert.equal(rowBytes({ attributes: [{ key: 'a', type: 'string', size: 20000 }] }), 20) // TEXT + assert.ok(rowBytes({ attributes: [{ key: 'a', type: 'string', size: 16000 }, { key: 'b', type: 'string', size: 16000 }] }) > 65535) // darum E1 + for (const c of SCHEMA) assert.ok(rowBytes(c) <= 60000, c.id) + }) + ``` +- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/schema.test.js` → FAIL `ERR_MODULE_NOT_FOUND` (`src/schema.js`). +- [ ] **Step 3: Implementieren.** `src/schema.js`: Mini-DSL `s(key,size,opts)`, `i`, `f`, `b`, `dt`, `e(key,elements,opts)` und `idx(attrs…)`; `SCHEMA` Sammlung für Sammlung aus Übersicht §4 abschreiben (Mapping `s(n)`→`string`+`size`, `i`→`integer`, `f`→`float`, `b`→`boolean`, `dt`→`datetime`, `e[…]`→`enum`+`elements`, `[]`→`array:true`, `*`→`required:true`, sonst `required:false`; Reihenfolge wie §4; Indizes aus §4 mit Schlüssel `idx__`, `tokenHash` als `unique`; Zusatzindizes aus E1 mit den Schlüsseln `idx_kind_status` und `idx_status`). `name` = Sammlungs-ID. Felder aus E1 mit `size: TEXT_SIZE` und `max`. `rowBytes(c)`: je Attribut Array → 20; `string` > 16381 → 20, sonst `size*4 + (size > 255 ? 2 : 1)`; `enum` → `längstes Element*4 + 1`; `integer`/`float`/`datetime` → 8; `boolean` → 1; Summe. +- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (6 Tests). +- [ ] **Step 5: Commit.** `git add src/schema.js test/schema.test.js && git commit -m "P1-T02: Schema der 13 Workers-Sammlungen" -m "Co-Authored-By: Claude Opus 5.5 "` + +--- + +### P1-T03: Setup-Skript für die Sammlungen + +**Files:** +- Create: `scripts/setup-collections.mjs` +- Test: `test/setupCollections.test.js` + +**Interfaces:** +- Consumes: `SCHEMA` (T02). +- Produces: `main({ check, client, log, schema = SCHEMA }) → { missingCollections:[id], missingAttributes:[{collection,key}], missingIndexes:[{collection,key}] }`; `exitCodeFor(result, check) → 0|1`; `attributeCall(collectionId, attr) → [methodName, args]` (SDK-Aufruf, `args` beginnt mit `'woms-database'`); `createSetupClient({ endpoint, projectId, apiKey }, { sdk }) → { collectionExists(id), listAttributes(id), listIndexes(id), createCollection(id, name), createAttribute(id, attr), waitForAttribute(id, key), createIndex(id, index) }`. + +- [ ] **Step 1: Test schreiben.** Fake-Client im Test: Objekt `existing[id] = { attrs:[Schlüssel], idx:[Schlüssel] }`, protokolliert schreibende Aufrufe in `calls`. + ```js + import { main, exitCodeFor, attributeCall, createSetupClient } from '../scripts/setup-collections.mjs' + import { SCHEMA } from '../src/schema.js' + function fakeClient(existing = {}) { + const calls = [] + const st = structuredClone(existing) // { id: { attrs: [keys], idx: [keys] } } + return { calls, st, + collectionExists: async (id) => id in st, + listAttributes: async (id) => (st[id]?.attrs ?? []).map((key) => ({ key, status: 'available' })), + listIndexes: async (id) => (st[id]?.idx ?? []).map((key) => ({ key })), + createCollection: async (id, name) => { calls.push(['createCollection', id, name]); st[id] = { attrs: [], idx: [] } }, + createAttribute: async (id, a) => { calls.push(['createAttribute', id, a.key]); st[id].attrs.push(a.key) }, + waitForAttribute: async (id, key) => { calls.push(['wait', id, key]) }, + createIndex: async (id, i) => { calls.push(['createIndex', id, i.key]); st[id].idx.push(i.key) } } + } + test('--check meldet alles als fehlend, ändert nichts, Exit 1', async () => { + const c = fakeClient(); const lines = [] + const r = await main({ check: true, client: c, log: (l) => lines.push(l) }) + assert.equal(r.missingCollections.length, 13); assert.equal(c.calls.length, 0) + assert.equal(exitCodeFor(r, true), 1); assert.ok(lines.some((l) => l.includes('workersJobs') && l.includes('FEHLT'))) + }) + test('Lauf legt an: Reihenfolge Sammlung → Attribute → warten → Indizes; zweiter Lauf und --check sind leer', async () => { + const c = fakeClient(); await main({ check: false, client: c, log: () => {} }) + const ops = c.calls.map((x) => x[0]); assert.equal(ops.filter((o) => o === 'createCollection').length, 13) + const iJobs = c.calls.findIndex((x) => x[0] === 'createIndex' && x[1] === 'workersJobs') + assert.ok(c.calls.slice(0, iJobs).some((x) => x[0] === 'wait' && x[1] === 'workersJobs')) + const n = c.calls.length; await main({ check: false, client: c, log: () => {} }); assert.equal(c.calls.length, n) + assert.equal(exitCodeFor(await main({ check: true, client: c, log: () => {} }), true), 0) + }) + test('fehlendes Attribut in vorhandener Sammlung wird nachgezogen, sonst nichts', async () => { + const full = Object.fromEntries(SCHEMA.map((s) => [s.id, { attrs: s.attributes.map((a) => a.key), idx: s.indexes.map((i) => i.key) }])) + full.workersDaily.attrs = full.workersDaily.attrs.filter((k) => k !== 'stopped') + const c = fakeClient(full); await main({ check: false, client: c, log: () => {} }) + assert.deepEqual(c.calls.filter((x) => x[0] === 'createAttribute'), [['createAttribute', 'workersDaily', 'stopped']]) + }) + test('attributeCall: Abbildung auf node-appwrite 14 (Positionsargumente)', () => { + assert.deepEqual(attributeCall('c', { key: 'a', type: 'string', size: 64, required: true }), ['createStringAttribute', ['woms-database', 'c', 'a', 64, true, undefined, false]]) + assert.deepEqual(attributeCall('c', { key: 'a', type: 'enum', elements: ['x', 'y'], required: false }), ['createEnumAttribute', ['woms-database', 'c', 'a', ['x', 'y'], false, undefined, false]]) + assert.deepEqual(attributeCall('c', { key: 'a', type: 'string', size: 32, array: true }), ['createStringAttribute', ['woms-database', 'c', 'a', 32, false, undefined, true]]) + assert.equal(attributeCall('c', { key: 'a', type: 'integer' })[0], 'createIntegerAttribute') + assert.equal(attributeCall('c', { key: 'a', type: 'float' })[0], 'createFloatAttribute') + assert.equal(attributeCall('c', { key: 'a', type: 'boolean' })[0], 'createBooleanAttribute') + assert.equal(attributeCall('c', { key: 'a', type: 'datetime' })[0], 'createDatetimeAttribute') + }) + test('echter Client fragt Attribute/Indizes mit limit(200) (Standard wären 25) und legt Sammlungen ohne Rechte an', async () => { + const calls = []; const page = { attributes: [{ key: 'a', status: 'available' }], indexes: [{ key: 'i' }] } + class Databases { async listAttributes(...a) { calls.push(['listAttributes', ...a]); return page } async listIndexes(...a) { calls.push(['listIndexes', ...a]); return page } async createCollection(...a) { calls.push(['createCollection', ...a]) } } + class Client { setEndpoint() { return this } setProject() { return this } setKey() { return this } } + const c = createSetupClient({ endpoint: 'e', projectId: 'p', apiKey: 'k' }, { sdk: { Client, Databases } }) + await c.listAttributes('workersJobs'); await c.listIndexes('workersJobs'); await c.createCollection('workersJobs', 'workersJobs') + assert.deepEqual(calls, [['listAttributes', 'woms-database', 'workersJobs', [Query.limit(200)]], ['listIndexes', 'woms-database', 'workersJobs', [Query.limit(200)]], ['createCollection', 'woms-database', 'workersJobs', 'workersJobs', [], false, true]]) + }) + ``` + (Import im Test: `import { Query } from 'node-appwrite'`; `src/db.js` entsteht erst in T04.) +- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/setupCollections.test.js` → FAIL `ERR_MODULE_NOT_FOUND` (`scripts/setup-collections.mjs`). +- [ ] **Step 3: Implementieren.** `main`: erst alles prüfen (Sammlung, Attribute, Indizes), Zeilen `+ Collection ` / `= : n Attribute, m Indizes vorhanden` / `- . FEHLT` (Muster `/home/webklar/apps/kundenbereich.webklar.com/build/scripts/unterkunft-setup.mjs`; fehlt die Sammlung, druckt `--check` `- Collection FEHLT` und danach je Attribut/Index eine `FEHLT`-Zeile); mit `check` hier zurückkehren (Schlusszeile `Alles vorhanden.` bzw. ` Eintrag/Einträge fehlen.`); sonst je Sammlung `createCollection(id, id)`, fehlende Attribute anlegen, **danach** für jedes neue Attribut `waitForAttribute`, erst dann fehlende Indizes; Schlusszeile `Fertig.`. `attributeCall` nach den Signaturen in `node_modules/node-appwrite/dist/services/databases.d.ts` (`string`: `(db,col,key,size,required,default,array)`; `enum`: `(db,col,key,elements,required,default,array)`; `integer`/`float`: `(db,col,key,required,min,max,default,array)`; `boolean`/`datetime`: `(db,col,key,required,default,array)`); `required`/`array` default `false`. `createSetupClient` mit `sdk.Client/Databases`, `listAttributes`/`listIndexes` mit `Query.limit(200)`, `createCollection(DB, id, name, [], false, true)`, `waitForAttribute` pollt `getAttribute` jede Sekunde bis `available` (60 s, `failed` wirft). CLI (nur wenn direkt gestartet): `--check`; Env `APPWRITE_ENDPOINT` (Standard `https://appwrite.webklar.com/v1`), `APPWRITE_PROJECT_ID` (Standard `6a1058610003c5a13a05`), `APPWRITE_API_KEY` (fehlt → Meldung mit dem Namen, Exit 2); Exit `exitCodeFor`. +- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (5 Tests). +- [ ] **Step 5: Commit.** `git add scripts/setup-collections.mjs test/setupCollections.test.js && git commit -m "P1-T03: Setup-Skript mit --check" -m "Co-Authored-By: Claude Opus 5.5 "` + +--- + +### P1-T04: Appwrite-Hülle `db.js` und schema-prüfende Fake-DB + +**Files:** +- Create: `src/db.js`, `test/support/fakeDb.js` +- Modify: `test/support/helpers.js` (ergänzt `makeDeps`) +- Test: `test/db.test.js`, `test/fakeDb.test.js` + +**Interfaces:** +- Produces: `createDb({ endpoint, projectId, apiKey }, { sdk }) → { get(col,id) → doc|null, list(col,queries=[]) → doc[], listAll(col,queries=[]) → doc[], count(col,queries=[]) → number, create(col,data,id?) → doc, update(col,id,data) → doc, remove(col,id) }` (Übersicht §6 + E3); `ConflictError` (`.code = 409`); `DATABASE_ID = 'woms-database'`; `Query` (Re-Export); `GESPERRTE_SAMMLUNGEN = ['customers','workorders','websiteProjects','customerPortalAccess']` (E11): `create`/`update`/`remove` darauf werfen `Error('Schreiben in ist für Workers gesperrt')` ohne SDK-Aufruf, Lesen bleibt erlaubt. `createFakeDb({ schema = SCHEMA, clock, loose = ['customers','workorders','websiteProjects','customerPortalAccess'] })` mit derselben Schnittstelle (inkl. Schreibsperre; nur `seed` darf in lose Sammlungen schreiben) plus `seed(col, over = {}, id?) → doc` (füllt Pflichtfelder mit Typ-Standardwerten), `all(col) → doc[]`, `calls` = Array `{ op:'get'|'list'|'listAll'|'count'|'create'|'update'|'remove', col, id?, queries? }` (`listAll` protokolliert nur den eigenen Aufruf, nicht die Seiten). `makeDeps({ now, …over }) → { db, clock, log }`. +- `sdk` = `{ Client, Databases, ID }` (Standard: `import * as sdk from 'node-appwrite'`). + +- [ ] **Step 1: Tests schreiben.** `db.test.js` (Fake-SDK: `mkSdk(fns, calls)` baut `{ Client, ID, Databases }`, jede Methode aus `fns` protokolliert ihre Argumente in `calls`): + ```js + const mkSdk = (fns, calls = []) => ({ Client: class { setEndpoint() { return this } setProject() { return this } setKey() { return this } }, ID: { unique: () => 'uniq1' }, + Databases: class { constructor() { for (const [k, f] of Object.entries(fns)) this[k] = async (...a) => { calls.push([k, ...a]); return f(...a) } } } }) + const err = (code) => Object.assign(new Error('x'), { code }) + const cfg = { endpoint: 'e', projectId: 'p', apiKey: 'k' } + test('create reicht Datenbank, Sammlung, ID und Daten durch; ohne ID ID.unique(); 409 → ConflictError, sonst unverändert', async () => { + const calls = []; const db = createDb(cfg, { sdk: mkSdk({ createDocument: async (d, c, id, data) => { if (data.boom) throw err(data.boom); return { $id: id, ...data } } }, calls) }) + assert.deepEqual(await db.create('workersKeys', { a: 1 }, 'x'), { $id: 'x', a: 1 }); assert.deepEqual(calls[0], ['createDocument', DATABASE_ID, 'workersKeys', 'x', { a: 1 }]) + assert.equal((await db.create('workersKeys', { a: 1 })).$id, 'uniq1') + await assert.rejects(db.create('workersKeys', { boom: 409 }, 'x'), (e) => e instanceof ConflictError && e.code === 409) + const e500 = await db.create('workersKeys', { boom: 500 }, 'x').catch((e) => e); assert.ok(!(e500 instanceof ConflictError)); assert.equal(e500.code, 500) + }) + test('get: gefunden → Dokument; 404 → null; anderer Fehler wirft', async () => { + const db = createDb(cfg, { sdk: mkSdk({ getDocument: async (d, c, id) => { if (id === 'weg') throw err(404); if (id === 'kaputt') throw err(500); return { $id: id } } }) }) + assert.deepEqual(await db.get('c', 'a'), { $id: 'a' }); assert.equal(await db.get('c', 'weg'), null); await assert.rejects(db.get('c', 'kaputt'), (e) => e.code === 500) + }) + test('list liefert documents; count liefert total mit Query.limit(1)', async () => { + const calls = []; const db = createDb(cfg, { sdk: mkSdk({ listDocuments: async () => ({ total: 7, documents: [{ $id: 'a' }] }) }, calls) }) + assert.deepEqual(await db.list('c', [Query.equal('s', ['x'])]), [{ $id: 'a' }]); assert.equal(await db.count('c', [Query.equal('s', ['x'])]), 7) + assert.deepEqual(calls[1], ['listDocuments', DATABASE_ID, 'c', [Query.equal('s', ['x']), Query.limit(1)]]) + }) + test('listAll holt 250 Dokumente in 3 Seiten à 100 (limit + cursorAfter); eigene limit-Query wirft', async () => { + const all = Array.from({ length: 250 }, (_, i) => ({ $id: `d${i}` })); const calls = [] + const db = createDb(cfg, { sdk: mkSdk({ listDocuments: async (d, c, qs) => { const after = qs.map((q) => JSON.parse(q)).find((q) => q.method === 'cursorAfter')?.values[0]; const start = after ? Number(after.slice(1)) + 1 : 0; return { total: 250, documents: all.slice(start, start + 100) } } }, calls) }) + assert.equal((await db.listAll('c')).length, 250); assert.equal(calls.length, 3); assert.ok(calls[1][3].includes(Query.limit(100))); assert.ok(calls[1][3].includes(Query.cursorAfter('d99'))) + await assert.rejects(db.listAll('c', [Query.limit(5)]), /limit/) + }) + test('update und remove delegieren', async () => { + const calls = []; const db = createDb(cfg, { sdk: mkSdk({ updateDocument: async (d, c, id, data) => ({ $id: id, ...data }), deleteDocument: async () => ({}) }, calls) }) + assert.deepEqual(await db.update('c', 'a', { x: 1 }), { $id: 'a', x: 1 }); await db.remove('c', 'a'); assert.deepEqual(calls.map((c) => c[0]), ['updateDocument', 'deleteDocument']) + }) + test('Kundensammlungen sind für Schreibzugriffe gesperrt (Übersicht §2.13), Lesen geht; kein SDK-Schreibaufruf', async () => { + const calls = []; const db = createDb(cfg, { sdk: mkSdk({ listDocuments: async () => ({ total: 0, documents: [] }), createDocument: async () => ({}), updateDocument: async () => ({}), deleteDocument: async () => ({}) }, calls) }) + assert.deepEqual(GESPERRTE_SAMMLUNGEN, ['customers', 'workorders', 'websiteProjects', 'customerPortalAccess']) + for (const c of GESPERRTE_SAMMLUNGEN) { await assert.rejects(db.create(c, {}), /gesperrt/); await assert.rejects(db.update(c, 'x', {}), /gesperrt/); await assert.rejects(db.remove(c, 'x'), /gesperrt/); await db.list(c) } + assert.ok(calls.every((c) => c[0] === 'listDocuments')) + }) + ``` + `fakeDb.test.js` (`seed` ist synchron): + ```js + const ISO = '2026-10-19T09:00:00.000Z' + const OK = { kind: 'dom', origin: 'workers', blocked: false, hint: false, createdAt: ISO } + test('prüft Dokumente gegen SCHEMA wie Appwrite', async () => { + const db = createFakeDb() + await assert.rejects(db.create('workersKeys', { ...OK, nope: 1 }, 'a'), /Unknown attribute/) + await assert.rejects(db.create('workersKeys', { ...OK, kind: 'xx' }, 'a'), /enum/i) + await assert.rejects(db.create('workersKeys', { kind: 'dom' }, 'a'), /required/i) + await assert.rejects(db.create('workersEvents', { ts: ISO, workerId: 'otto', kind: 'leerlauf', text: 'x'.repeat(301) }, 'e'), /size|length/i) + await db.create('workersKeys', OK, 'a'); await assert.rejects(db.create('workersKeys', OK, 'a'), ConflictError) + }) + test('Länge nach max statt size: payloadJson mit 16001 Zeichen wird abgelehnt, 16000 nicht', async () => { + const db = createFakeDb(); const base = { kind: 'suche', workerId: 'ali', status: 'offen', attempts: 0, createdAt: ISO } + await assert.rejects(db.create('workersJobs', { ...base, payloadJson: 'x'.repeat(16001) }), /size|length/i); await db.create('workersJobs', { ...base, payloadJson: 'x'.repeat(16000) }) + }) + test('Standard-Limit 25 wie Appwrite; Query.limit(100) und listAll holen alle', async () => { + const db = createFakeDb(); for (let i = 0; i < 30; i++) db.seed('workersEvents', { ts: ISO, workerId: 'otto', kind: 'leerlauf', text: String(i) }) + assert.equal((await db.list('workersEvents')).length, 25); assert.equal((await db.list('workersEvents', [Query.limit(100)])).length, 30); assert.equal((await db.listAll('workersEvents')).length, 30) + }) + test('Queries: equal (mehrere Werte = ODER), greaterThan, orderDesc, offset, cursorAfter, select', async () => { + const db = createFakeDb(); for (const [i, k] of ['fund', 'doppelt', 'fund'].entries()) db.seed('workersEvents', { ts: `2026-10-19T09:00:0${i}.000Z`, workerId: 'otto', kind: k, text: `t${i}` }, `e${i}`) + const ids = async (qs) => (await db.list('workersEvents', qs)).map((d) => d.$id) + assert.deepEqual(await ids([Query.equal('kind', ['fund', 'doppelt'])]), ['e0', 'e1', 'e2']); assert.deepEqual(await ids([Query.equal('kind', ['doppelt'])]), ['e1']) + assert.deepEqual(await ids([Query.greaterThan('ts', '2026-10-19T09:00:00.000Z'), Query.orderDesc('ts')]), ['e2', 'e1']); assert.deepEqual(await ids([Query.orderAsc('ts'), Query.offset(1)]), ['e1', 'e2']) + assert.deepEqual(await ids([Query.orderAsc('ts'), Query.cursorAfter('e0'), Query.limit(1)]), ['e1']) + assert.deepEqual(Object.keys((await db.list('workersEvents', [Query.select(['kind'])]))[0]).sort(), ['$id', 'kind']) + }) + test('unbekannte Query-Methode wirft', async () => assert.rejects(createFakeDb().list('workersEvents', [Query.search('text', 'x')]), /nicht unterstützt/)) + test('unique-Index (tokenHash) → ConflictError; unbekannte Sammlung → code 404; lose Sammlung prüft nichts', async () => { + const db = createFakeDb(); db.seed('workersOffers', { tokenHash: 'h1' }); assert.throws(() => db.seed('workersOffers', { tokenHash: 'h1' }), ConflictError) + await assert.rejects(db.get('nirgends', 'x'), (e) => e.code === 404); db.seed('customers', { name: 'Freitext', irgendwas: 1 }, 'c1'); assert.equal((await db.get('customers', 'c1')).irgendwas, 1) + for (const op of [() => db.create('customers', { name: 'x' }), () => db.update('customers', 'c1', { name: 'y' }), () => db.remove('customers', 'c1')]) await assert.rejects(op(), /gesperrt/) // Schreibsperre auch im Fake (§2.13) + }) + test('$-Schlüssel in data werden abgelehnt (Appwrite verträgt kein fremdes $id); datetime darf einen Offset tragen', async () => { + const db = createFakeDb(); await db.create('workersKeys', OK, 'a') + await assert.rejects(db.update('workersKeys', 'a', { $id: 'zzz' }), /Unknown attribute/); await assert.rejects(db.update('workersKeys', 'a', { createdAt: 'gestern' }), /createdAt/) + await db.update('workersKeys', 'a', { createdAt: '2026-10-19T09:00:00.000+00:00' }) + }) + ``` +- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/db.test.js test/fakeDb.test.js` → FAIL `ERR_MODULE_NOT_FOUND`. +- [ ] **Step 3: Implementieren.** `db.js`: `new sdk.Client().setEndpoint().setProject().setKey()`, `new sdk.Databases(client)`; `create(col, data, id = sdk.ID.unique())`; Fehler mit `code === 409` → `new ConflictError(...)` (Ursache als `cause`); `get` fängt nur 404; `listAll` hängt selbst `Query.limit(100)` und `Query.cursorAfter(lastId)` an, bis eine Seite < 100 liefert (Sicherung: höchstens 1000 Seiten), eigene `limit`/`cursor`-Queries → Fehler; `count` = `total` einer `limit(1)`-Abfrage. `create`/`update`/`remove` prüfen zuerst `GESPERRTE_SAMMLUNGEN` (Fehlertext `Schreiben in ist für Workers gesperrt`). Kein Retry in dieser Schicht. `fakeDb.js`: Dokumente in Maps; jeder Aufruf wird in `calls` protokolliert (Form siehe Interfaces); Queries per `JSON.parse` der SDK-Query-Strings; `$id` (ohne ID `fake000001`…), `$createdAt`/`$updatedAt` aus `clock`; Prüfung gegen `SCHEMA` (unbekannte Felder – auch jeder `$…`-Schlüssel in `data`, Typen, `datetime` = von `Date.parse` lesbarer ISO-String, Offset erlaubt, Enum, Länge nach `max ?? size`, Pflichtfelder nur bei `create`, `unique`-Indizes → `ConflictError`; Meldungen nennen Feldnamen und Art: `Unknown attribute: `, `: enum …`, `: required`, `: size/length …`, `: ungültiger Wert`); Standard-Limit 25; nicht unterstützte Query-Methoden werfen `Query-Methode nicht unterstützt: `; `listAll` ruft die eigene `list`-Paginierung. `helpers.js`: `makeDeps({ now = '2026-10-19T09:00:00.000Z', ...over })`. +- [ ] **Step 4: Tests laufen lassen.** Gleicher Befehl → PASS. +- [ ] **Step 5: Commit.** `git add src/db.js test/support/fakeDb.js test/support/helpers.js test/db.test.js test/fakeDb.test.js && git commit -m "P1-T04: db.js mit ConflictError und schema-prüfende Fake-DB" -m "Co-Authored-By: Claude Opus 5.5 "` + +--- + +### P1-T05: Rollen-Register mit Werkzeug-Sperre + +**Files:** +- Create: `src/roles.js`, `test/fixtures/workers-roster.json` +- Test: `test/roles.test.js` + +**Interfaces:** +- Produces: `ROLES`, `FLOORS`, `MODELS`, `role(workerId)`, `assertCap(workerId, cap)` (wirft `CapError`), `CapError`, `hasCap(workerId, cap) → boolean`, `SEARCH_ROOMS`, `workerForRoom(room) → workerId|null`, `roomLabel(room) → string` (Übersicht §3, §6, E2). + +- [ ] **Step 1: Fixture und Test schreiben.** `test/fixtures/workers-roster.json` = `{ "floors": [10 Etagen aus §3], "roles": [19 Objekte `{ workerId, name, floor, room, model, role, caps }` in Tabellenreihenfolge; `role` = Zelle „Einzige Rolle“ wörtlich] }` (WOMS bekommt später dieselbe Datei). Test: + ```js + const roster = JSON.parse(readFileSync(new URL('./fixtures/workers-roster.json', import.meta.url), 'utf8')) + test('ROLES/FLOORS entsprechen der gemeinsamen Fixture (Spiegel in WOMS)', () => assert.deepEqual({ floors: FLOORS, roles: ROLES }, roster)) + test('19 Rollen, 10 Etagen in Reihenfolge', () => { + assert.equal(ROLES.length, 19) + assert.deepEqual(FLOORS, ['bruecke', 'anfragen', 'ohne_website', 'bewertung', 'gespraeche', 'werkstatt', 'pruefung', 'freigabe', 'tresor', 'keller']) + assert.deepEqual(MODELS, { opus: 'claude-opus-5-5', sonnet: 'claude-sonnet-5-5', haiku: 'claude-haiku-5-5' }) + }) + test('Besetzung exakt nach Übersicht §3 (workerId, Etage, Raum, Modell)', () => assert.deepEqual(ROLES.map((r) => [r.workerId, r.floor, r.room, r.model]), [ + ['otto', 'bruecke', 'bruecke', 'opus'], ['rita', 'anfragen', 'netz', 'haiku'], ['kim', 'anfragen', 'kleinanzeigen', 'haiku'], ['pia', 'anfragen', 'portale', 'haiku'], ['finn', 'anfragen', 'foren', 'haiku'], + ['ali', 'ohne_website', 'aerzte', 'haiku'], ['hanna', 'ohne_website', 'handwerk', 'haiku'], ['sven', 'ohne_website', 'selbst', 'haiku'], ['lea', 'ohne_website', 'laden', 'haiku'], ['bea', 'bewertung', 'waage', 'sonnet'], + ['max', 'gespraeche', 'mail', 'sonnet'], ['clara', 'gespraeche', 'forum', 'sonnet'], ['nora', 'gespraeche', 'posteingang', 'haiku'], ['theo', 'gespraeche', 'abschluss', 'sonnet'], + ['bob', 'werkstatt', 'tisch1', 'opus'], ['bianca', 'werkstatt', 'tisch2', 'opus'], ['ben', 'werkstatt', 'tisch3', 'opus'], ['paul', 'pruefung', 'pruefung', 'sonnet'], ['arne', 'keller', 'archiv', 'haiku']])) + test('Fähigkeiten sind getrennt: send_mail nur Max, pult nur Clara und Theo', () => { + assert.deepEqual(ROLES.filter((r) => r.caps.includes('send_mail')).map((r) => r.workerId), ['max']) + assert.deepEqual(ROLES.filter((r) => r.caps.includes('pult')).map((r) => r.workerId), ['clara', 'theo']) + assert.deepEqual(role('ali').caps, ['web_search', 'web_fetch', 'places', 'osm']); assert.deepEqual(role('otto').caps, ['plan', 'report']) + }) + test('assertCap: erlaubt → still; verboten/unbekannt → CapError mit workerId und cap', () => { + assertCap('max', 'send_mail') + assert.throws(() => assertCap('kim', 'send_mail'), (e) => e instanceof CapError && /kim/.test(e.message) && /send_mail/.test(e.message)) + assert.throws(() => assertCap('niemand', 'plan'), CapError) + assert.equal(hasCap('rita', 'web_fetch'), true); assert.equal(hasCap('kim', 'web_search'), false) + }) + test('Räume', () => { + assert.deepEqual(SEARCH_ROOMS, ['netz', 'kleinanzeigen', 'portale', 'foren', 'aerzte', 'handwerk', 'selbst', 'laden']) + assert.equal(workerForRoom('handwerk'), 'hanna'); assert.equal(workerForRoom('gibtsnicht'), null) + assert.equal(roomLabel('aerzte'), 'Ärzte & Praxen'); assert.equal(roomLabel('foren'), 'Foren & Fragen'); assert.equal(roomLabel('laden'), 'Läden & Gastro') + }) + ``` +- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/roles.test.js` → FAIL `ERR_MODULE_NOT_FOUND`. +- [ ] **Step 3: Implementieren.** `ROLES` (eingefroren) wörtlich aus Übersicht §3 / Fixture; `SEARCH_ROOMS` = Räume der Etagen `anfragen` und `ohne_website` in `ROLES`-Reihenfolge; `roomLabel`: Tabelle aus Spec §5 (netz „Netz-Radar“, kleinanzeigen „Kleinanzeigen“, portale „Portale“, foren „Foren & Fragen“, aerzte „Ärzte & Praxen“, handwerk „Handwerk“, selbst „Selbstständige“, laden „Läden & Gastro“, übrige: Raum-ID); `CapError.message` = `Worker darf 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 "` + +--- + +### 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.** + ```js + const KINDS = ['auftrag', 'leerlauf', 'gelesen', 'fund', 'doppelt', 'uebergabe', 'bewertet', 'top3', 'botlead', 'entwurf', 'pult', 'gesendet', 'eingang', 'sortiert', 'bau_start', 'bau_fertig', 'pruefung_zurueck', 'pruefung_ok', 'vorschau_link', 'angebot', 'deal', 'gesperrt', 'budget_warnung', 'budget_stopp', 'pause', 'fehler'] + test('EVENT_KINDS = Übersicht §5', () => assert.deepEqual(EVENT_KINDS, KINDS)) + test('schreibt alle Felder; ts aus der Uhr; meta als JSON', async () => { + const deps = makeDeps() + const e = await emit(deps, { workerId: 'kim', kind: 'gelesen', text: 'Kim hat 3 Suchaufträge gelesen.', prospectId: 'p1', to: 'bea', tokensIn: 10, tokensOut: 5, costUsd: 0.002, meta: { count: 3 } }) + assert.equal(e.ts, '2026-10-19T09:00:00.000Z'); assert.equal(e.metaJson, '{"count":3}'); assert.equal(e.to, 'bea'); assert.equal(e.workerId, 'kim') + }) + test('unbekannte Art, Worker oder Ziel → Fehler, nichts geschrieben; system ist erlaubt', async () => { + const deps = makeDeps() + await assert.rejects(emit(deps, { workerId: 'kim', kind: 'quatsch', text: 'x' }), /Ereignisart/) + await assert.rejects(emit(deps, { workerId: 'niemand', kind: 'fehler', text: 'x' }), /Worker/) + await assert.rejects(emit(deps, { workerId: 'kim', kind: 'uebergabe', text: 'x', to: 'niemand' }), /Ziel/) + assert.equal(deps.db.all('workersEvents').length, 0) + await emit(deps, { workerId: 'system', kind: 'pause', text: 'Pause.' }) + }) + test('text > 300 wird auf genau 300 gekürzt (… am Ende); zu große meta wird {"truncated":true}', async () => { + const deps = makeDeps() + const e = await emit(deps, { workerId: 'otto', kind: 'leerlauf', text: 'ä'.repeat(400), meta: { x: 'y'.repeat(5000) } }) + assert.equal(e.text.length, 300); assert.ok(e.text.endsWith('…')); assert.equal(e.metaJson, '{"truncated":true}') + }) + test('gleiche Millisekunde → ts strikt aufsteigend (sonst verliert der Poll "after=" Ereignisse)', async () => { + const deps = makeDeps({ now: '2026-10-19T09:00:00.000Z' }) + const ts = []; for (let i = 0; i < 3; i++) ts.push((await emit(deps, { workerId: 'otto', kind: 'leerlauf', text: 'a' })).ts) + assert.deepEqual(ts, ['2026-10-19T09:00:00.000Z', '2026-10-19T09:00:00.001Z', '2026-10-19T09:00:00.002Z']) + deps.clock.set('2026-10-19T09:00:05.000Z'); assert.equal((await emit(deps, { workerId: 'otto', kind: 'leerlauf', text: 'b' })).ts, '2026-10-19T09:00:05.000Z') + }) + ``` +- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/events.test.js` → FAIL `ERR_MODULE_NOT_FOUND`. +- [ ] **Step 3: Implementieren.** Fehlertexte enthalten `Ereignisart`, `Worker`, `Ziel`. `ts` = `max(clock.now(), letztes ts dieser DB + 1)` (letztes `ts` je `deps.db` in einer `WeakMap`); `text` > 300 → erste 299 Zeichen + `…`; `link` auf 1024 kürzen; `metaJson` nur wenn `meta` gesetzt und serialisiert ≤ 4000, sonst `{"truncated":true}`; nicht gesetzte optionale Felder weglassen; Rückgabe = angelegtes Dokument. +- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (5 Tests). +- [ ] **Step 5: Commit.** `git add src/events.js test/events.test.js && git commit -m "P1-T06: Ereignisstrom emit mit strikt aufsteigendem ts" -m "Co-Authored-By: Claude Opus 5.5 "` + +--- + +### P1-T07: Fehlerklassen und Einstellungen + +**Files:** +- Create: `src/errors.js`, `src/settings.js` +- Test: `test/settings.test.js` + +**Interfaces:** +- Produces: `HttpError(status, message)`, `ValidationError` (400), `NotFoundError` (404); `DEFAULTS`; `getSettings(deps) → Einstellungen` (Standardwerte §4.1, gespeichertes Dokument darüber gelegt, ohne `$`-Felder); `updateSettings(deps, patch, by) → Einstellungen`; `publicSettings(settings)` (ohne `previewsSent`) (Übersicht §6). +- Consumes: `ROLES`/`SEARCH_ROOMS` (T05), `deps.db`, `deps.clock`. + +- [ ] **Step 1: Test schreiben.** + ```js + test('Standardwerte §4.1 ohne gespeichertes Dokument', async () => { + const s = await getSettings(makeDeps()) + assert.deepEqual([s.paused, s.dailyBudgetUsd, s.monthlyBudgetUsd, s.warnPct, s.mailColdPerDay, s.mailReplyCapPerDay], [false, 10, 250, 80, 3, 15]) + assert.deepEqual([s.sendWindowStart, s.sendWindowEnd, s.sendWeekdaysOnly, s.runsPerNight, s.candidatesPerRun], ['09:00', '16:00', true, 1, 10]) + assert.deepEqual(s.activeRooms, ['netz', 'kleinanzeigen', 'portale', 'foren', 'aerzte', 'handwerk', 'selbst', 'laden']) + assert.deepEqual([s.connectedRooms, s.regions], [[], ['Rheinland-Pfalz']]) + assert.deepEqual([s.previewGate, s.previewGateN, s.previewsSent, s.offerProductKey], ['erste_n', 10, 0, 'hosting']) + assert.equal(s.offerText, 'Gratis-Vorschau, unverbindlich. Wenn sie gefällt, ist die Website im Hosting-Abo enthalten: 20 € im Monat, 12 Monate Mindestlaufzeit, ohne MwSt. nach § 19 UStG.') + assert.deepEqual([s.pultStaleDays, s.retentionDays, s.followupDays, s.previewReminderDays, s.previewDeleteDays], [3, 90, 7, 5, 60]) + assert.equal(s.reportEmail, 'kenso.gri@gmail.com') + }) + test('updateSettings legt Dokument "main" an und aktualisiert danach; setzt updatedBy/updatedAt', async () => { + const deps = makeDeps(); const s = await updateSettings(deps, { dailyBudgetUsd: 12 }, 'u1') + assert.equal(s.dailyBudgetUsd, 12); assert.equal(s.updatedBy, 'u1'); assert.equal(deps.db.all('workersSettings')[0].$id, 'main') + await updateSettings(deps, { paused: true }, 'u2'); assert.equal(deps.db.all('workersSettings').length, 1) + assert.deepEqual([(await getSettings(deps)).dailyBudgetUsd, (await getSettings(deps)).paused], [12, true]) + }) + test('Validierung: ungültige Patches werfen ValidationError (400, deutsch, mit Feldname) und ändern nichts', async () => { + const deps = makeDeps() + for (const [patch, re] of [[{ mailColdPerDay: 4 }, /mailColdPerDay/], [{ mailColdPerDay: 3.5 }, /mailColdPerDay/], [{ mailReplyCapPerDay: 16 }, /mailReplyCapPerDay/], [{ dailyBudgetUsd: 0 }, /dailyBudgetUsd/], + [{ monthlyBudgetUsd: -1 }, /monthlyBudgetUsd/], [{ warnPct: 101 }, /warnPct/], [{ sendWindowStart: '9:00' }, /sendWindowStart/], [{ sendWindowStart: '16:00', sendWindowEnd: '09:00' }, /sendWindow/], + [{ runsPerNight: 3 }, /runsPerNight/], [{ activeRooms: ['nirgends'] }, /activeRooms/], [{ connectedRooms: ['nirgends'] }, /connectedRooms/], [{ previewGate: 'vielleicht' }, /previewGate/], + [{ previewsSent: 5 }, /previewsSent/], [{ unbekannt: 1 }, /unbekannt/], [{ reportEmail: 'kein-mail' }, /reportEmail/], [{ offerProductKey: 'gratis' }, /offerProductKey/], [{ regions: [] }, /regions/]]) + await assert.rejects(updateSettings(deps, patch, 'u1'), (e) => e instanceof ValidationError && e.status === 400 && re.test(e.message), JSON.stringify(patch)) + assert.equal(deps.db.all('workersSettings').length, 0) + await updateSettings(deps, { mailColdPerDay: 3, connectedRooms: ['netz', 'bruecke', 'waage'] }, 'u1') // 3 ist erlaubt; connectedRooms darf jeden bekannten Raum enthalten + }) + test('getSettings begrenzt die Mail-Grenzen auch bei von Hand geändertem Dokument (E11)', async () => { + const deps = makeDeps(); deps.db.seed('workersSettings', { mailColdPerDay: 50, mailReplyCapPerDay: 99, runsPerNight: 9 }, 'main') + const s = await getSettings(deps); assert.deepEqual([s.mailColdPerDay, s.mailReplyCapPerDay, s.runsPerNight], [3, 15, 2]) + }) + test('publicSettings entfernt previewsSent und $-Felder', async () => { const p = publicSettings(await getSettings(makeDeps())); assert.ok(!('previewsSent' in p)); assert.ok(!Object.keys(p).some((k) => k.startsWith('$'))) }) + ``` +- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/settings.test.js` → FAIL `ERR_MODULE_NOT_FOUND`. +- [ ] **Step 3: Implementieren.** `errors.js`: `HttpError extends Error` mit `status`; Unterklassen. `settings.js`: `DEFAULTS` wie im Test (Arrays je Aufruf kopieren); `getSettings` = `db.get('workersSettings','main')` über `DEFAULTS` gelegt (nur bekannte Schlüssel), danach `mailColdPerDay` auf höchstens 3, `mailReplyCapPerDay` auf höchstens 15 und `runsPerNight` auf höchstens 2 begrenzt (harte Grenzen gelten auch, wenn das Dokument in der Appwrite-Konsole geändert wurde). `updateSettings`: ganzen Patch zuerst validieren (erst danach schreiben), dann `get`: fehlt → `create(…, 'main')` mit allen `DEFAULTS` + Patch (bei `ConflictError` weiter mit `update`), sonst `update`. Grenzen: Budgets > 0; `warnPct` ganze Zahl 1–100; `mailColdPerDay` ganze Zahl 0–3; `mailReplyCapPerDay` 0–15; `sendWindow*` `HH:MM`, Start < Ende; `runsPerNight` 1–2; `candidatesPerRun` 1–20; `activeRooms` ⊆ `SEARCH_ROOMS`; `connectedRooms` ⊆ alle `room`-Werte aus `ROLES`; `regions` 1–30 Strings (je 1–64 Zeichen); `previewGate` ∈ {erste_n, immer, nie}; `previewGateN` 0–1000; `offerText` 1–1000 Zeichen; `offerProductKey` ∈ {hosting, wartung}; `pultStaleDays` 1–30; `retentionDays` 7–365; `followupDays`/`previewReminderDays` 1–60; `previewDeleteDays` 7–365; `reportEmail` einfache Adressprüfung ≤ 256; `paused`/`sendWeekdaysOnly` Boolean; `previewsSent` im Patch → „internes Feld“; Meldungen deutsch, beginnen mit dem Feldnamen. +- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (5 Tests). +- [ ] **Step 5: Commit.** `git add src/errors.js src/settings.js test/settings.test.js && git commit -m "P1-T07: Einstellungen mit Validierung" -m "Co-Authored-By: Claude Opus 5.5 "` + +--- + +### P1-T08: Zeitplan (Berliner Zeit, Nachtfenster, Sendefenster) + +**Files:** +- Create: `src/schedule.js` +- Test: `test/schedule.test.js` + +**Interfaces:** +- Produces: `berlinNow(clock) → { ts, day:'YYYY-MM-DD', hour, minute, weekday:1..7 }`; `berlinStartOfDay(day) → ISO-UTC`; `nightKey(now) → 'YYYY-MM-DD'`; `runKey(kind, now) → string|null`; `isDue(kind, now, last) → boolean`; `inSendWindow(now, settings) → boolean` (Übersicht §6 + E4). +- Arten: täglich `bericht` 06:30, `kuerzen` 03:00, `pultAlt` 07:00; Nacht `nachtplan` (22:00–04:59), `nacht2` (01:00–04:59); Intervall `posteingang` 2 min, `alarme` 10 min, `senden` 5 min, `batches` 5 min, `aufraeumen` 5 min (intern, T26). + +- [ ] **Step 1: Test schreiben.** + ```js + const at = (iso) => berlinNow({ now: () => Date.parse(iso) }) + test('berlinNow: Sommer- und Winterzeit, Wochentag', () => { + const p = ({ day, hour, minute, weekday }) => ({ day, hour, minute, weekday }) + assert.deepEqual(p(at('2026-10-17T20:00:00Z')), { day: '2026-10-17', hour: 22, minute: 0, weekday: 6 }) // Samstag, MESZ + assert.deepEqual(p(at('2026-11-17T21:00:00Z')), { day: '2026-11-17', hour: 22, minute: 0, weekday: 2 }) // Dienstag, MEZ + }) + test('berlinStartOfDay', () => { + assert.equal(berlinStartOfDay('2026-10-01'), '2026-09-30T22:00:00.000Z'); assert.equal(berlinStartOfDay('2026-11-01'), '2026-10-31T23:00:00.000Z') + }) + test('täglich: bericht ab 06:30, einmal pro Tag, auch an der Zeitumstellung', () => { + assert.equal(isDue('bericht', at('2026-10-18T04:29:00Z'), null), false); assert.equal(isDue('bericht', at('2026-10-18T04:30:00Z'), null), true) + assert.equal(isDue('bericht', at('2026-10-18T09:00:00Z'), '2026-10-18'), false) + assert.equal(isDue('bericht', at('2026-10-25T05:29:00Z'), null), false); assert.equal(isDue('bericht', at('2026-10-25T05:30:00Z'), null), true) // 25.10.: MEZ, 06:30 = 05:30Z + }) + test('Nacht: nachtplan 22:00–04:59 mit Nacht-Tag; nacht2 ab 01:00', () => { + assert.equal(isDue('nachtplan', at('2026-10-17T19:59:00Z'), null), false) // 21:59 + assert.equal(isDue('nachtplan', at('2026-10-17T20:00:00Z'), null), true) + assert.equal(isDue('nachtplan', at('2026-10-17T20:00:00Z'), '2026-10-17'), false) + assert.equal(runKey('nachtplan', at('2026-10-17T22:30:00Z')), '2026-10-17') // 00:30 am 18. gehört zur Nacht des 17. + assert.equal(isDue('nachtplan', at('2026-10-17T22:30:00Z'), null), true) // Nachholen nach Neustart + assert.equal(isDue('nachtplan', at('2026-10-18T03:00:00Z'), null), false) // 05:00: Fenster vorbei + assert.equal(isDue('nacht2', at('2026-10-17T22:59:00Z'), null), false); assert.equal(isDue('nacht2', at('2026-10-17T23:00:00Z'), null), true) + }) + test('Intervall: last ist ms-Zeitstempel', () => { + const n = at('2026-10-19T08:00:00Z') + assert.equal(isDue('posteingang', n, null), true); assert.equal(isDue('posteingang', n, n.ts - 119999), false); assert.equal(isDue('posteingang', n, n.ts - 120000), true) + assert.equal(isDue('batches', n, n.ts - 299999), false); assert.equal(isDue('aufraeumen', n, n.ts - 300000), true); assert.equal(runKey('posteingang', n), null) + assert.throws(() => isDue('quatsch', n, null), /Unbekannte Art/) + }) + test('inSendWindow: Mo–Fr 09:00 inklusive bis 16:00 exklusive', () => { + const s = { sendWindowStart: '09:00', sendWindowEnd: '16:00', sendWeekdaysOnly: true } + assert.equal(inSendWindow(at('2026-10-19T06:59:00Z'), s), false); assert.equal(inSendWindow(at('2026-10-19T07:00:00Z'), s), true) // Montag 08:59 / 09:00 + assert.equal(inSendWindow(at('2026-10-19T13:59:00Z'), s), true); assert.equal(inSendWindow(at('2026-10-19T14:00:00Z'), s), false) // 15:59 / 16:00 + assert.equal(inSendWindow(at('2026-10-17T09:00:00Z'), s), false) // Samstag + assert.equal(inSendWindow(at('2026-10-17T09:00:00Z'), { ...s, sendWeekdaysOnly: false }), true) + }) + ``` +- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/schedule.test.js` → FAIL `ERR_MODULE_NOT_FOUND`. +- [ ] **Step 3: Implementieren.** `Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin', hourCycle: 'h23', … })` wie `serverpuls/src/schedule.js`. `berlinStartOfDay(day)`: UTC-Mitternacht von `day` minus 2 h bzw. 1 h – derjenige Kandidat, dessen `berlinNow` genau `day 00:00` ergibt. `nightKey(now)` = Berliner Tag von `now.ts − 5 h`. `runKey`: täglich → `now.day`; Nachtarten → `nightKey`; Intervall → `null`. `isDue`: täglich/Nacht → Zeitfenster (in Minuten seit Mitternacht; `nachtplan`: ≥ 22:00 oder < 05:00; `nacht2`: ≥ 01:00 und < 05:00) und `last !== runKey`; Intervall → `last == null || now.ts − last ≥ Intervall`; unbekannte Art → `Error('Unbekannte Art: …')`. `inSendWindow` vergleicht `HH:MM` aus den Einstellungen als Minuten. +- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (6 Tests). +- [ ] **Step 5: Commit.** `git add src/schedule.js test/schedule.test.js && git commit -m "P1-T08: Zeitplan mit Berliner Zeit und Nachtfenster" -m "Co-Authored-By: Claude Opus 5.5 "` + +--- + +### P1-T09: Tageszähler `daily.js` (serialisiert, Lauf-Marker) + +**Files:** +- Create: `src/daily.js` +- Test: `test/daily.test.js` + +**Interfaces:** +- Consumes: `deps.db`, `berlinStartOfDay` (T08). +- Produces: Tagesobjekt `{ $id, costUsd, mailsCold, mailsReply, searches, found, contacted, deals, warned, stopped, counters }` (`counters` = geparstes `countersJson`, fehlt es → `{}`); `getDaily(deps, day) → Tagesobjekt|null`; `updateDaily(deps, day, mutate) → Tagesobjekt` (legt fehlenden Tag mit Nullwerten an, `mutate(obj)` ändert in place, alle Aufrufe je `deps.db` laufen nacheinander); `monthDocs(deps, now) → Tagesobjekt[]` (Tage des Berliner Monats von `now.day`); `markRun(deps, kind, key)`; `lastRun(deps, kind, key) → string|null` (E4). + +- [ ] **Step 1: Test schreiben.** + ```js + test('getDaily: fehlender Tag → null; updateDaily legt mit Nullwerten an und liefert geparste counters', async () => { + const deps = makeDeps(); assert.equal(await getDaily(deps, '2026-10-19'), null) + const d = await updateDaily(deps, '2026-10-19', (x) => { x.found += 2; x.counters.kim = { gelesen: 3 } }) + assert.deepEqual([d.$id, d.costUsd, d.found, d.warned, d.stopped, d.counters], ['2026-10-19', 0, 2, false, false, { kim: { gelesen: 3 } }]) + assert.equal(deps.db.all('workersDaily')[0].countersJson, '{"kim":{"gelesen":3}}') + }) + test('vier gleichzeitige Updates verlieren nichts (Sperre je DB)', async () => { + const deps = makeDeps() + await Promise.all([1, 2, 3, 4].map(() => updateDaily(deps, '2026-10-19', (d) => { d.costUsd += 0.5; d.counters.kim = { gelesen: (d.counters.kim?.gelesen ?? 0) + 1 } }))) + const d = await getDaily(deps, '2026-10-19'); assert.equal(d.costUsd, 2); assert.equal(d.counters.kim.gelesen, 4) + }) + test('ein Fehler in mutate gibt die Sperre frei und schreibt nichts', async () => { + const deps = makeDeps(); await assert.rejects(updateDaily(deps, '2026-10-19', () => { throw new Error('kaputt') }), /kaputt/) + await updateDaily(deps, '2026-10-19', (d) => { d.found = 1 }); assert.equal((await getDaily(deps, '2026-10-19')).found, 1) + }) + test('Zähler-JSON über 4000 Zeichen → Fehler statt stiller Kürzung', async () => { + await assert.rejects(updateDaily(makeDeps(), '2026-10-19', (d) => { d.counters.x = 'y'.repeat(4000) }), /countersJson/) + }) + test('monthDocs: nur Tage des Berliner Monats (Grenze 00:00 Berlin)', async () => { + const deps = makeDeps({ now: '2026-09-30T21:59:59.000Z' }); await updateDaily(deps, '2026-09-30', (d) => { d.costUsd = 1 }) // 23:59:59 Berlin = September + deps.clock.set('2026-09-30T22:00:00.000Z'); await updateDaily(deps, '2026-10-01', (d) => { d.costUsd = 2 }) // 00:00 Berlin = Oktober + deps.clock.set('2026-10-19T09:00:00.000Z'); await updateDaily(deps, '2026-10-19', (d) => { d.costUsd = 3 }) + assert.deepEqual((await monthDocs(deps, berlinNow(deps.clock))).map((d) => d.$id).sort(), ['2026-10-01', '2026-10-19']) + }) + test('Lauf-Marker: pro Art und Schlüssel, überlebt einen Neustart (neues deps mit gleicher DB)', async () => { + const deps = makeDeps(); assert.equal(await lastRun(deps, 'bericht', '2026-10-19'), null) + await markRun(deps, 'bericht', '2026-10-19'); await markRun(deps, 'nachtplan', '2026-10-19') + const neu = { ...deps }; assert.equal(await lastRun(neu, 'bericht', '2026-10-19'), '2026-10-19'); assert.equal(await lastRun(neu, 'kuerzen', '2026-10-19'), null) + assert.deepEqual((await getDaily(neu, '2026-10-19')).counters.__runs, { bericht: '2026-10-19', nachtplan: '2026-10-19' }) + }) + ``` +- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/daily.test.js` → FAIL `ERR_MODULE_NOT_FOUND` (`src/daily.js`). +- [ ] **Step 3: Implementieren.** Sperre: `WeakMap`-Kette, Fehler brechen die Kette nicht. `updateDaily`: `get` → fehlt: `create(…, day)` mit Nullwerten (`countersJson: '{}'`, bei `ConflictError` neu lesen) → `mutate` auf dem Objekt → `costUsd` auf 6 Nachkommastellen runden → `JSON.stringify(counters)` > 4000 Zeichen wirft `Error('countersJson zu groß')` → `update` mit **nur** den Schema-Feldern (`costUsd, mailsCold, mailsReply, searches, found, contacted, deals, countersJson, warned, stopped`), nie mit `$…`-Feldern oder `counters`. `monthDocs`: `list('workersDaily', [Query.greaterThanEqual('$createdAt', berlinStartOfDay()), Query.limit(40)])`. `markRun` schreibt `counters.__runs[kind] = key` in das Dokument `key`. +- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (6 Tests). +- [ ] **Step 5: Commit.** `git add src/daily.js test/daily.test.js && git commit -m "P1-T09: Tageszähler mit Sperre und Lauf-Markern" -m "Co-Authored-By: Claude Opus 5.5 "` + +--- + +### P1-T10: Kostenformel `costOf` + +**Files:** +- Create: `src/budget.js` (zunächst nur Kostenteil) +- Test: `test/budgetCost.test.js` + +**Interfaces:** +- Produces: `costOf(model, usage, { batch = false }) → USD (Zahl)` (Übersicht §6); `priceOf(model) → { in, out }` ($/MTok, Normaltarif); `estimateUsd(model, chars, maxTokens) → USD` (Eingabe grob `ceil(chars/3)` Tokens zum Normaltarif + `maxTokens` Ausgabe). +- `usage` = Anthropic-Usage-Objekt (`input_tokens`, `output_tokens`, `cache_read_input_tokens`, `cache_creation_input_tokens`, `cache_creation:{ephemeral_5m_input_tokens, ephemeral_1h_input_tokens}`, `server_tool_use:{web_search_requests, web_fetch_requests}`); fehlende Felder zählen 0. + +- [ ] **Step 1: Test schreiben.** + ```js + // near aus test/support/helpers.js (T01) + test('Grundpreise je MTok', () => { + near(costOf('claude-opus-5-5', { input_tokens: 1e6, output_tokens: 1e5 }), 6) + near(costOf('claude-sonnet-5-5', { input_tokens: 1e6, output_tokens: 1e6 }), 12) + near(costOf('claude-haiku-5-5', { input_tokens: 1e6, output_tokens: 1e6 }), 0.6) + }) + test('Cache: Lesen ×0,05 (Haiku ×0,1), Schreiben 5 min ×1,25, 1 h ×2; ohne Aufschlüsselung = 5 min', () => { + near(costOf('claude-opus-5-5', { cache_read_input_tokens: 1e6 }), 0.2); near(costOf('claude-sonnet-5-5', { cache_read_input_tokens: 1e6 }), 0.1); near(costOf('claude-haiku-5-5', { cache_read_input_tokens: 1e6 }), 0.01) + near(costOf('claude-opus-5-5', { cache_creation: { ephemeral_5m_input_tokens: 1e6 } }), 5); near(costOf('claude-opus-5-5', { cache_creation: { ephemeral_1h_input_tokens: 1e6 } }), 8) + near(costOf('claude-sonnet-5-5', { cache_creation_input_tokens: 1e6 }), 2.5) + }) + test('Batch halbiert Tokens, nicht die Suche', () => { + near(costOf('claude-sonnet-5-5', { input_tokens: 1e6, output_tokens: 1e6 }, { batch: true }), 6) + near(costOf('claude-haiku-5-5', { input_tokens: 1e6, server_tool_use: { web_search_requests: 2 } }, { batch: true }), 0.07) + }) + test('Websuche 0,01 $, Web-Fetch kostenlos', () => near(costOf('claude-haiku-5-5', { input_tokens: 10000, output_tokens: 2000, server_tool_use: { web_search_requests: 3, web_fetch_requests: 5 } }), 0.032)) + test('Haiku-Langprompt: > 100 000 Prompt-Tokens (inkl. Cache) → 0,50/2,50; genau 100 000 noch Normaltarif', () => { + near(costOf('claude-haiku-5-5', { input_tokens: 150000, output_tokens: 10000 }), 0.1); near(costOf('claude-haiku-5-5', { input_tokens: 100000, output_tokens: 10000 }), 0.015) + near(costOf('claude-haiku-5-5', { input_tokens: 50000, cache_read_input_tokens: 60000 }), 0.028) // Prompt 110 000 inkl. Cache → Langtarif: 50 000×0,50 + 60 000×0,05 je MTok + }) + test('unbekanntes Modell wirft; leeres usage = 0; priceOf/estimateUsd', () => { + assert.throws(() => costOf('gpt-x', {}), /Unbekanntes Modell/); near(costOf('claude-opus-5-5', {}), 0) + assert.deepEqual(priceOf('claude-haiku-5-5'), { in: 0.1, out: 0.5 }); near(estimateUsd('claude-sonnet-5-5', 3000, 1000), 1000 * 2 / 1e6 + 1000 * 10 / 1e6) + }) + ``` +- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/budgetCost.test.js` → FAIL `ERR_MODULE_NOT_FOUND` (`src/budget.js`). +- [ ] **Step 3: Implementieren.** Preistabelle als Konstante (Opus 4/20, Sonnet 2/10, Haiku 0,1/0,5; Cache-Lesefaktor 0,05 bzw. 0,1 für Haiku). Haiku-Langprompt: Prompt-Tokens = `input + cache_read + cache_creation (5m+1h bzw. cache_creation_input_tokens)`; > 100 000 ⇒ Eingabe-/Ausgabepreis 0,5/2,5 (Cache-Faktoren wirken auf diesen Eingabepreis). Batch: alle Token-Anteile ×0,5, Suchanteil nicht. Ergebnis auf 1e-9 genau (nicht runden). +- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (6 Tests). +- [ ] **Step 5: Commit.** `git add src/budget.js test/budgetCost.test.js && git commit -m "P1-T10: Kostenformel costOf" -m "Co-Authored-By: Claude Opus 5.5 "` + +--- + +### P1-T11: Budget-Wächter `checkBudget`, `book`, `markStopped` + +**Files:** +- Modify: `src/budget.js` +- Test: `test/budget.test.js` + +**Interfaces:** +- Consumes: `getSettings` (T07), `getDaily`/`updateDaily`/`monthDocs` (T09), `berlinNow` (T08), `emit` (T06), optional `deps.notify`. +- Produces: `checkBudget(deps, estUsd, { reply = false }) → { ok, reason:'tag'|'monat'|'pause'|null, warn }`; `book(deps, { workerId, usd, counters = {} }) → { costUsd, warned, stopped }`; `markStopped(deps, reason)`; `formatUsd(n) → '8,12 $'` (E5). + +- [ ] **Step 1: Test schreiben.** (`near` aus `test/support/helpers.js`; Standard-`makeDeps` = Montag 11:00 Berlin.) + ```js + test('frisch: ok, kein warn', async () => assert.deepEqual(await checkBudget(makeDeps(), 1), { ok: true, reason: null, warn: false })) + test('Tagesgrenze: 8,5 $ → warn; Schätzung 1,6 sprengt den Tag; reply:true darf trotzdem', async () => { + const deps = makeDeps(); await book(deps, { workerId: 'rita', usd: 8.5 }) + assert.deepEqual(await checkBudget(deps, 1), { ok: true, reason: null, warn: true }) + assert.deepEqual(await checkBudget(deps, 1.6), { ok: false, reason: 'tag', warn: true }); assert.equal((await checkBudget(deps, 1.6, { reply: true })).ok, true) + }) + test('Pause gewinnt immer, auch bei reply', async () => { + const deps = makeDeps(); await updateSettings(deps, { paused: true }, 'u') + assert.equal((await checkBudget(deps, 0)).reason, 'pause'); assert.equal((await checkBudget(deps, 0, { reply: true })).reason, 'pause') + }) + test('Monatsgrenze 250 $ gilt auch für reply', async () => { + const deps = makeDeps(); deps.db.seed('workersDaily', { costUsd: 245 }, '2026-10-05') + assert.equal((await checkBudget(deps, 6)).reason, 'monat'); assert.equal((await checkBudget(deps, 6, { reply: true })).reason, 'monat'); assert.equal((await checkBudget(deps, 4)).ok, true) + }) + test('80 %: genau eine budget_warnung (otto) und eine Mail, auch bei einer zweiten Buchung darüber', async () => { + const mails = []; const deps = makeDeps({ notify: async (m) => { mails.push(m); return { sent: true } } }) + await book(deps, { workerId: 'rita', usd: 8.1 }); await book(deps, { workerId: 'rita', usd: 0.5 }) + const ev = deps.db.all('workersEvents').filter((e) => e.kind === 'budget_warnung') + assert.equal(ev.length, 1); assert.equal(ev[0].workerId, 'otto'); assert.equal(mails.length, 1); assert.match(mails[0].subject, /Budget/) + }) + test('100 %: ein budget_stopp ohne zusätzliche Warnung; danach tag-gesperrt, reply darf; keine Wiederholung', async () => { + const deps = makeDeps(); const r = await book(deps, { workerId: 'rita', usd: 10.2 }) + assert.deepEqual(deps.db.all('workersEvents').map((e) => e.kind), ['budget_stopp']); assert.deepEqual([r.warned, r.stopped], [true, true]) + assert.deepEqual(await checkBudget(deps, 0.01), { ok: false, reason: 'tag', warn: true }); assert.equal((await checkBudget(deps, 0.01, { reply: true })).ok, true) + await book(deps, { workerId: 'rita', usd: 0.5 }); assert.equal(deps.db.all('workersEvents').length, 1) + }) + test('Console-Stopp: markStopped sperrt auch Antworten, Ereignis nur einmal', async () => { + const deps = makeDeps(); await markStopped(deps, 'console'); await markStopped(deps, 'console') + assert.equal(deps.db.all('workersEvents').filter((e) => e.kind === 'budget_stopp').length, 1); assert.equal((await checkBudget(deps, 0.01, { reply: true })).ok, false) + }) + test('book: counters je Worker; Sonderschlüssel auch in den Tagesfeldern; costUsd je Worker', async () => { + const deps = makeDeps(); await book(deps, { workerId: 'rita', usd: 0.02, counters: { searches: 2, gelesen: 5, tokensIn: 1000 } }) + const d = await getDaily(deps, '2026-10-19'); assert.equal(d.searches, 2); assert.deepEqual(d.counters.rita, { searches: 2, gelesen: 5, tokensIn: 1000, costUsd: 0.02 }); near(d.costUsd, 0.02) + }) + test('vier gleichzeitige Buchungen verlieren keine Kosten; Warnung nur einmal', async () => { + const deps = makeDeps(); await Promise.all([2.1, 2.1, 2.1, 2.1].map((usd) => book(deps, { workerId: 'kim', usd }))) + const d = await getDaily(deps, '2026-10-19'); near(d.costUsd, 8.4); near(d.counters.kim.costUsd, 8.4) + assert.equal(deps.db.all('workersEvents').filter((e) => e.kind === 'budget_warnung').length, 1) + }) + ``` +- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/budget.test.js` → FAIL (`checkBudget is not a function` bzw. fehlender Export). +- [ ] **Step 3: Implementieren.** `checkBudget`: Reihenfolge Pause → (Console-Stopp: `stopped && counters.__stop === 'console'` ⇒ `tag`, auch für `reply`) → Tag (`costUsd + est > dailyBudgetUsd` oder `stopped`; entfällt bei `reply`) → Monat (Summe `monthDocs` + est > `monthlyBudgetUsd`); `warn` = Tag oder Monat ≥ `warnPct` %. `book`: ein `updateDaily` addiert `usd` (je Worker `costUsd`, 6 Nachkommastellen) und `counters` (additiv; Sonderschlüssel `mailsCold, mailsReply, searches, found, contacted, deals` zusätzlich in die Tagesfelder); danach entscheidet ein zweiter `updateDaily` anhand des **dann aktuellen** Tagesdokuments (nicht des Rückgabewerts des ersten Schritts; Monatssumme vorher per `monthDocs` gelesen), ob `warned`/`stopped` zu setzen sind – **nur wenn das Flag in der Sperre wirklich von `false` auf `true` wechselt**, folgt Ereignis (`otto`, `budget_warnung`/`budget_stopp`, deutscher Satz mit `formatUsd`, `meta:{scope:'tag'|'monat',usd,limit}`) und `deps.notify?.({ subject:'Budget …', text })` (Fehler der Mail nur loggen); Stopp setzt `warned` mit, ohne eigene Warnung. `markStopped(deps, reason)`: `stopped = true`, `counters.__stop = reason`, `budget_stopp` nur beim Wechsel. `formatUsd`: `Intl.NumberFormat('de-DE', 2 Nachkommastellen) + ' $'`. +- [ ] **Step 4: Tests laufen lassen.** Gleicher Befehl und `test/budgetCost.test.js` → PASS (9 + 6 Tests). +- [ ] **Step 5: Commit.** `git add src/budget.js test/budget.test.js && git commit -m "P1-T11: Budget-Wächter mit Warnung, Stopp und Antwort-Ausnahme" -m "Co-Authored-By: Claude Opus 5.5 "` + +--- + +### P1-T12: Interner Mailer `notify` + +**Files:** +- Create: `src/notify.js` +- Test: `test/notify.test.js` + +**Interfaces:** +- Produces: `createNotifier({ smtp, getTo, createTransport = nodemailer.createTransport, clock, log }) → { notify({ subject, text }) → Promise<{ sent:boolean, reason?:string }> }`; `smtp` = `config.reportSmtp` oder `null`; `getTo()` → Empfängeradresse (E3). Reason: `nicht_konfiguriert`, `kein_empfaenger`, `smtp_auth`, `smtp_fehler`. + +- [ ] **Step 1: Test schreiben.** + ```js + const SMTP = { host: 'mail.example', port: 587, user: 'u', pass: 'geheim-pw', from: 'server@example.de' } + function mk({ fail } = {}) { const t = { attempts: 0, sent: [], cfg: null } + t.createTransport = (cfg) => { t.cfg = cfg; return { sendMail: async (m) => { t.attempts++; if (fail) throw fail; t.sent.push(m); return { messageId: 'x' } } } }; return t } + test('sendet an getTo(): Betreff einzeilig mit [Workers]-Präfix, STARTTLS, Timeouts', async () => { + const deps = makeDeps(); const t = mk() + const n = createNotifier({ smtp: SMTP, getTo: async () => 'kenso@example.de', createTransport: t.createTransport, clock: deps.clock, log: deps.log }) + assert.deepEqual(await n.notify({ subject: 'Budget\nWarnung', text: 'T' }), { sent: true }) + assert.deepEqual([t.sent[0].to, t.sent[0].from, t.sent[0].subject], ['kenso@example.de', 'server@example.de', '[Workers] Budget Warnung']) + assert.deepEqual([t.cfg.host, t.cfg.port, t.cfg.requireTLS, t.cfg.connectionTimeout, t.cfg.auth], ['mail.example', 587, true, 20000, { user: 'u', pass: 'geheim-pw' }]) + }) + test('ohne Konfiguration oder Empfänger: sent=false, kein Transport', async () => { + const deps = makeDeps(); const t = mk(); const base = { createTransport: t.createTransport, clock: deps.clock, log: deps.log } + assert.deepEqual(await createNotifier({ smtp: null, getTo: async () => 'k@example.de', ...base }).notify({ subject: 's', text: 't' }), { sent: false, reason: 'nicht_konfiguriert' }) + assert.deepEqual(await createNotifier({ smtp: SMTP, getTo: async () => '', ...base }).notify({ subject: 's', text: 't' }), { sent: false, reason: 'kein_empfaenger' }); assert.equal(t.attempts, 0) + }) + test('Login-Fehler (EAUTH/535): genau ein Versuch pro Berliner Tag, Grund ohne Passwort', async () => { + const deps = makeDeps(); const t = mk({ fail: Object.assign(new Error('Invalid login geheim-pw'), { code: 'EAUTH', responseCode: 535 }) }); const logs = [] + const n = createNotifier({ smtp: SMTP, getTo: async () => 'k@example.de', createTransport: t.createTransport, clock: deps.clock, log: (m) => logs.push(m) }) + for (let i = 0; i < 3; i++) assert.deepEqual(await n.notify({ subject: 's', text: 't' }), { sent: false, reason: 'smtp_auth' }) + assert.equal(t.attempts, 1); assert.ok(!logs.join('\n').includes('geheim-pw')) + deps.clock.advance(24 * 3600 * 1000); await n.notify({ subject: 's', text: 't' }); assert.equal(t.attempts, 2) + }) + test('anderer Fehler: kein internes Wiederholen, reason smtp_fehler', async () => { + const deps = makeDeps(); const t = mk({ fail: Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' }) }) + const n = createNotifier({ smtp: SMTP, getTo: async () => 'k@example.de', createTransport: t.createTransport, clock: deps.clock, log: deps.log }) + assert.deepEqual(await n.notify({ subject: 's', text: 't' }), { sent: false, reason: 'smtp_fehler' }); assert.equal(t.attempts, 1) + }) + ``` +- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/notify.test.js` → FAIL `ERR_MODULE_NOT_FOUND`. +- [ ] **Step 3: Implementieren.** Transport lazy einmal erzeugen: `{ host, port, secure: port === 465, requireTLS: port !== 465, connectionTimeout: 20000, greetingTimeout: 20000, socketTimeout: 20000, auth: { user, pass } }`. Betreff: CR/LF → Leerzeichen, Präfix `[Workers] ` falls nicht vorhanden. Auth-Fehler (`code === 'EAUTH'` oder `responseCode` ∈ {530, 534, 535}) sperren weitere Versuche bis zum nächsten Berliner Tag (`berlinNow(clock).day`) — Hintergrund: wiederholte falsche SMTP-Logins sperrten am 2026-09-30 die Server-IP bei Hetzner. Logs enthalten nie Passwort oder Nachrichtentext. +- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (4 Tests). +- [ ] **Step 5: Commit.** `git add src/notify.js test/notify.test.js && git commit -m "P1-T12: interner Mailer mit Login-Sperre pro Tag" -m "Co-Authored-By: Claude Opus 5.5 "` + +--- + +### P1-T13: Claude-Hülle `ask` mit Budget-Erkennung + +**Files:** +- Create: `src/claude.js`, `test/support/fakeSdk.js` +- Test: `test/claudeAsk.test.js` + +**Interfaces:** +- Consumes: `checkBudget`, `book`, `markStopped`, `costOf`, `estimateUsd` (T10/T11), `ROLES`/`MODELS` (T05). +- Produces: `createClaude({ apiKey, sdk, searchModel = '' }) → { ask, search, batch }` (hier zunächst `ask`; `sdk` = Client mit `.messages.create`, Standard `new Anthropic({ apiKey, maxRetries: 2, timeout: 300000 })` – bewusst wenige Wiederholungen und ein langes Zeitlimit: Suchläufe mit Serverwerkzeugen dauern bis zu mehreren Minuten, und jede wiederholte, nicht gebuchte Anfrage wäre unbemerkte Ausgabe; die Console-Grenze fängt den Rest ab); `ask(deps, { workerId, model?, system, input, schema?, images?, maxTokens, effort?, reply? }) → { data?, text, usage, costUsd, model }`; `BudgetStop` (`.reason` ∈ `tag|monat|pause|console`); `ClaudeRefusal`; `isBudgetError(err) → boolean`. +- `fakeSdk(script)` → `{ calls, messages:{ create, batches } }` (`create` liefert die Einträge von `script` der Reihe nach, der letzte wiederholt sich; ein `Error` wird geworfen; `calls` enthält Kopien der Anfragen); `msg(over)` (Standard: `claude-haiku-5-5`, `end_turn`, Text `ok`, usage 1000/200); `apiError(status, body)` (Fehler mit `status` und `error = body`, Meldung ` ` wie das SDK). + +- [ ] **Step 1: Tests schreiben.** + ```js + const SCHEMA_J = { type: 'object', properties: { klasse: { type: 'string' } }, required: ['klasse'], additionalProperties: false } + test('Anfrageform: Rollenmodell, gecachter System-Block, Schema in output_config, keine Sampling-Parameter', async () => { + const sdk = fakeSdk([msg({ content: [{ type: 'text', text: '{"klasse":"ja"}' }] })]); const deps = makeDeps() + const r = await createClaude({ apiKey: 'k', sdk }).ask(deps, { workerId: 'nora', system: 'SYS', input: 'IN', schema: SCHEMA_J, maxTokens: 500, effort: 'low' }) + assert.deepEqual(r.data, { klasse: 'ja' }); const q = sdk.calls[0] + assert.deepEqual([q.model, q.max_tokens], ['claude-haiku-5-5', 500]) + assert.deepEqual(q.system, [{ type: 'text', text: 'SYS', cache_control: { type: 'ephemeral' } }]); assert.deepEqual(q.messages, [{ role: 'user', content: 'IN' }]) + assert.deepEqual(q.output_config, { effort: 'low', format: { type: 'json_schema', schema: SCHEMA_J } }) + for (const k of ['temperature', 'top_p', 'top_k']) assert.ok(!(k in q)) + }) + test('Bilder stehen vor dem Text; explizites model überschreibt das Rollenmodell', async () => { + const sdk = fakeSdk([msg()]); await createClaude({ apiKey: 'k', sdk }).ask(makeDeps(), { workerId: 'paul', model: 'claude-sonnet-5-5', system: 's', input: 'IN', images: [{ mediaType: 'image/png', data: 'AAA' }], maxTokens: 10 }) + assert.equal(sdk.calls[0].model, 'claude-sonnet-5-5'); assert.deepEqual(sdk.calls[0].messages[0].content, [{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAA' } }, { type: 'text', text: 'IN' }]) + }) + test('bucht Kosten und Tokens (Haiku 1000/200 = 0,0002 $) in Tag und Worker-Zähler', async () => { + const deps = makeDeps(); const r = await createClaude({ apiKey: 'k', sdk: fakeSdk([msg()]) }).ask(deps, { workerId: 'nora', system: 's', input: 'i', maxTokens: 100 }) + near(r.costUsd, 0.0002); const d = await getDaily(deps, '2026-10-19'); near(d.costUsd, 0.0002); assert.deepEqual(d.counters.nora, { calls: 1, tokensIn: 1000, tokensOut: 200, costUsd: 0.0002 }) + }) + test('Verweigerung, abgeschnittene Antwort, kaputtes JSON werfen – nach dem Buchen', async () => { + for (const [res, re] of [[msg({ stop_reason: 'refusal' }), ClaudeRefusal], [msg({ stop_reason: 'max_tokens' }), /abgeschnitten/], [msg({ content: [{ type: 'text', text: 'kein json' }] }), /JSON/]]) { + const deps = makeDeps(); const c = createClaude({ apiKey: 'k', sdk: fakeSdk([res]) }) + await assert.rejects(c.ask(deps, { workerId: 'nora', system: 's', input: 'i', schema: SCHEMA_J, maxTokens: 10 }), re); assert.ok((await getDaily(deps, '2026-10-19')).costUsd > 0) + } + }) + test('Vorabprüfung: Pause und Tagesgrenze → BudgetStop ohne API-Aufruf; reply:true darf am Tageslimit', async () => { + const sdk = fakeSdk([msg()]); const c = createClaude({ apiKey: 'k', sdk }); const ask = (deps, o = {}) => c.ask(deps, { workerId: 'nora', system: 's', input: 'i', maxTokens: 10, ...o }) + let deps = makeDeps(); await updateSettings(deps, { paused: true }, 't'); await assert.rejects(ask(deps), (e) => e instanceof BudgetStop && e.reason === 'pause') + deps = makeDeps(); await book(deps, { workerId: 'rita', usd: 10 }); await assert.rejects(ask(deps), (e) => e instanceof BudgetStop && e.reason === 'tag') + assert.equal(sdk.calls.length, 0); await ask(deps, { reply: true }); assert.equal(sdk.calls.length, 1) + }) + test('API-Budgetantworten: 400-Workspace-Limit und 429 enforced_spend_limit_reached → BudgetStop(console), Tag gestoppt, Ereignis einmal, kein weiterer Aufruf', async () => { + const b400 = { type: 'error', error: { type: 'invalid_request_error', message: 'You have reached your specified workspace API usage limits. You will regain access on 2026-11-01 at 00:00 UTC.' } } + const b429 = { type: 'error', error: { type: 'rate_limit_error', message: 'Monthly spend limit reached', details: { error_code: 'enforced_spend_limit_reached' } } } + for (const err of [apiError(400, b400), apiError(429, b429)]) { + const deps = makeDeps(); const sdk = fakeSdk([err]); const c = createClaude({ apiKey: 'k', sdk }) + await assert.rejects(c.ask(deps, { workerId: 'nora', system: 's', input: 'i', maxTokens: 10 }), (e) => e instanceof BudgetStop && e.reason === 'console') + await assert.rejects(c.ask(deps, { workerId: 'nora', system: 's', input: 'i', maxTokens: 10, reply: true }), BudgetStop) + assert.equal(sdk.calls.length, 1); assert.equal(deps.db.all('workersEvents').filter((e) => e.kind === 'budget_stopp').length, 1) + } + }) + test('gewöhnliche Fehler bleiben unverändert: normales 429 und fremdes 400', async () => { + const e429 = apiError(429, { type: 'error', error: { type: 'rate_limit_error', message: 'slow down' } }); const e400 = apiError(400, { type: 'error', error: { type: 'invalid_request_error', message: 'max_tokens too large' } }) + assert.equal(isBudgetError(e429), false); assert.equal(isBudgetError(e400), false) + const deps = makeDeps(); await assert.rejects(createClaude({ apiKey: 'k', sdk: fakeSdk([e429]) }).ask(deps, { workerId: 'nora', system: 's', input: 'i', maxTokens: 10 }), (e) => e === e429) + assert.equal((await getDaily(deps, '2026-10-19'))?.stopped ?? false, false) + }) + ``` +- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/claudeAsk.test.js` → FAIL `ERR_MODULE_NOT_FOUND` (`src/claude.js`). +- [ ] **Step 3: Implementieren.** `ask`: Modell = `opts.model ?? MODELS[role(workerId).model]` (unbekannter Worker → Fehler); `checkBudget(deps, estimateUsd(model, system.length + input.length, maxTokens), { reply })` nicht ok ⇒ `BudgetStop(reason)`; Anfrage genau wie im Test (`output_config` nur, wenn `effort` oder `schema` gesetzt); SDK-Fehler: `isBudgetError(err)` ⇒ `markStopped(deps, 'console')` und `BudgetStop('console')`, sonst unverändert werfen. `isBudgetError`: Text = `err.error?.error?.message ?? err.message`; Status 400 und Text enthält `You have reached your specified` und `API usage limits`, oder Status 429 und `JSON.stringify(err.error ?? {})` enthält `enforced_spend_limit_reached` (nicht am Textanfang verankern: das SDK stellt „400 {…}“ voran). Nach der Antwort **zuerst** `book(deps, { workerId, usd, counters:{ calls:1, tokensIn: input+cache_read+cache_creation, tokensOut: output } })`, dann `stop_reason` prüfen (`refusal` → `ClaudeRefusal`, `max_tokens` → `Error('Antwort abgeschnitten (max_tokens)')`), dann bei `schema` den ersten Text-Block mit `JSON.parse` (Fehler → `Error('Antwort war kein gültiges JSON')`). +- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (7 Tests). +- [ ] **Step 5: Commit.** `git add src/claude.js test/support/fakeSdk.js test/claudeAsk.test.js && git commit -m "P1-T13: Claude-Hülle ask mit Budget-Erkennung" -m "Co-Authored-By: Claude Opus 5.5 "` + +--- + +### P1-T14: Claude `search` (pause_turn), `batch`, Modell-Prüfskript + +**Files:** +- Modify: `src/claude.js` +- Create: `scripts/check-modelle.mjs` +- Test: `test/claudeSearch.test.js`, `test/checkModelle.test.js` + +**Interfaces:** +- Produces: `search(deps, { workerId, system, task, maxUses, allowedDomains?, blockedDomains?, maxTokens = 8000, reply? }) → { text, sources:[{url,title}], usage, costUsd, incomplete, toolErrors:[{tool,code,url?}] }` (Übersicht §6 + E6); `batch = { create(deps, requests), retrieve(batchId), results(batchId) → [{ customId, type, text?, usage?, costUsd? }] }`; `BOT_VERBOT_DOMAINS = ['kleinanzeigen.de','ebay-kleinanzeigen.de','facebook.com','fb.com','instagram.com','linkedin.com','xing.com','upwork.com','myhammer.de','freelancermap.de','freelance.de']` (Export aus `src/claude.js`; E11); Skript `scripts/check-modelle.mjs`: `evaluate(models) → { exit:0|1|2, searchModel:string, lines:string[] }` (`models` = Antworten von `models.retrieve` je Modell-ID) und `evaluateProbe(probe) → { exit:0|1, searchModel:string, lines:string[] }` (`probe[modellId] = { ok:boolean, reason? }`, Ergebnis je einer echten Probe-Suche; `models.retrieve` liefert den Zweig `server_tools.web_search` womöglich gar nicht, dann entscheidet nur die Probe). +- Consumes: `assertCap`/`hasCap` (T05), `ask`-Bausteine aus T13. + +- [ ] **Step 1: Tests schreiben.** + ```js + 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`: + ```js + const cap = (supported) => ({ capabilities: { server_tools: { web_search: { supported } } } }) + const all = (h, s) => ({ 'claude-haiku-5-5': h, 'claude-sonnet-5-5': s, 'claude-opus-5-5': cap(true) }) + test('Haiku kann suchen → keine Änderung', () => { const r = evaluate(all(cap(true), cap(true))); assert.deepEqual([r.exit, r.searchModel], [0, '']) }) + test('Haiku kann nicht → Fallback Sonnet mit .env-Zeile', () => { + const r = evaluate(all(cap(false), cap(true))); assert.deepEqual([r.exit, r.searchModel], [0, 'claude-sonnet-5-5']); assert.ok(r.lines.includes('WORKERS_SEARCH_MODEL=claude-sonnet-5-5')) + }) + test('Angabe fehlt → Exit 2 (echte Probe-Suche nötig); weder Haiku noch Sonnet → Exit 1', () => { + assert.equal(evaluate(all({}, cap(true))).exit, 2); assert.equal(evaluate(all(cap(false), cap(false))).exit, 1) + }) + test('Probe-Suche (--probe): Haiku ok → keine Änderung; Haiku verweigert + Sonnet ok → Fallback mit .env-Zeile; beide verweigert → Exit 1', () => { + const h = evaluateProbe({ 'claude-haiku-5-5': { ok: true } }); assert.deepEqual([h.exit, h.searchModel], [0, '']) + const f = evaluateProbe({ 'claude-haiku-5-5': { ok: false, reason: 'does not support web_search' }, 'claude-sonnet-5-5': { ok: true } }) + assert.deepEqual([f.exit, f.searchModel], [0, 'claude-sonnet-5-5']); assert.ok(f.lines.includes('WORKERS_SEARCH_MODEL=claude-sonnet-5-5')) + assert.equal(evaluateProbe({ 'claude-haiku-5-5': { ok: false }, 'claude-sonnet-5-5': { ok: false } }).exit, 1) + }) + ``` +- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/claudeSearch.test.js test/checkModelle.test.js` → FAIL (`search is not a function`, `ERR_MODULE_NOT_FOUND` für das Skript). +- [ ] **Step 3: Implementieren.** `search`: `assertCap(workerId, 'web_search')`; `allowedDomains` und `blockedDomains` zusammen ⇒ `Error('Entweder allowedDomains oder blockedDomains')`; Modell = `searchModel || MODELS[role]`; `web_fetch` nur, wenn `hasCap(workerId,'web_fetch')`, und immer mit `blocked_domains` = `BOT_VERBOT_DOMAINS` + eigene `blockedDomains` (ohne Doppelte, Reihenfolge wie im Test); bei `allowedDomains` trägt `web_fetch` stattdessen `allowed_domains` = dieselbe Liste ohne Einträge, die gleich einer `BOT_VERBOT_DOMAINS`-Domain oder deren Subdomain sind (bleibt nichts übrig ⇒ kein `web_fetch`-Werkzeug; `web_search` behält die Liste unverändert, Suchtreffer sind erlaubt); Vorabprüfung `checkBudget(deps, maxUses*0.01 + estimateUsd(...), { reply })`. Schleife: Antwort **sofort buchen** (`book`, `counters:{ searches, fetches, calls:1, tokensIn, tokensOut }`); `pause_turn` ⇒ `messages = [...messages, { role:'assistant', content }]` und erneut, höchstens 5 Fortsetzungen, danach `incomplete = true`; Kosten pro Antwort mit `costOf` und summiert. `text` = alle Text-Blöcke aller Antworten; `sources` aus `web_search_tool_result`-Einträgen (`web_search_result`), doppelte URLs entfernt; `toolErrors` aus Blöcken `web_search_tool_result`/`web_fetch_tool_result`, deren `content.type` auf `_tool_result_error` endet (`tool` = Blocktyp ohne `_tool_result`, `code` = `content.error_code`, `url` aus dem `server_tool_use`-Block mit gleicher `tool_use_id`); SDK-Fehler wie in `ask` (gemeinsamer Helfer). `batch.create`: Budget-Vorabprüfung mit halbierter Schätzung, dann `sdk.messages.batches.create({ requests })`; `results`: `for await` über `sdk.messages.batches.results(id)`, Kosten `costOf(message.model, message.usage, { batch:true })`, gebucht wird vom Aufrufer. `scripts/check-modelle.mjs`: `evaluate(models)` rein (Logik: Haiku `supported === true` ⇒ Exit 0; Angabe bei Haiku fehlt ⇒ Exit 2 „unklar – mit `--probe` prüfen“; Haiku `false` und Sonnet `true` ⇒ Exit 0 mit Zeile `WORKERS_SEARCH_MODEL=claude-sonnet-5-5` und `searchModel` gesetzt; sonst Exit 1); `evaluateProbe(probe)` rein (Haiku `ok` ⇒ Exit 0, `searchModel ''`; Haiku nicht ok und Sonnet ok ⇒ Exit 0 mit derselben `.env`-Zeile; sonst Exit 1); `main` ruft `new Anthropic({ apiKey }).models.retrieve(id)` für die drei Modelle (Schlüssel aus `ANTHROPIC_API_KEY`, nie ausgeben) und druckt nur die Zeilen. Mit `--probe` (nur bei Exit 2 nötig, kostet etwa 0,02 $, wird nicht gebucht) folgt je Modell – erst Haiku, nur bei Fehlschlag Sonnet – ein echter Aufruf `messages.create` mit `max_tokens: 300`, Eingabe `Suche kurz: Öffnungszeiten Rathaus Kaiserslautern.` und `tools: [{ type: 'web_search_20260318', name: 'web_search', max_uses: 1, allowed_callers: ['direct'] }]`; `ok` = Antwort ohne Fehler und `usage.server_tool_use.web_search_requests ≥ 1`, bei einem API-Fehler `ok:false` mit auf 120 Zeichen gekürzter Meldung (`reason`), das Ergebnis geht an `evaluateProbe`. +- [ ] **Step 4: Tests laufen lassen.** Gleicher Befehl → PASS (8 + 4 Tests). +- [ ] **Step 5: Commit.** `git add src/claude.js scripts/check-modelle.mjs test/claudeSearch.test.js test/checkModelle.test.js && git commit -m "P1-T14: Claude search mit pause_turn, batch, Modell-Prüfskript" -m "Co-Authored-By: Claude Opus 5.5 "` + +--- + +### P1-T15: Auftragswarteschlange `jobs.js` + +**Files:** +- Create: `src/jobs.js` +- Test: `test/jobs.test.js` + +**Interfaces:** +- Produces: `enqueue(deps, { kind, workerId, prospectId?, threadId?, previewId?, payload }) → Job`; `next(deps, { kinds }) → Job|null` (ältester `offen`-Job der Arten, setzt `laeuft`, `startedAt`, `attempts+1`); `finish(deps, jobId, result)`; `fail(deps, jobId, error, { retry = true })`; `reclaimStale(deps, { olderThanMs }) → Anzahl`; `JOB_KINDS` (aus `SCHEMA`); `Job = { id, kind, workerId, status, prospectId, threadId, previewId, attempts, payload, createdAt }` (Übersicht §6 + E8). + +- [ ] **Step 1: Test schreiben.** + ```js + test('enqueue: offener Job, payload als JSON; ungültige Art/Worker/zu großes payload werfen', async () => { + const deps = makeDeps(); const j = await enqueue(deps, { kind: 'suche', workerId: 'ali', payload: { room: 'aerzte' } }) + assert.deepEqual([j.status, j.attempts, j.payload], ['offen', 0, { room: 'aerzte' }]); assert.equal(deps.db.all('workersJobs')[0].payloadJson, '{"room":"aerzte"}') + await assert.rejects(enqueue(deps, { kind: 'quatsch', workerId: 'ali', payload: {} }), /Auftragsart/) + await assert.rejects(enqueue(deps, { kind: 'suche', workerId: 'niemand', payload: {} }), /Worker/) + await assert.rejects(enqueue(deps, { kind: 'suche', workerId: 'ali', payload: { x: 'y'.repeat(16000) } }), /zu groß/) + }) + test('next: ältester offener Job der gewünschten Arten; fremde Arten bleiben offen', async () => { + const deps = makeDeps(); await enqueue(deps, { kind: 'bau', workerId: 'bob', payload: {} }); deps.clock.advance(1000) + const a = await enqueue(deps, { kind: 'suche', workerId: 'ali', payload: { n: 1 } }); deps.clock.advance(1000); await enqueue(deps, { kind: 'suche', workerId: 'hanna', payload: { n: 2 } }) + const got = await next(deps, { kinds: ['suche'] }); assert.equal(got.id, a.id); assert.deepEqual([got.status, got.attempts], ['laeuft', 1]) + assert.equal((await next(deps, { kinds: ['suche'] })).payload.n, 2); assert.equal(await next(deps, { kinds: ['suche'] }), null) + assert.equal(deps.db.all('workersJobs').find((d) => d.kind === 'bau').status, 'offen') + }) + test('finish speichert resultJson; fail: Wiederholung bis 3 Versuche, dann fehler; retry:false sofort', async () => { + const deps = makeDeps(); const a = await enqueue(deps, { kind: 'suche', workerId: 'ali', payload: {} }) + await next(deps, { kinds: ['suche'] }); await finish(deps, a.id, { found: 3 }); let d = deps.db.all('workersJobs')[0]; assert.deepEqual([d.status, d.resultJson], ['fertig', '{"found":3}']); assert.ok(d.finishedAt) + const b = await enqueue(deps, { kind: 'suche', workerId: 'ali', payload: {} }) + for (let i = 1; i <= 3; i++) { await next(deps, { kinds: ['suche'] }); await fail(deps, b.id, 'x'.repeat(3000)); d = deps.db.all('workersJobs')[1]; assert.equal(d.status, i < 3 ? 'offen' : 'fehler') } + assert.equal(d.error.length, 2000) + const c = await enqueue(deps, { kind: 'suche', workerId: 'ali', payload: {} }); await next(deps, { kinds: ['suche'] }); await fail(deps, c.id, 'weg', { retry: false }); assert.equal(deps.db.all('workersJobs')[2].status, 'fehler') + }) + test('reclaimStale: hängender laeuft-Job wird wieder offen, jüngerer und warten_batch nicht', async () => { + const deps = makeDeps(); const old = await enqueue(deps, { kind: 'suche', workerId: 'ali', payload: {} }); await next(deps, { kinds: ['suche'] }) + deps.clock.advance(40 * 60000); const young = await enqueue(deps, { kind: 'suche', workerId: 'hanna', payload: {} }); await next(deps, { kinds: ['suche'] }) + deps.db.seed('workersJobs', { kind: 'bau', workerId: 'bob', status: 'warten_batch', startedAt: '2026-10-19T00:00:00.000Z' }) + assert.equal(await reclaimStale(deps, { olderThanMs: 30 * 60000 }), 1) + const st = (id) => deps.db.all('workersJobs').find((d) => d.$id === id).status; assert.deepEqual([st(old.id), st(young.id)], ['offen', 'laeuft']); assert.equal(deps.db.all('workersJobs').find((d) => d.kind === 'bau').status, 'warten_batch') + }) + ``` +- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/jobs.test.js` → FAIL `ERR_MODULE_NOT_FOUND`. +- [ ] **Step 3: Implementieren.** `next`: `list('workersJobs', [Query.equal('status',['offen']), Query.equal('kind', kinds), Query.orderAsc('$createdAt'), Query.limit(1)])` (nutzt Index `idx_kind_status`); Anspruch per `update` (ein Prozess, kein verteiltes Locking). `payloadJson`/`resultJson` > 16000 Zeichen ⇒ `Error('payload zu groß')`. `fail`: `attempts < 3 && retry` ⇒ `offen` (Fehlertext gespeichert), sonst `fehler` mit `finishedAt`. `reclaimStale`: `laeuft`-Jobs mit `startedAt` älter als `olderThanMs` ⇒ `offen`, `error='neu gestartet nach Hänger'`. +- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (4 Tests). +- [ ] **Step 5: Commit.** `git add src/jobs.js test/jobs.test.js && git commit -m "P1-T15: Auftragswarteschlange" -m "Co-Authored-By: Claude Opus 5.5 "` + +--- + +### P1-T16: Normalisierung – Port aus WOMS mit „(0)“-Fix + +**Files:** +- Create: `src/register/normalize.js` +- Test: `test/normalize.test.js` + +**Interfaces:** +- Produces: `normalizeName(name)`, `normalizePhone(phone)`, `normalizeEmail(email)`, `companyDomain(email)`, `isFreemail(domain)` – Kopie von `/home/webklar/apps/ticket.webklar.com/dev-workers/src/lib/leadDuplicates.js` (nur diese Funktionen und `FREEMAIL`/`LEGAL_WORDS`, ohne `findDuplicates` und Absage-Funktionen), mit Fix in `normalizePhone` (Übersicht §6). + +- [ ] **Step 1: Test schreiben** (die Fälle aus WOMS `tests/unit/leadDuplicates.test.mjs` plus die neuen): + ```js + test('normalizeName wie WOMS', () => { + assert.equal(normalizeName('Bäckerei Müller GmbH & Co. KG'), 'baeckerei mueller'); assert.equal(normalizeName('Café Rosé'), 'cafe rose') + assert.equal(normalizeName('Friseur Amin e.K.'), 'friseur amin'); assert.equal(normalizeName('Straße-Bau UG (haftungsbeschränkt)'), 'strasse bau') + assert.equal(normalizeName(''), ''); assert.equal(normalizeName(undefined), '') + }) + test('companyDomain/normalizeEmail/isFreemail wie WOMS', () => { + assert.equal(companyDomain('Info@Baecker-Mueller.de'), 'baecker-mueller.de'); assert.equal(companyDomain('x@gmail.com'), ''); assert.equal(companyDomain('x@web.de'), ''); assert.equal(companyDomain('kaputt'), '') + assert.equal(normalizeEmail(' A@B.DE '), 'a@b.de'); assert.equal(isFreemail('gmx.de'), true); assert.equal(isFreemail('mueller.de'), false) + }) + test('normalizePhone: alle Schreibweisen derselben Nummer ergeben dieselbe Zahl (inkl. "(0)"-Fix)', () => { + for (const p of ['0631 123456', '0631/123456', '+49 631 123456', '0049 631 123456', '+49 (0) 631 123456', '+49(0)631/123456', '0049 (0)631 123456', '+49 0631 123456', '0631-123456']) + assert.equal(normalizePhone(p), '0631123456', p) + assert.equal(normalizePhone('12 34'), ''); assert.equal(normalizePhone(null), ''); assert.equal(normalizePhone('+43 1 234567'), '431234567') // Ausland bleibt unverändert (ohne Plus) + }) + ``` +- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/normalize.test.js` → FAIL `ERR_MODULE_NOT_FOUND`. +- [ ] **Step 3: Implementieren.** Funktionskörper 1:1 aus `leadDuplicates.js` übernehmen (Kommentar mit Quelle und Stand `d288301`), `FREEMAIL` zusätzlich über `isFreemail(domain)` exportieren. Fix in `normalizePhone`: vor dem Ziffern-Ziehen `(0)` (`/\(\s*0\s*\)/g`) entfernen; nach erkannter deutscher Landesvorwahl (`0049…` oder `+49…`) den Rest nehmen, **eine** führende `0` davon entfernen und `0` voranstellen (damit `+49 0631…` nicht `00631…` wird); sonst Verhalten unverändert (unter 6 Ziffern ⇒ `''`). +- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (3 Tests). +- [ ] **Step 5: Commit.** `git add src/register/normalize.js test/normalize.test.js && git commit -m "P1-T16: Normalisierung aus WOMS mit (0)-Fix" -m "Co-Authored-By: Claude Opus 5.5 "` + +--- + +### P1-T17: Domain- und Schlüssel-Normalisierung + +**Files:** +- Modify: `src/register/normalize.js` +- Test: `test/normalizeDomain.test.js` + +**Interfaces:** +- Produces: `normalizeDomain(input) → string` (eTLD+1 ohne `www.`, `''` für Müll/Verzeichnisse/Soziale/IPs; Shared-Hosting behält den vollen Host); `normalizePhoneE164(input) → string`; `emailKey(input) → string`; `placeIdKey(input) → string`; `namePlzKey(name, plz) → string` (Übersicht §6; `''` = kein Schlüssel). + +- [ ] **Step 1: Test schreiben.** + ```js + 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'], ['kaputt', ''], ['a@b', ''], ['a@b@c.de', ''], [undefined, '']]) assert.equal(emailKey(i), o, String(i)) + }) + test('placeIdKey', () => { + const id = 'ChIJN1t_tDeuEmsRUsoyG83frY4'; assert.equal(placeIdKey(id), id) + assert.equal(placeIdKey(`https://www.google.com/maps/place/?q=place_id:${id}&x=1`), id); assert.equal(placeIdKey('https://www.google.com/maps/search/?api=1&query=Lackwerk'), ''); assert.equal(placeIdKey('abc'), '') + }) + test('namePlzKey: nur mit Namen (mind. ein Wort) und fünfstelliger PLZ', () => { + assert.equal(namePlzKey('Bäckerei Müller GmbH', '67655'), 'baeckerei mueller|67655'); assert.equal(namePlzKey('Bäckerei Müller', '67655 Kaiserslautern'), 'baeckerei mueller|67655') + assert.equal(namePlzKey('Bäckerei Müller', ''), ''); assert.equal(namePlzKey('& Co. GmbH', '67655'), ''); assert.equal(namePlzKey('Bäckerei Müller', 'Kaiserslautern'), '') + }) + ``` +- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/normalizeDomain.test.js` → FAIL `does not provide an export named 'normalizeDomain'`. +- [ ] **Step 3: Implementieren.** `normalizeDomain`: Markdown `[x](url)` → `url`; Text mit Leerzeichen ohne Schema ⇒ `''`; ohne Schema `http://` voranstellen; `new URL()` (Fehler ⇒ `''`, Punycode kommt gratis); Host klein, `www.` und Schlusspunkt ab, IPv4/IPv6 und Hosts ohne Punkt ⇒ `''`; Verzeichnis-/Soziale-Liste `DIRECTORY` (eTLD+1-Domains `facebook.com, fb.com, instagram.com, g.page, gelbeseiten.de, dasoertliche.de, 11880.com, cylex.de, golocal.de, meinestadt.de, linkedin.com, xing.com, youtube.com, tiktok.com, twitter.com, x.com` plus Labelregel `google.*`) ⇒ `''`; Shared-Hosting-Suffixe `SHARED_HOSTS` (`jimdofree.com, jimdosite.com, wixsite.com, business.site, myshopify.com, weebly.com, wordpress.com, blogspot.com, webnode.page, godaddysites.com, site123.me, bplaced.net, npage.de`): Host mit Label davor bleibt voll, der nackte Suffix ⇒ `''`; sonst eTLD+1 mit kleiner Mehrlabel-Suffixliste (`co.uk, org.uk, ac.uk, gov.uk, com.au, co.at, or.at, co.nz, com.br, co.jp, com.tr`). `normalizePhoneE164`: leer, wenn `normalizePhone` leer; `+` am Anfang und nicht `+49` ⇒ `+` + Ziffern; `00…` (nicht `0049`) ⇒ `+` + Ziffern ohne `00`; sonst `+49` + Nummer ohne führende 0. `emailKey`: `mailto:` und `<…>` auflösen, trimmen, klein, genau ein `@`, Domain mit Punkt. `placeIdKey`: Regex `/^ChIJ[\w-]{10,}$/` auf den Wert oder auf `place_id[:=](ChIJ[^&\s]+)`. `namePlzKey`: `normalizeName(name)` nicht leer und erste `\b\d{5}\b` aus `plz`, Ergebnis `|`. +- [ ] **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 "` + +--- + +### P1-T18: Register – `keysFor`, `keyId`, `claimKeys` + +**Files:** +- Create: `src/register/keys.js` +- Test: `test/keys.test.js` + +**Interfaces:** +- Produces: `keysFor(p) → [{ kind, value }]` (`p` = `{ website, email, phone, placeId, adId, name, postalCode }`, die ersten drei und `name` auch als Arrays; Reihenfolge `dom` (Website), `dom` (Mail-Domain ohne Freemail), `mail`, `tel`, `pid`, `ad`, `namplz`; leere und doppelte entfallen); `keyId(kind, value) → 32 Hex-Zeichen`; `claimKeys(deps, keys, { prospectId = '', origin, blocked = false }) → { fresh, hits:[{kind,prospectId,blocked,origin}], hints:[…] }` (Übersicht §6 + E7); `prospectIdFor(keys) → string` (E10: `'p'` + erste 30 Zeichen von `keyId` des ersten Schlüssels mit `kind` ≠ `namplz`; ohne harten Schlüssel `''`). + +- [ ] **Step 1: Test schreiben.** + ```js + const P1 = { website: 'https://www.mueller-backt.de/', email: 'Info@mueller-backt.de', phone: '0631 123456', placeId: 'ChIJN1t_tDeuEmsRUsoyG83frY4', name: 'Bäckerei Müller GmbH', postalCode: '67655', adId: 'kleinanzeigen:2987654321' } + test('keyId: sha256(kind:value), 32 Hex-Zeichen', () => assert.equal(keyId('dom', 'example.de'), createHash('sha256').update('dom:example.de').digest('hex').slice(0, 32))) + test('prospectIdFor: deterministisch aus dem ersten harten Schlüssel (31 Zeichen); ohne harten Schlüssel leer', () => { + const id = prospectIdFor(keysFor(P1)); assert.equal(id, `p${keyId('dom', 'mueller-backt.de').slice(0, 30)}`); assert.equal(id.length, 31); assert.equal(prospectIdFor(keysFor(P1)), id) + assert.equal(prospectIdFor(keysFor({ name: 'Bäckerei Müller', postalCode: '67655' })), ''); assert.equal(prospectIdFor([]), '') + }) + test('keysFor: alle Arten, ohne Leere und Doppelte; Freemail liefert keine Domain; Arrays erlaubt', () => { + assert.deepEqual(keysFor(P1), [{ kind: 'dom', value: 'mueller-backt.de' }, { kind: 'mail', value: 'info@mueller-backt.de' }, { kind: 'tel', value: '+49631123456' }, { kind: 'pid', value: 'ChIJN1t_tDeuEmsRUsoyG83frY4' }, { kind: 'ad', value: 'kleinanzeigen:2987654321' }, { kind: 'namplz', value: 'baeckerei mueller|67655' }]) + assert.deepEqual(keysFor({ email: 'x@gmail.com' }), [{ kind: 'mail', value: 'x@gmail.com' }]); assert.deepEqual(keysFor({}), []) + assert.deepEqual(keysFor({ website: ['a.de', 'https://b.de'], phone: ['0631 1', '0631 123456'] }).map((k) => k.value), ['a.de', 'b.de', '+49631123456']) + }) + test('claimKeys: erster gewinnt; Rest des abgelehnten Kandidaten wird trotzdem eingetragen; Dokument ohne Klartext', async () => { + const deps = makeDeps(); const a = await claimKeys(deps, keysFor(P1), { prospectId: 'pA', origin: 'workers' }) + assert.deepEqual([a.fresh, a.hits, a.hints], [true, [], []]) + assert.deepEqual(Object.keys(deps.db.all('workersKeys')[0]).filter((k) => !k.startsWith('$')).sort(), ['blocked', 'createdAt', 'hint', 'kind', 'origin', 'prospectId']) + const b = await claimKeys(deps, [{ kind: 'mail', value: 'info@mueller-backt.de' }, { kind: 'tel', value: '+4963199999' }], { prospectId: 'pB', origin: 'workers' }) + assert.equal(b.fresh, false); assert.deepEqual(b.hits, [{ kind: 'mail', prospectId: 'pA', blocked: false, origin: 'workers' }]); assert.ok(await deps.db.get('workersKeys', keyId('tel', '+4963199999'))) + }) + test('Wiederholung nach Absturz: gleiche prospectId ist wieder fresh, fremde prospectId bleibt Treffer', async () => { + const deps = makeDeps(); await claimKeys(deps, keysFor(P1), { prospectId: 'pA', origin: 'workers' }) + const again = await claimKeys(deps, keysFor(P1), { prospectId: 'pA', origin: 'workers' }); assert.deepEqual([again.fresh, again.hits], [true, []]) + assert.equal((await claimKeys(deps, keysFor(P1), { prospectId: 'pZ', origin: 'workers' })).fresh, false) + }) + test('namplz ist nur Hinweis: fresh bleibt true, hints gefüllt (auch mit blocked)', async () => { + const deps = makeDeps(); await claimKeys(deps, [{ kind: 'namplz', value: 'baeckerei mueller|67655' }], { origin: 'seed_kunde', blocked: true }) + const c = await claimKeys(deps, [{ kind: 'namplz', value: 'baeckerei mueller|67655' }], { prospectId: 'pC', origin: 'workers' }) + assert.deepEqual([c.fresh, c.hits, c.hints.length, c.hints[0].blocked], [true, [], 1, true]) + }) + test('blocked:true hebt bestehende Schlüssel auf gesperrt (Besitzer und Origin bleiben)', async () => { + const deps = makeDeps(); await claimKeys(deps, [{ kind: 'mail', value: 'a@b.de' }], { prospectId: 'pA', origin: 'workers' }) + const r = await claimKeys(deps, [{ kind: 'mail', value: 'a@b.de' }], { origin: 'seed_kunde', blocked: true }) + const d = await deps.db.get('workersKeys', keyId('mail', 'a@b.de')); assert.deepEqual([d.blocked, d.origin, d.prospectId], [true, 'workers', 'pA']); assert.deepEqual(r.hits, [{ kind: 'mail', prospectId: 'pA', blocked: true, origin: 'workers' }]) + }) + test('Startbestand (prospectId leer) zweimal: zweiter Lauf = nur Treffer; leere Werte entfallen; ungültige origin wirft', async () => { + const deps = makeDeps(); const ks = [{ kind: 'mail', value: 'a@firma.de' }, { kind: 'tel', value: '' }] + assert.equal((await claimKeys(deps, ks, { origin: 'seed_alt' })).fresh, true); assert.equal(deps.db.all('workersKeys').length, 1) + const second = await claimKeys(deps, ks, { origin: 'seed_alt' }); assert.equal(second.fresh, false); assert.equal(second.hits.length, 1); assert.equal(deps.db.all('workersKeys').length, 1) + await assert.rejects(claimKeys(deps, ks, { origin: 'irgendwer' }), /origin/) + }) + ``` +- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/keys.test.js` → FAIL `ERR_MODULE_NOT_FOUND` (`src/register/keys.js`). +- [ ] **Step 3: Implementieren.** `claimKeys` je Schlüssel nacheinander (nie abbrechen): `create('workersKeys', { kind, prospectId?, origin, blocked, hint: kind === 'namplz', createdAt }, keyId(kind,value))`; bei `ConflictError` bestehendes Dokument lesen; ist `blocked` angefragt und bestehend nicht gesperrt ⇒ `update({ blocked:true })` (und im Ergebnis `blocked:true`); gehört der Schlüssel derselben nicht-leeren `prospectId` ⇒ kein Treffer; sonst `hits.push(…)` (`namplz` ⇒ `hints`). `fresh = hits.length === 0`. `origin` ∉ {workers, seed_alt, seed_kunde} ⇒ Fehler. Leere Werte überspringen; `prospectId` leer ⇒ Feld weglassen. `prospectIdFor` wie in den Interfaces. +- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (8 Tests). +- [ ] **Step 5: Commit.** `git add src/register/keys.js test/keys.test.js && git commit -m "P1-T18: Nie-doppelt-Register mit claimKeys" -m "Co-Authored-By: Claude Opus 5.5 "` + +--- + +### P1-T19: Sperrliste `suppress` / `isSuppressed` + +**Files:** +- Modify: `src/register/keys.js` +- Test: `test/suppression.test.js` + +**Interfaces:** +- Produces: `suppress(deps, { email?, domain?, phone? }, reason, source) → { created: ['mail','dom','tel'…] }` (idempotent; `reason` ∈ abmeldung, widerspruch, nein, beschwerde, bounce, kunde, manuell); `isSuppressed(deps, { email?, domain?, phone? }) → { suppressed, reason }` (Übersicht §6 + E7). Dokument `workersSuppression` mit `$id = keyId(kind, wert)` (`mail` = `emailKey`, `dom` = `normalizeDomain`, `tel` = `normalizePhoneE164`). + +- [ ] **Step 1: Test schreiben.** + ```js + test('Freemail-Widerspruch sperrt nur die Adresse, nie gmail.com', async () => { + const deps = makeDeps(); await suppress(deps, { email: 'Max@Gmail.com' }, 'widerspruch', 'nora') + assert.deepEqual(deps.db.all('workersSuppression').map((d) => d.kind), ['mail']) + assert.equal((await isSuppressed(deps, { email: 'max@gmail.com' })).suppressed, true) + assert.equal((await isSuppressed(deps, { email: 'andere@gmail.com' })).suppressed, false); assert.equal((await isSuppressed(deps, { domain: 'gmail.com' })).suppressed, false) + }) + test('Firmen-Adresse sperrt Adresse und Domain; Telefon in jeder Schreibweise', async () => { + const deps = makeDeps(); const r = await suppress(deps, { email: 'info@mueller.de', phone: '0631 123456' }, 'abmeldung', 'mensch:u1') + assert.deepEqual(r.created.sort(), ['dom', 'mail', 'tel']); assert.ok(await deps.db.get('workersSuppression', keyId('dom', 'mueller.de'))) + assert.deepEqual(await isSuppressed(deps, { email: 'chef@mueller.de' }), { suppressed: true, reason: 'abmeldung' }) + assert.equal((await isSuppressed(deps, { phone: '+49 (0) 631 123456' })).suppressed, true); assert.equal((await isSuppressed(deps, { domain: 'https://www.mueller.de/kontakt' })).suppressed, true) + }) + test('idempotent: zweiter Aufruf legt nichts an, erster Grund bleibt', async () => { + const deps = makeDeps(); await suppress(deps, { email: 'a@firma.de' }, 'nein', 'nora'); const r = await suppress(deps, { email: 'a@firma.de' }, 'beschwerde', 'nora') + assert.deepEqual(r.created, []); assert.equal((await isSuppressed(deps, { email: 'a@firma.de' })).reason, 'nein') + }) + test('Register-Sperren zählen mit: Kunde und Alt-Lead "kontaktiert" gesperrt, normaler Alt-Lead nicht', async () => { + const deps = makeDeps() + await claimKeys(deps, [{ kind: 'mail', value: 'kunde@firma.de' }], { origin: 'seed_kunde', blocked: true }); await claimKeys(deps, [{ kind: 'tel', value: '+49631111111' }], { origin: 'seed_alt', blocked: true }) + await claimKeys(deps, [{ kind: 'mail', value: 'neu@lead.de' }], { origin: 'seed_alt' }) + assert.deepEqual(await isSuppressed(deps, { email: 'kunde@firma.de' }), { suppressed: true, reason: 'kunde' }); assert.deepEqual(await isSuppressed(deps, { phone: '0631 111111' }), { suppressed: true, reason: 'manuell' }) + assert.equal((await isSuppressed(deps, { email: 'neu@lead.de' })).suppressed, false) + }) + test('ungültiger Grund wirft; leere Eingabe → nicht gesperrt', async () => { await assert.rejects(suppress(makeDeps(), { email: 'a@b.de' }, 'egal', 'x'), /Grund/); assert.deepEqual(await isSuppressed(makeDeps(), {}), { suppressed: false, reason: null }) }) + ``` +- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/suppression.test.js` → FAIL (`suppress is not a function`). +- [ ] **Step 3: Implementieren.** Schlüsselableitung: `mail` = `emailKey(email)`; `dom` = `normalizeDomain(domain)` und zusätzlich `normalizeDomain(companyDomain(email))` (Freemail ⇒ nichts); `tel` = `normalizePhoneE164(phone)`. `suppress`: je Schlüssel `create('workersSuppression', { kind, reason, createdAt, source }, keyId(kind, wert))`, `ConflictError` ignorieren. `isSuppressed`: erst `workersSuppression` (Reihenfolge mail, dom, tel), dann `workersKeys` mit `blocked === true` für dieselben Schlüssel (Reason: Origin `seed_kunde` ⇒ `kunde`, sonst `manuell`). +- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl und `test/keys.test.js` → PASS (5 + 8 Tests). +- [ ] **Step 5: Commit.** `git add src/register/keys.js test/suppression.test.js && git commit -m "P1-T19: Sperrliste mit Freemail-Schutz" -m "Co-Authored-By: Claude Opus 5.5 "` + +--- + +### P1-T20: Otto – Nachtplan + +**Files:** +- Create: `src/otto/nachtplan.js` +- Test: `test/nachtplan.test.js` + +**Interfaces:** +- Consumes: `getSettings`/`updateSettings` (T07), `checkBudget` (T11), `enqueue` (T15), `emit` (T06), `assertCap`/`workerForRoom`/`roomLabel`/`ROLES` (T05), `nightKey`/`berlinNow` (T08), `Query` (T04). +- Produces: `planNight(deps, { run = 1 }) → { planned:[{ room, workerId, region, jobId }], skipped:null|'pause'|'budget'|'keine_raeume' }`. Job: `kind 'suche'`, `payload { night, run, room, region, candidates, entrance }`; Ereignis `auftrag` (`workerId 'otto'`, `to`, `jobId`, Text `Otto schickt los: .` für Anfragen-Räume, `Otto schickt los: in ( Kandidaten).` für Branchen-Räume). + +- [ ] **Step 1: Tests schreiben.** + ```js + const logAt = (deps, room, region, at) => deps.db.seed('workersSearchLog', { workerId: workerForRoom(room), room, region, query: 'q', at, found: 10, fresh: 8 }) + const pick = (r) => r.planned.map((p) => [p.workerId, p.room, p.region]) + test('Lauf 1: Rita und Finn jede Nacht + ein Branchen-Raum nach Suchprotokoll; Lauf 2 nimmt die nächste Kombination', async () => { + const deps = makeDeps({ now: '2026-10-19T20:00:00.000Z' }) // Montag 22:00 Berlin + await updateSettings(deps, { connectedRooms: ['netz', 'foren', 'aerzte', 'handwerk'], regions: ['Rheinland-Pfalz', 'Saarland'], runsPerNight: 2 }, 't') + logAt(deps, 'aerzte', 'Rheinland-Pfalz', '2026-10-18T22:00:00.000Z') + assert.deepEqual(pick(await planNight(deps, { run: 1 })), [['rita', 'netz', ''], ['finn', 'foren', ''], ['ali', 'aerzte', 'Saarland']]) + assert.deepEqual(pick(await planNight(deps, { run: 2 })), [['hanna', 'handwerk', 'Rheinland-Pfalz']]) + const jobs = deps.db.all('workersJobs'); assert.equal(jobs.length, 4); assert.ok(jobs.every((j) => j.kind === 'suche' && j.status === 'offen')) + assert.deepEqual(JSON.parse(jobs[2].payloadJson), { night: '2026-10-19', run: 1, room: 'aerzte', region: 'Saarland', candidates: 10, entrance: 'ohne_website' }) + assert.equal(JSON.parse(jobs[0].payloadJson).entrance, 'anfrage') + const ev = deps.db.all('workersEvents').filter((e) => e.kind === 'auftrag'); assert.equal(ev.length, 4) + assert.deepEqual([ev[2].workerId, ev[2].to, ev[2].jobId, ev[2].text], ['otto', 'ali', jobs[2].$id, 'Otto schickt Ali los: Ärzte & Praxen in Saarland (10 Kandidaten).']) + }) + test('alle Kombinationen schon gesucht: die älteste gewinnt', async () => { + const deps = makeDeps({ now: '2026-10-19T20:00:00.000Z' }); await updateSettings(deps, { connectedRooms: ['aerzte', 'handwerk'], regions: ['Rheinland-Pfalz', 'Saarland'] }, 't') + logAt(deps, 'aerzte', 'Rheinland-Pfalz', '2026-10-10T20:00:00.000Z'); logAt(deps, 'aerzte', 'Saarland', '2026-10-12T20:00:00.000Z'); logAt(deps, 'handwerk', 'Rheinland-Pfalz', '2026-10-09T20:00:00.000Z'); logAt(deps, 'handwerk', 'Saarland', '2026-10-11T20:00:00.000Z') + assert.deepEqual(pick(await planNight(deps, { run: 1 })), [['hanna', 'handwerk', 'Rheinland-Pfalz']]) + }) + test('nichts geplant: Pause (still), Budget erschöpft, kein angeschlossener Raum (ein leerlauf-Ereignis); Kim/Pia/inaktive Räume nie', async () => { + let deps = makeDeps(); await updateSettings(deps, { paused: true, connectedRooms: ['netz'] }, 't') + assert.deepEqual(await planNight(deps, { run: 1 }), { planned: [], skipped: 'pause' }); assert.equal(deps.db.all('workersJobs').length + deps.db.all('workersEvents').length, 0) + deps = makeDeps(); await updateSettings(deps, { connectedRooms: ['netz'] }, 't'); await book(deps, { workerId: 'rita', usd: 9.9 }) + assert.equal((await planNight(deps, { run: 1 })).skipped, 'budget'); assert.equal(deps.db.all('workersJobs').length, 0) + deps = makeDeps(); await updateSettings(deps, { connectedRooms: ['kleinanzeigen', 'portale'] }, 't') + assert.deepEqual(await planNight(deps, { run: 1 }), { planned: [], skipped: 'keine_raeume' }) + assert.deepEqual(deps.db.all('workersEvents').map((e) => [e.workerId, e.kind]), [['otto', 'leerlauf']]) + deps = makeDeps(); await updateSettings(deps, { activeRooms: ['aerzte'], connectedRooms: ['netz', 'aerzte'] }, 't'); assert.deepEqual(pick(await planNight(deps, { run: 1 })).map((x) => x[1]), ['aerzte']) + }) + ``` +- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/nachtplan.test.js` → FAIL `ERR_MODULE_NOT_FOUND` (`src/otto/nachtplan.js`). +- [ ] **Step 3: Implementieren.** Ablauf: `assertCap('otto','plan')`; Einstellungen lesen, `paused` ⇒ `{ planned:[], skipped:'pause' }` ohne Schreibzugriff; `checkBudget(deps, 1)` (Mindestrest 1 $) nicht ok ⇒ `skipped:'budget'`; Räume = `SEARCH_ROOMS` ∩ `activeRooms` ∩ `connectedRooms`. Anfragen: nur in `run === 1` die Räume `netz` (Rita) und `foren` (Finn), `region ''`, `entrance 'anfrage'` (Kim/Pia treibt der Tick `alarme`, E8). Ohne Websites: je Aufruf genau **eine** Kombination aus den Räumen der Etage `ohne_website` × `settings.regions`: sortiert nach „zuletzt gesucht“ aufsteigend (nie gesucht zuerst), Gleichstand nach Rollenreihenfolge, dann Regionsreihenfolge; „zuletzt gesucht“ = jüngstes `at` aus `workersSearchLog` (je Raum: `Query.equal('room',[room])`, `orderDesc('at')`, `limit(100)`) **oder** `$createdAt` eines `suche`-Jobs der letzten 48 h (Payload `room`+`region`), damit Lauf 2 nicht dieselbe Kombination plant. Je Eintrag erst `enqueue`, dann `emit`. Ist nach der Auswahl nichts geplant, genau ein `leerlauf`-Ereignis (`Otto: Kein Raum angeschlossen, nichts zu planen.`) und `skipped:'keine_raeume'`. +- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (3 Tests). +- [ ] **Step 5: Commit.** `git add src/otto/nachtplan.js test/nachtplan.test.js && git commit -m "P1-T20: Otto plant die Nacht" -m "Co-Authored-By: Claude Opus 5.5 "` + +--- + +### P1-T21: Otto – Morgenbericht + +**Files:** +- Create: `src/otto/bericht.js` +- Test: `test/bericht.test.js` + +**Interfaces:** +- Consumes: `getSettings` (T07), `getDaily`/`monthDocs` (T09), `berlinNow` (T08), `formatUsd` (T11), `roomLabel` (T05), `emit` (T06), `deps.db.listAll/count`, `deps.notify` (E3). +- Produces: `buildReport(deps) → { subject, text }`; `sendReport(deps) → notify-Ergebnis` (bei `sent:false` zusätzlich Ereignis `fehler`, `otto`, `Morgenbericht nicht gesendet: `). + +- [ ] **Step 1: Tests schreiben.** + ```js + test('Bericht: Zählzeilen, Kosten, Wartendes, Fehler – nur Ereignisse der letzten 24 h', async () => { + const deps = makeDeps({ now: '2026-10-19T04:30:00.000Z' }) // Montag 06:30 Berlin + const ev = (n, kind, workerId = 'kim', ts = '2026-10-19T00:30:00.000Z', text = 'x') => { for (let i = 0; i < n; i++) deps.db.seed('workersEvents', { ts, workerId, kind, text }) } + ev(12, 'fund'); ev(5, 'doppelt', 'arne'); ev(7, 'bewertet', 'bea'); ev(3, 'top3', 'bea'); ev(4, 'botlead', 'arne'); ev(3, 'gesendet', 'max'); ev(1, 'eingang', 'nora'); ev(4, 'auftrag', 'otto') + ev(1, 'fehler', 'kim', '2026-10-19T01:00:00.000Z', 'Kim: Postfach nicht erreichbar.'); ev(9, 'fund', 'kim', '2026-10-17T00:00:00.000Z') // letztes: zu alt + deps.db.seed('workersDaily', { costUsd: 6.4 }, '2026-10-18'); deps.db.seed('workersDaily', { costUsd: 0.3 }, '2026-10-19'); deps.db.seed('workersDaily', { costUsd: 34.4 }, '2026-10-05') + for (const status of ['offen', 'in_arbeit', 'erledigt']) deps.db.seed('workersPult', { status }) + const { subject, text } = await buildReport(deps) + assert.equal(subject, 'Nachtbericht 19.10.2026') + for (const re of [/Gefunden: 12 · schon bekannt: 5 · bewertet: 7 · Top 3: 3 · Bot-Leads: 4/, /Gesendet: 3 · Antworten eingegangen: 1 · Deals: 0 · gesperrt: 0/, /Wartet auf dich: 2/, /Aufträge von Otto: 4/, + /Kosten gestern: 6,40 \$ · heute bis jetzt: 0,30 \$ · Monat: 41,10 \$ von 250,00 \$ \(Tagesgrenze 10,00 \$\)/, /Fehler: 1 \(zuletzt: Kim: Postfach nicht erreichbar\.\)/, + /Noch nicht angeschlossen: Netz-Radar, Kleinanzeigen, Portale, Foren & Fragen, Ärzte & Praxen, Handwerk, Selbstständige, Läden & Gastro/]) assert.match(text, re) + }) + test('ruhige Nacht, Pause und Budget-Hinweise', async () => { + const deps = makeDeps({ now: '2026-10-19T04:30:00.000Z' }); await updateSettings(deps, { paused: true, connectedRooms: ['netz'] }, 't') + deps.db.seed('workersEvents', { ts: '2026-10-19T01:00:00.000Z', workerId: 'otto', kind: 'budget_warnung', text: 'Budget-Warnung' }) + const { text } = await buildReport(deps); assert.match(text, /Pause: aktiv/); assert.match(text, /Budget: Warnung ausgelöst/); assert.match(text, /Die Nacht war ruhig/); assert.ok(!/Netz-Radar/.test(text)) + }) + test('sendReport reicht Betreff/Text an notify; Fehlschlag wird als fehler-Ereignis sichtbar', async () => { + const sent = []; const ok = makeDeps({ notify: async (m) => { sent.push(m); return { sent: true } } }) + assert.deepEqual(await sendReport(ok), { sent: true }); assert.match(sent[0].subject, /^Nachtbericht \d\d\.\d\d\.\d{4}$/) + const bad = makeDeps({ notify: async () => ({ sent: false, reason: 'smtp_auth' }) }); await sendReport(bad) + const e = bad.db.all('workersEvents').at(-1); assert.deepEqual([e.kind, e.workerId], ['fehler', 'otto']); assert.match(e.text, /Morgenbericht nicht gesendet: smtp_auth/) + }) + ``` +- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/bericht.test.js` → FAIL `ERR_MODULE_NOT_FOUND`. +- [ ] **Step 3: Implementieren.** Fenster: Ereignisse mit `ts ≥ jetzt − 24 h` (`listAll` mit `Query.greaterThanEqual('ts', …)`), gezählt nach `kind`; Kosten aus den Tagesdokumenten (gestern = Tag vor `now.day`, heute, Monat = `monthDocs`) und den Einstellungen; Pult = `count` mit `status` ∈ {offen, in_arbeit}; Zeilenformate **wörtlich** wie im Test; Zeile `Fehler: n (zuletzt: )` nur bei n > 0; `Budget: Warnung ausgelöst`/`Stopp ausgelöst` nur bei entsprechenden Ereignissen im Fenster; `Pause: aktiv` nur bei `paused`; `Noch nicht angeschlossen: …` = aktive Räume (Reihenfolge `SEARCH_ROOMS`, Namen via `roomLabel`) ohne Anschluss, Zeile entfällt wenn leer; kam keine der gezählten Arten (fund, doppelt, bewertet, top3, botlead, gesendet, eingang, deal, gesperrt, fehler) vor, zusätzlich `Die Nacht war ruhig: keine neuen Funde, keine Nachrichten.`; Anrede `Guten Morgen, hier ist Otto. Nachtbericht für den 19.10.2026.`; Datum `TT.MM.JJJJ` aus `now.day`. `sendReport` ohne `deps.notify` ⇒ Ergebnis `{ sent:false, reason:'nicht_konfiguriert' }` (und das `fehler`-Ereignis). Kein Modellaufruf. +- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (3 Tests). +- [ ] **Step 5: Commit.** `git add src/otto/bericht.js test/bericht.test.js && git commit -m "P1-T21: Otto schreibt den Morgenbericht" -m "Co-Authored-By: Claude Opus 5.5 "` + +--- + +### P1-T22: Seed-Bausteine für Alt-Leads und Kunden + +**Files:** +- Create: `src/register/seed.js`, `test/fixtures/seed-backup.json`, `test/fixtures/seed-master.json` +- Test: `test/seed.test.js` + +**Interfaces:** +- Consumes: `keysFor` (T18), `normalizeDomain` (T17). +- Produces: `leadEntries(backup, master) → Entry[]` (`backup` = geparstes `appwrite-backup.json`, nur `.leads`; `master` = geparstes `master.json`, nur `.leads`); `customerEntries(customers, projects) → Entry[]`; `Entry = { origin:'seed_alt'|'seed_kunde', blocked:boolean, keys:[{kind,value}] }`. + +- [ ] **Step 1: Fixtures und Test schreiben.** Fixtures mit erfundenen Daten: `seed-backup.json` = `{ "leads": [A, B, C], "customers": [ { "name": "Fake", "portalPassword": "GEHEIM-PW-3" } ] }` mit + A = `{ leadId:'lack-demo-eins', firma:'Lack Demo Eins GmbH', email:'info@lack-demo-eins.de', telefon:'+49 (0) 631 111111', website:'[lack-demo-eins.de](https://www.lack-demo-eins.de/)', adresse:'Musterstr. 1, 67655 Kaiserslautern', googleMapsLink:'https://www.google.com/maps/search/?api=1&query=Lack+Demo', templateData:'{"GOOGLE_PLACE_ID":"ChIJdemoPlaceId0001","PLZ":"67655"}', crmStatus:'neu', portalLogin:'x', portalPasswort:'GEHEIM-PW-1' }`, + B = `{ leadId:'lack-demo-zwei', firma:'Zwei Lackierer', email:'zwei@gmx.de', telefon:'0631 222222', website:'keine', adresse:'Weg 2, 80331 München', templateData:'{}', crmStatus:'kontaktiert', portalPasswort:'GEHEIM-PW-2' }`, + C = `{ leadId:'lack-demo-drei', firma:'Drei Lack', email:'', telefon:'', website:'https://drei-lack.jimdofree.com/start', adresse:'Allee 3, 85551 Kirchheim', templateData:'{"PLZ":"85551"}', crmStatus:'neu' }`; + `seed-master.json` = `{ "leads": [ …dieselben drei als snake_case (lead_id, firma, email, telefon, website, adresse, google_maps_link, template_data als Objekt, portal_passwort:'GEHEIM-PW-5'), wobei der dritte email:'kontakt@drei-lack.de', telefon:'08991 333333' hat ] }`. + ```js + test('Alt-Leads: nur "kontaktiert" blockiert; Schlüssel bereinigt; Lücken aus master gefüllt; backup.customers ignoriert', () => { + const e = leadEntries(backup, master) + assert.deepEqual(e.map((x) => [x.origin, x.blocked]), [['seed_alt', false], ['seed_alt', true], ['seed_alt', false]]) + assert.deepEqual(e[0].keys, [{ kind: 'dom', value: 'lack-demo-eins.de' }, { kind: 'mail', value: 'info@lack-demo-eins.de' }, { kind: 'tel', value: '+49631111111' }, { kind: 'pid', value: 'ChIJdemoPlaceId0001' }, { kind: 'namplz', value: 'lack demo eins|67655' }]) + assert.deepEqual(e[1].keys, [{ kind: 'mail', value: 'zwei@gmx.de' }, { kind: 'tel', value: '+49631222222' }, { kind: 'namplz', value: 'zwei lackierer|80331' }]) + assert.deepEqual(e[2].keys, [{ kind: 'dom', value: 'drei-lack.jimdofree.com' }, { kind: 'dom', value: 'drei-lack.de' }, { kind: 'mail', value: 'kontakt@drei-lack.de' }, { kind: 'tel', value: '+498991333333' }, { kind: 'namplz', value: 'drei lack|85551' }]) + }) + test('unbekannter crmStatus gilt als gesperrt (nur leer oder "neu" ist frei)', () => { + const b = structuredClone(backup); b.leads[0].crmStatus = 'verloren'; assert.equal(leadEntries(b, master)[0].blocked, true); b.leads[0].crmStatus = ''; assert.equal(leadEntries(b, master)[0].blocked, false) + }) + const customers = [ + { $id: 'c1', name: 'Bäckerei Müller', companyName: 'Bäckerei Müller GmbH', email: 'info@mueller-backt.de; chef@gmail.com', phone: '0631 123456', postalCode: '67655', customerStatus: 'customer', portalPassword: 'GEHEIM-PW-4' }, + { $id: 'c2', name: 'Alter Lead', email: 'alt@lead.de', customerStatus: 'lead' }, { $id: 'c3', name: 'Abgesagt', email: 'weg@abgesagt.de', customerStatus: 'lost', archived: true }] + const projects = [{ customerId: 'c1', liveDomain: 'mueller-backt.de' }, { customerId: 'c1', liveDomain: 'baeckerei.project.webklar.com' }] + test('Kunden: alle Status (auch lost, archiviert, Lead) gesperrt; Mehrfachadressen; Vorschau-Hosts und eigene Domain ausgelassen', () => { + const e = customerEntries(customers, projects) + assert.equal(e.length, 3); assert.ok(e.every((x) => x.origin === 'seed_kunde' && x.blocked === true)) + assert.deepEqual(e[0].keys, [{ kind: 'dom', value: 'mueller-backt.de' }, { kind: 'mail', value: 'info@mueller-backt.de' }, { kind: 'mail', value: 'chef@gmail.com' }, { kind: 'tel', value: '+49631123456' }, { kind: 'namplz', value: 'baeckerei mueller|67655' }]) + assert.deepEqual(e[1].keys, [{ kind: 'dom', value: 'lead.de' }, { kind: 'mail', value: 'alt@lead.de' }]) + }) + test('Passwörter tauchen in keinem Ergebnis auf', () => { assert.ok(!JSON.stringify([leadEntries(backup, master), customerEntries(customers, projects)]).includes('GEHEIM')) }) + ``` +- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/seed.test.js` → FAIL `ERR_MODULE_NOT_FOUND` (`src/register/seed.js`). +- [ ] **Step 3: Implementieren.** Felder **nur per Whitelist** lesen, nie das Objekt weiterreichen. Lead: je `leadId` (Backup zuerst, danach nur in `master` vorhandene) aus Backup `firma, email, telefon, website, adresse, googleMapsLink, templateData (JSON-String, bei Parse-Fehler {}), crmStatus` mit Lücken (leer) aus `master` (`firma, email, telefon, website, adresse, google_maps_link, template_data`-Objekt); `placeId` = `templateData.GOOGLE_PLACE_ID` sonst `placeIdKey(googleMapsLink)`; PLZ = `templateData.PLZ` sonst erste `\b\d{5}\b` aus `adresse`; Schlüssel über `keysFor({ website, email, phone, placeId, name:firma, postalCode })`; `blocked = crmStatus ∉ {'', 'neu', undefined}`. Kunde: `email` an `;`/`,` teilen, `name: [name, companyName]`, `website` = alle `liveDomain` des Kunden aus `projects` (nach `customerId`), danach `dom`-Schlüssel `webklar.com` (eigene Vorschau-Hosts) entfernen; jeder Kunde (egal welcher `customerStatus`, auch `archived`) ⇒ `blocked: true`, `origin: 'seed_kunde'`; Einträge ohne Schlüssel entfallen nicht (leere `keys` erlaubt). +- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (4 Tests). +- [ ] **Step 5: Commit.** `git add src/register/seed.js test/fixtures/seed-backup.json test/fixtures/seed-master.json test/seed.test.js && git commit -m "P1-T22: Seed-Bausteine für Alt-Leads und Kunden" -m "Co-Authored-By: Claude Opus 5.5 "` + +--- + +### P1-T23: Seed-Skript `seed-register.mjs` + +**Files:** +- Create: `scripts/seed-register.mjs` +- Test: `test/seedRunner.test.js` + +**Interfaces:** +- Consumes: `leadEntries`/`customerEntries` (T22), `claimKeys`/`keyId` (T18), `createDb`/`GESPERRTE_SAMMLUNGEN`/`Query` (T04). +- Produces: `main({ argv, deps, readJson, log }) → { created, existing }`; CLI `node scripts/seed-register.mjs [--dry-run] [--backup ] [--master ]` (Standard `/root/reports/leads-auf-eis-2026-09-30/appwrite-backup.json`, `/opt/webklar-automation/master.json`). Ausgabezeilen: `TROCKENLAUF – es wird nichts geschrieben.`, `Alte Leads: n (blockiert: m)`, `Kunden: n (customer a, lead b, lost c, archiviert d)`, `Schlüssel neu: n · schon vorhanden: m` – nie Adressen, Nummern oder Namen. + +- [ ] **Step 1: Tests schreiben** (`backup`/`master` = die Fixtures aus T22, per `readFileSync` + `JSON.parse` geladen; Kunden und `websiteProjects` kommen über `deps.db.seed`, `readJson` liefert die Fixtures): + ```js + const prepareCustomers = (deps) => { + deps.db.seed('customers', { name: 'Bäckerei Müller', email: 'info@mueller-backt.de', phone: '0631 123456', postalCode: '67655', customerStatus: 'customer', portalPassword: 'GEHEIM-PW-4' }, 'c1') + deps.db.seed('customers', { name: 'Altes Lead', email: 'alt@lead.de', customerStatus: 'lead' }, 'c2'); deps.db.seed('websiteProjects', { customerId: 'c1', liveDomain: 'mueller-backt.de' }, 'p1') + } + const run = async (deps, argv = []) => { const out = []; const files = { '/b.json': backup, '/m.json': master } + const r = await main({ argv: ['--backup', '/b.json', '--master', '/m.json', ...argv], deps, readJson: async (p) => files[p], log: (l) => out.push(l) }); return { r, text: out.join('\n') } } + test('Trockenlauf schreibt nichts; echter Lauf legt an; zweiter Lauf legt nichts mehr an; Ausgabe ohne Adressen', async () => { + const deps = makeDeps(); prepareCustomers(deps) + const dry = await run(deps, ['--dry-run']); assert.equal(deps.db.all('workersKeys').length, 0); assert.match(dry.text, /TROCKENLAUF/); assert.match(dry.text, /^Alte Leads: 3 \(blockiert: 1\)$/m) + const real = await run(deps); const n = deps.db.all('workersKeys').length; assert.ok(n > 0); assert.equal(real.r.created, n); assert.equal(dry.r.created, n) + const again = await run(deps); assert.equal(again.r.created, 0); assert.equal(deps.db.all('workersKeys').length, n) + assert.ok(![dry.text, real.text, again.text].join('\n').includes('@')) + }) + test('Kunden zuerst: gemeinsamer Schlüssel von Kunde und Alt-Lead bleibt gesperrt mit Origin seed_kunde', async () => { + const deps = makeDeps(); deps.db.seed('customers', { name: 'Drei Lack GmbH', email: 'kontakt@drei-lack.de', customerStatus: 'customer' }, 'c9'); await run(deps) + const d = await deps.db.get('workersKeys', keyId('mail', 'kontakt@drei-lack.de')); assert.deepEqual([d.blocked, d.origin], [true, 'seed_kunde']) + }) + test('Kunden werden nur mit select-Whitelist geladen (portalPassword nie abgefragt) und nie beschrieben', async () => { + const deps = makeDeps(); prepareCustomers(deps); await run(deps) + const sel = deps.db.calls.find((c) => c.col === 'customers' && c.queries).queries.map((s) => JSON.parse(s)).find((q) => q.method === 'select') + assert.ok(sel.values.includes('email') && sel.values.includes('customerStatus')); assert.ok(!sel.values.includes('portalPassword')) + assert.ok(deps.db.calls.every((c) => !GESPERRTE_SAMMLUNGEN.includes(c.col) || ['get', 'list', 'listAll', 'count'].includes(c.op))) // Übersicht §2.13: nur lesen + }) + test('fehlende Datei → Fehler mit Pfad, nichts geschrieben', async () => { + const deps = makeDeps(); const readJson = async (p) => { throw Object.assign(new Error('ENOENT'), { path: p }) } + await assert.rejects(main({ argv: ['--backup', '/weg.json', '--master', '/m.json'], deps, readJson, log: () => {} }), /\/weg\.json/); assert.equal(deps.db.all('workersKeys').length, 0) + }) + ``` +- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/seedRunner.test.js` → FAIL `ERR_MODULE_NOT_FOUND` (`scripts/seed-register.mjs`). +- [ ] **Step 3: Implementieren.** `main`: Argumente parsen; Dateien per `readJson` (CLI: `JSON.parse(readFile)`); Kunden: `listAll('customers', [Query.select(['name','companyName','contactName','email','phone','postalCode','customerStatus','archived'])])`, Projekte: `listAll('websiteProjects', [Query.select(['customerId','liveDomain'])])`; Reihenfolge **Kunden zuerst**, dann Alt-Leads; je Eintrag `claimKeys(deps, keys, { prospectId:'', origin, blocked })`. Trockenlauf: keine Schreibzugriffe, stattdessen je Schlüssel `get('workersKeys', keyId(…))` plus ein `Set` der in diesem Lauf schon gesehenen Schlüssel (zählt als „vorhanden“). `created` = Schlüssel − Treffer − Hinweise (nur neu angelegte). Ausgabe nur Zähler (Format siehe Interfaces); nicht lesbare Datei ⇒ `Error('Datei nicht lesbar: ')`, bevor irgendetwas geschrieben wird. CLI-Rahmen (nur bei direktem Start): Konfiguration `APPWRITE_ENDPOINT` (Standard `https://appwrite.webklar.com/v1`), `APPWRITE_PROJECT_ID` (Standard `6a1058610003c5a13a05`), `APPWRITE_API_KEY` (fehlt ⇒ Name nennen, Exit 2), `deps = { db: createDb(…), clock: { now: () => Date.now() }, log: () => {} }`, Exit 1 bei Fehler. +- [ ] **Step 4: Test laufen lassen.** Gleicher Befehl → PASS (4 Tests). +- [ ] **Step 5: Commit.** `git add scripts/seed-register.mjs test/seedRunner.test.js && git commit -m "P1-T23: Seed-Skript für den Startbestand" -m "Co-Authored-By: Claude Opus 5.5 "` + +--- + +### P1-T24: HTTP-API – Kern (Auth, `/health`, `/pause`, `/settings`) + +**Files:** +- Create: `src/api.js`, `test/support/apiBoot.js` +- Test: `test/apiAuth.test.js` + +**Interfaces:** +- Consumes: `getSettings`/`updateSettings`/`publicSettings` (T07), `emit` (T06), `HttpError` (T07). +- Produces: `createApi({ deps, appwrite:{endpoint,projectId}, version, fetchImpl = fetch, authTtlMs = 60000 }) → http.Server`; Basis `/api/workers`; Routen laut Übersicht §6.2: `GET /health` (offen), `POST /pause`, `GET|PUT /settings` (Admin); Fehler `{ error }` (E9). `boot({ labels = ['mitarbeiter'], status = 200, fetchThrows = false, deps = makeDeps() })` (Testhelfer in `test/support/apiBoot.js`, Version `0.1.0`) → `{ req(path, { method = 'GET', jwt = 'a.b.c', body }), calls, deps, close() }` mit Fake-`/account` (`{ $id:'u1', name:'Kenso', labels }`). + +- [ ] **Step 1: Tests schreiben** (`apiBoot.js`: Server auf Port 0, `fetchImpl` liefert `{ $id:'u1', name:'Kenso', labels }` bzw. Status/Fehler). + ```js + test('/health ohne JWT', async () => { const t = await boot(); const r = await t.req('/health', { jwt: null }); assert.deepEqual([r.status, await r.json()], [200, { ok: true, version: '0.1.0' }]); await t.close() }) + test('ohne oder mit unlesbarem JWT → 401, Appwrite wird nicht gefragt', async () => { + const t = await boot(); let r = await t.req('/settings', { jwt: null }); assert.deepEqual([r.status, (await r.json()).error], [401, 'Nicht angemeldet.']) + r = await t.req('/pause', { method: 'POST', jwt: 'abc', body: { paused: true } }); assert.equal(r.status, 401); assert.equal(t.calls.length, 0); await t.close() + }) + test('Portalkunde (Label fehlt oder customer) bekommt 403 ohne Daten', async () => { + for (const labels of [[], ['customer']]) { const t = await boot({ labels }) + for (const [p, m] of [['/settings', 'GET'], ['/pause', 'POST'], ['/settings', 'PUT']]) { const r = await t.req(p, { method: m, body: m === 'GET' ? undefined : { paused: true } }); assert.equal(r.status, 403); assert.deepEqual(await r.json(), { error: 'Kein Zugriff.' }) } + assert.equal(t.deps.db.all('workersSettings').length, 0); await t.close() } + }) + test('Mitarbeiter: /pause ja, /settings nur für Admin', async () => { + const t = await boot({ labels: ['mitarbeiter'] }); assert.equal((await t.req('/pause', { method: 'POST', body: { paused: true } })).status, 200) + for (const m of ['GET', 'PUT']) { const r = await t.req('/settings', { method: m, body: m === 'PUT' ? { warnPct: 70 } : undefined }); assert.deepEqual([r.status, (await r.json()).error], [403, 'Nur für Admins.']) }; await t.close() + }) + test('Admin: Einstellungen lesen (ohne previewsSent), ändern, Validierungsfehler 400 mit deutschem Text', async () => { + const t = await boot({ labels: ['admin'] }); const g = await (await t.req('/settings')).json(); assert.equal(g.dailyBudgetUsd, 10); assert.ok(!('previewsSent' in g)) + const p = await t.req('/settings', { method: 'PUT', body: { dailyBudgetUsd: 12 } }); assert.equal((await p.json()).dailyBudgetUsd, 12) + const bad = await t.req('/settings', { method: 'PUT', body: { mailColdPerDay: 4 } }); assert.equal(bad.status, 400); assert.match((await bad.json()).error, /mailColdPerDay/); await t.close() + }) + test('Appwrite: 401 → "Sitzung abgelaufen.", nicht erreichbar → 502; Cache: gleiches JWT = ein /account-Aufruf', async () => { + let t = await boot({ status: 401 }); let r = await t.req('/settings'); assert.deepEqual([r.status, (await r.json()).error], [401, 'Sitzung abgelaufen.']); await t.close() + t = await boot({ fetchThrows: true }); r = await t.req('/settings'); assert.deepEqual([r.status, (await r.json()).error], [502, 'Anmeldedienst nicht erreichbar.']); await t.close() + t = await boot({ labels: ['admin'] }); await t.req('/settings'); await t.req('/settings'); assert.equal(t.calls.length, 1); await t.req('/settings', { jwt: 'x.y.z' }); assert.equal(t.calls.length, 2); await t.close() + }) + test('/pause: setzt, Ereignis nur bei Änderung; ungültiger Body 400, Riesen-Body 413; unbekannter Pfad 404, falsche Methode 405', async () => { + const t = await boot({ labels: ['mitarbeiter'] }); await t.req('/pause', { method: 'POST', body: { paused: true } }); await t.req('/pause', { method: 'POST', body: { paused: true } }) + const ev = t.deps.db.all('workersEvents'); assert.equal(ev.length, 1); assert.deepEqual([ev[0].workerId, ev[0].kind], ['system', 'pause']); assert.match(ev[0].text, /Kenso/); assert.equal(JSON.parse(ev[0].metaJson).paused, true) + assert.equal((await t.req('/pause', { method: 'POST', body: { paused: 'ja' } })).status, 400); assert.equal((await t.req('/pause', { method: 'POST', body: { x: 'y'.repeat(20000) } })).status, 413) + assert.equal((await t.req('/gibtsnicht')).status, 404); assert.equal((await t.req('/pause', { method: 'DELETE' })).status, 405); await t.close() + }) + ``` +- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/apiAuth.test.js` → FAIL `ERR_MODULE_NOT_FOUND` (`src/api.js`). +- [ ] **Step 3: Implementieren.** Nach dem Muster `/home/webklar/apps/serverpuls/src/api.js` (`HttpError`, `sendJson` mit `Cache-Control: no-store`, JWT-Form `^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$` und ≤ 2048 Zeichen, Cache `jwt → Promise` 60 s mit Teilen laufender Anfragen, höchstens 500 Einträge, `AbortSignal.timeout(5000)`), aber: `labels` muss `mitarbeiter` **oder** `admin` enthalten (sonst 403 `Kein Zugriff.`), `/settings` verlangt `admin` (403 `Nur für Admins.`); Appwrite 401/403/404 auf `/account` ⇒ 401 `Sitzung abgelaufen.`, sonstige Fehler/Netz ⇒ 502 `Anmeldedienst nicht erreichbar.`. Antworttexte: fehlendes JWT `Nicht angemeldet.`, unbekannter Pfad `Nicht gefunden.`, falsche Methode 405 `Methode nicht erlaubt.`, ungültiges JSON/Feld 400 (`Ungültige Anfrage.` bzw. Text der `ValidationError`), Body > 16 KB 413 `Anfrage zu groß.`, sonst 500 `Interner Fehler.` (Details nur ins Log). `POST /pause`: Body `{ paused: boolean }`; nur bei Änderung `updateSettings(deps, { paused }, user.$id)` und `emit` (`system`, `pause`, Text `Alle Workers pausiert ().` bzw. `Pause aufgehoben ().`, `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 "` + +--- + +### P1-T25: API-Sichten `/state`, `/events`, `/worker/:id/trace` + +**Files:** +- Create: `src/view.js` +- Modify: `src/api.js` +- Test: `test/view.test.js` (nutzt `boot` aus `test/support/apiBoot.js`, T24) + +**Interfaces:** +- Consumes: `getSettings`, `getDaily`/`monthDocs`, `ROLES`/`MODELS`, `Query`, `NotFoundError`/`ValidationError`. +- Produces (Übersicht §6.2, E9): `buildState(deps) → { paused, workers:[{ workerId, status:'arbeitet'|'wartet'|'leerlauf'|'grau'|'fehler', task, counters, connected }], kpis:{ newAbosMonth, mrrNewEur, dealsMonth, openThreads, pultOpen, botLeads, apiTodayUsd, apiMonthUsd, dailyBudgetUsd, monthlyBudgetUsd }, lastEventTs }`; `buildEvents(deps, { after?, limit? }) → { events:[{ id, ts, workerId, kind, text, link, to, prospectId, costUsd, meta }] }` (aufsteigend; Limit 1–200, Standard 100); `buildTrace(deps, workerId, { limit? }) → { worker:{ …ROLES-Eintrag, modelId }, today:{ counters, tokens:{in,out}, costUsd }, events }` (neueste zuerst, Limit 1–50, Standard 20). Routen `GET /state`, `GET /events?after=&limit=`, `GET /worker/:workerId/trace?limit=` für Team. + +- [ ] **Step 1: Tests schreiben** (`test/view.test.js`; Welt per `deps.db.seed(...)`): + ```js + async function world(deps) { + await updateSettings(deps, { connectedRooms: ['netz', 'foren', 'aerzte'] }, 't') + deps.db.seed('workersJobs', { workerId: 'rita', kind: 'suche', status: 'laeuft' }); deps.db.seed('workersJobs', { workerId: 'ali', kind: 'suche', status: 'offen' }); deps.db.seed('workersJobs', { workerId: 'otto', kind: 'bericht', status: 'offen' }) + deps.db.seed('workersEvents', { ts: '2026-10-19T08:55:00.000Z', workerId: 'ali', kind: 'fehler', text: 'Ali: Quelle nicht erreichbar.' }); deps.db.seed('workersEvents', { ts: '2026-10-19T08:50:00.000Z', workerId: 'sven', kind: 'fehler', text: 'Sven: Fehler.' }) + deps.db.seed('workersDaily', { costUsd: 2.5, deals: 1, countersJson: JSON.stringify({ rita: { gelesen: 5, tokensIn: 100, tokensOut: 50, costUsd: 0.1, calls: 2 } }) }, '2026-10-19'); deps.db.seed('workersDaily', { costUsd: 6.4, deals: 2 }, '2026-10-18') + for (const status of ['offen', 'in_arbeit', 'erledigt']) deps.db.seed('workersPult', { status }); for (let i = 0; i < 3; i++) deps.db.seed('workersProspects', { status: 'botlead' }); deps.db.seed('workersProspects', { status: 'gefunden' }) + deps.db.seed('workersThreads', { status: 'offen' }); deps.db.seed('workersThreads', { status: 'abgeschlossen' }) + deps.db.seed('workersOffers', { status: 'bezahlt', productKey: 'hosting', paidAt: '2026-10-10T10:00:00.000Z' }); deps.db.seed('workersOffers', { status: 'offen' }) + } + test('buildState: Rangfolge grau > fehler > arbeitet > wartet > leerlauf; Zähler ohne Token-/Kostenfelder; Kennzahlen', async () => { + const deps = makeDeps(); await world(deps); const s = await buildState(deps); const w = Object.fromEntries(s.workers.map((x) => [x.workerId, x])) + assert.equal(s.workers.length, 19); assert.equal(s.paused, false) + assert.deepEqual([w.rita.status, w.rita.connected, w.rita.counters], ['arbeitet', true, { gelesen: 5 }]) + assert.deepEqual([w.ali.status, w.ali.task], ['fehler', 'Ali: Quelle nicht erreichbar.']); assert.equal(w.sven.status, 'grau'); assert.equal(w.sven.task, 'noch nicht angeschlossen') + assert.equal(w.finn.status, 'leerlauf'); assert.deepEqual([w.otto.status, w.otto.task, w.otto.connected], ['wartet', 'Wartet auf Start: Bericht', true]) + assert.deepEqual(s.kpis, { newAbosMonth: 1, mrrNewEur: 20, dealsMonth: 3, openThreads: 1, pultOpen: 2, botLeads: 3, apiTodayUsd: 2.5, apiMonthUsd: 8.9, dailyBudgetUsd: 10, monthlyBudgetUsd: 250 }) + assert.equal(s.lastEventTs, '2026-10-19T08:55:00.000Z') + }) + test('buildState auf leerer DB: nichts angeschlossen = alles grau außer Otto, Kennzahlen 0, lastEventTs null', async () => { + const s = await buildState(makeDeps()); assert.equal(s.workers.filter((w) => w.status === 'grau').length, 18); assert.equal(s.workers.find((w) => w.workerId === 'otto').status, 'leerlauf') + assert.deepEqual(s.kpis, { newAbosMonth: 0, mrrNewEur: 0, dealsMonth: 0, openThreads: 0, pultOpen: 0, botLeads: 0, apiTodayUsd: 0, apiMonthUsd: 0, dailyBudgetUsd: 10, monthlyBudgetUsd: 250 }); assert.equal(s.lastEventTs, null) + }) + test('buildEvents: aufsteigend ab after (strikt größer), Limit geklemmt, ohne after die letzten N aufsteigend, meta geparst; ungültiges Datum → ValidationError', async () => { + const deps = makeDeps(); for (let i = 1; i <= 5; i++) deps.db.seed('workersEvents', { ts: `2026-10-19T08:00:0${i}.000Z`, workerId: 'otto', kind: 'leerlauf', text: `e${i}`, metaJson: `{"n":${i}}` }, `e${i}`) + assert.deepEqual((await buildEvents(deps, { after: '2026-10-19T08:00:02.000Z' })).events.map((e) => e.id), ['e3', 'e4', 'e5']) + assert.deepEqual((await buildEvents(deps, { after: '2026-10-19T08:00:02.000+00:00' })).events.map((e) => e.id), ['e3', 'e4', 'e5']) // Offset-Schreibweise gleichwertig + const last2 = (await buildEvents(deps, { limit: 2 })).events; assert.deepEqual(last2.map((e) => e.id), ['e4', 'e5']); assert.deepEqual(last2[1].meta, { n: 5 }) + assert.equal((await buildEvents(deps, { limit: 999 })).events.length, 5); await assert.rejects(buildEvents(deps, { after: 'gestern' }), ValidationError) + }) + test('Zeitstempel werden immer als ISO mit Z ausgegeben (Appwrite liefert "+00:00")', async () => { + const deps = makeDeps(); deps.db.seed('workersEvents', { ts: '2026-10-19T08:00:06.000+00:00', workerId: 'otto', kind: 'leerlauf', text: 'x' }) + assert.equal((await buildEvents(deps, {})).events[0].ts, '2026-10-19T08:00:06.000Z'); assert.equal((await buildState(deps)).lastEventTs, '2026-10-19T08:00:06.000Z') + }) + test('buildTrace: unbekannter Worker → NotFoundError; Worker-Block mit modelId; today; neueste zuerst', async () => { + const deps = makeDeps(); await world(deps) + deps.db.seed('workersEvents', { ts: '2026-10-19T08:01:00.000Z', workerId: 'rita', kind: 'gelesen', text: 'alt' }); deps.db.seed('workersEvents', { ts: '2026-10-19T08:02:00.000Z', workerId: 'rita', kind: 'fund', text: 'neu' }) + const t = await buildTrace(deps, 'rita', {}) + assert.deepEqual([t.worker.workerId, t.worker.model, t.worker.modelId], ['rita', 'haiku', 'claude-haiku-5-5']); assert.deepEqual(t.today, { counters: { gelesen: 5 }, tokens: { in: 100, out: 50 }, costUsd: 0.1 }); assert.deepEqual(t.events.map((e) => e.text), ['neu', 'alt']) + await assert.rejects(buildTrace(deps, 'niemand', {}), NotFoundError) + }) + test('Routen: Team 200, Portalkunde 403, unbekannter Worker 404, ungültiges after 400', async () => { + const t = await boot({ labels: ['mitarbeiter'] }); await world(t.deps) + for (const p of ['/state', '/events?limit=999', '/worker/rita/trace?limit=999']) assert.equal((await t.req(p)).status, 200) + assert.equal((await (await t.req('/state')).json()).workers.length, 19); assert.equal((await t.req('/worker/niemand/trace')).status, 404); assert.equal((await t.req('/events?after=gestern')).status, 400); await t.close() + const k = await boot({ labels: ['customer'] }); assert.equal((await k.req('/state')).status, 403); await k.close() + }) + ``` +- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/view.test.js` → FAIL `ERR_MODULE_NOT_FOUND` (`src/view.js`). +- [ ] **Step 3: Implementieren.** `buildState`: Einstellungen; Aufträge `status ∈ {laeuft, offen}` (`limit 200`); Ereignisse der letzten 30 min (`ts` absteigend, `limit 200`) – „aktuell fehlerhaft“ = jüngstes Ereignis des Workers innerhalb 15 min ist `fehler`; `connected` = `room === 'bruecke' || connectedRooms.includes(room)`; Status in der Rangfolge `grau` (nicht angeschlossen) > `fehler` > `arbeitet` (Job `laeuft`) > `wartet` (Job `offen`) > `leerlauf`; `task`: `grau` ⇒ `noch nicht angeschlossen`, `fehler` ⇒ Text des Fehler-Ereignisses, `arbeitet` ⇒ Text des jüngsten Ereignisses des Workers (sonst `JOB_LABEL`), `wartet` ⇒ `Wartet auf Start: `, sonst `''`; `JOB_LABEL` = deutsche Substantive für alle 15 Auftragsarten (`suche` → `Suche`, `bericht` → `Bericht`, `alarm_lesen` → `Alarm-Postfach lesen`, …); `counters` aus `getDaily(heute).counters[workerId]` ohne `tokensIn, tokensOut, costUsd, calls`. Kennzahlen: `newAbosMonth`/`mrrNewEur` aus `workersOffers` (Abfrage nur über den Index `status = 'bezahlt'`, `limit 500`; `paidAt` ≥ Monatsanfang Berlin wird im Code gefiltert; Preise `{ hosting: 20, wartung: 50 }`), `dealsMonth` = Summe `deals` aus `monthDocs`, `openThreads` = `count` Fäden mit Status offen/wartet_auf_kunde/wartet_auf_uns, `pultOpen` = `count` offen+in_arbeit, `botLeads` = `count` Interessenten `botlead`, `apiTodayUsd`/`apiMonthUsd` aus den Tagesdokumenten (alle Geldwerte auf 6 Nachkommastellen runden). `buildEvents`/`buildTrace` mit den Indizes `ts` bzw. `workerId+ts`; `meta` = geparstes `metaJson` oder `null`; ungültiges `after` (`Date.parse` = NaN) ⇒ `ValidationError('Ungültiges Datum.')`, gültiges wird als `new Date(after).toISOString()` in die Query gegeben; jedes ausgegebene `ts`/`lastEventTs` läuft durch `new Date(x).toISOString()` (E9); Limits klemmen statt ablehnen. Routen in `api.js` für Team (Pfadparameter URL-dekodieren, unbekannter Worker ⇒ 404 `Unbekannter Worker.`). +- [ ] **Step 4: Tests laufen lassen.** Gleicher Befehl und `test/apiAuth.test.js` → PASS (6 + 7 Tests). +- [ ] **Step 5: Commit.** `git add src/view.js src/api.js test/view.test.js && git commit -m "P1-T25: API-Sichten state, events, trace" -m "Co-Authored-By: Claude Opus 5.5 "` + +--- + +### P1-T26: Tick, `main.js`, Abnahme-Skript `jetzt.mjs` + +**Files:** +- Create: `src/tick.js`, `src/main.js`, `scripts/jetzt.mjs` +- Test: `test/tick.test.js`, `test/main.test.js` + +**Interfaces:** +- Consumes: `isDue`/`runKey`/`berlinNow` (T08), `markRun`/`lastRun` (T09), `getSettings`, `emit`, `reclaimStale` (T15), `planNight`, `sendReport`, `createApi`, `createDb`, `createClaude`, `createNotifier`, `loadConfig`. +- Produces: `createTick({ deps, jobs }) → { tick() }` mit `jobs: [{ kind, workerId, pauseSafe, enabled?(settings), run(deps, { now, settings }) }]` (spätere Pläne hängen weitere Einträge an); `DEFAULT_JOBS` = `nachtplan` (planNight run 1), `nacht2` (run 2, nur `runsPerNight ≥ 2`), `bericht` (pauseSafe), `aufraeumen` (Intervall 5 min, pauseSafe: `reclaimStale` 30 min); `buildDeps(config, over = {}) → { db, clock, log, claude, notify }`; `start(env = process.env, over = {}) → Promise<{ server, stop(), tick }>` (`over`: `config`, `deps`, `port`); `runJetzt(art, deps)` (Export aus `scripts/jetzt.mjs`, Arten `bericht`, `nachtplan`, `nachtplan2`; sonst `Error('Unbekannte Art: …')`). Imports in den Tests: `createTick, DEFAULT_JOBS` aus `src/tick.js`, `start` aus `src/main.js`, `runJetzt` aus `scripts/jetzt.mjs`, `pkg` = geparste `package.json`. + +- [ ] **Step 1: Tests schreiben.** + ```js + 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`: + ```js + test('start: /health antwortet, Tick lief, stop() beendet sauber; ohne Pflicht-Env ConfigError', async () => { + const deps = makeDeps(); const app = await start({}, { config: { appwrite: { endpoint: 'http://aw/v1', projectId: 'p', apiKey: 'k' }, api: { port: 0 } }, deps, port: 0 }) + const r = await fetch(`http://127.0.0.1:${app.server.address().port}/api/workers/health`); assert.deepEqual(await r.json(), { ok: true, version: pkg.version }); await app.stop() + await assert.rejects(start({}), (e) => e.missing.includes('APPWRITE_API_KEY')) + }) + test('jetzt: bericht ruft notify, nachtplan plant (hier: leerlauf), unbekannte Art wirft', async () => { + const sent = []; const deps = makeDeps({ notify: async (m) => { sent.push(m); return { sent: true } } }) + await runJetzt('bericht', deps); assert.equal(sent.length, 1); assert.deepEqual(await runJetzt('nachtplan', deps), { planned: [], skipped: 'keine_raeume' }); await assert.rejects(runJetzt('quatsch', deps), /Unbekannte Art/) + }) + ``` +- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/tick.test.js test/main.test.js` → FAIL `ERR_MODULE_NOT_FOUND` (`src/tick.js`). +- [ ] **Step 3: Implementieren.** `tick()`: Guard gegen Überlappung; `now = berlinNow(clock)`; Einstellungen laden (Fehler ⇒ loggen, Abbruch); je Job in Tabellenreihenfolge: übersprungen bei `paused && !pauseSafe` oder `enabled(settings) === false`; `key = runKey(kind, now)`; `last` = `key ? await lastRun(deps, kind, key) : intervalLast.get(kind) ?? null`; nicht fällig ⇒ weiter; Marker **vor** dem Lauf setzen (`markRun` bzw. `intervalLast.set(kind, now.ts)`), damit ein fehlschlagender Lauf nicht jede Minute wiederholt wird; `run` in `try/catch`: Log + `emit({ workerId: job.workerId, kind:'fehler', text:'Fehler bei : ' })` (selbst abgesichert, Text ohne Secrets/Stack). `DEFAULT_JOBS` (Tick-Tabelle, exportiert aus `tick.js`) wörtlich wie unter „Produces“; `workerId` `otto` (bei `aufraeumen`: `system`). `buildDeps`: `db = createDb(config.appwrite)`, `clock = { now: () => Date.now() }`, `log` = `console.log` mit ISO-Zeitstempel, `claude = createClaude({ apiKey: config.anthropicKey, searchModel: config.searchModel })`, `notify = createNotifier({ smtp: config.reportSmtp, getTo: async () => (await getSettings(base)).reportEmail, clock, log }).notify`; `over` überschreibt jedes dieser Teile. `start`: `loadConfig` (oder `over.config`), `createApi({ deps, appwrite: config.appwrite, version: package.json-Version })`, `server.listen(over.port ?? config.api.port)`, sofort ein Tick, danach `setInterval(tick, 60000).unref()`, `unhandledRejection` loggen statt abstürzen, `stop()` = Intervall löschen, `server.close`, nach höchstens 5 s Ausstieg; Signale `SIGTERM`/`SIGINT` rufen `stop` und beenden den Prozess (nur wenn direkt gestartet). Logzeile `workers listening on `. `scripts/jetzt.mjs `: `loadConfig(process.env)` → `buildDeps` → `runJetzt` → druckt das Ergebnis als JSON (ohne Secrets); nur für die Abnahme, solange `connectedRooms` leer ist. +- [ ] **Step 4: Tests laufen lassen.** Gleicher Befehl → PASS (6 + 2 Tests). +- [ ] **Step 5: Commit.** `git add src/tick.js src/main.js scripts/jetzt.mjs test/tick.test.js test/main.test.js && git commit -m "P1-T26: Tick, Dienststart und Abnahme-Skript" -m "Co-Authored-By: Claude Opus 5.5 "` + +--- + +### 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.** + ```js + const read = (p) => readFileSync(new URL(`../${p}`, import.meta.url), 'utf8') + test('Compose-Block: gehärtet nach Übersicht §9, nicht öffentlich, ohne Geheimnisse', () => { + const c = read('deploy/compose-workers.yml') + for (const l of ['container_name: webklar-workers', 'image: webklar-workers:latest', 'build: ./workers', 'restart: unless-stopped', 'init: true', 'mem_limit: 768m', 'memswap_limit: 768m', 'cpus: 1.0', 'user: node', 'read_only: true', '- /tmp', '- ALL', 'no-new-privileges:true', + 'max-size: "5m"', 'max-file: "2"', '- TZ=Europe/Berlin', '- NODE_OPTIONS=--max-old-space-size=384', '- APPWRITE_ENDPOINT=http://webklar-appwrite/v1', '- APPWRITE_PROJECT_ID=6a1058610003c5a13a05', + '- APPWRITE_API_KEY=${WORKERS_APPWRITE_API_KEY:-}', '- ANTHROPIC_API_KEY=${WORKERS_ANTHROPIC_API_KEY:-}', '- WORKERS_REPORT_SMTP_PASS=${WORKERS_REPORT_SMTP_PASS:-}', '- WORKERS_UNSUB_BASE=https://entwurf.project.webklar.com/abmelden', 'workers_data:/data', '- webklar-network']) assert.ok(c.includes(l), l) + assert.ok(!/^\s*ports:/m.test(c)); assert.ok(!/traefik/i.test(c)); assert.ok(!/sk-ant|=\s*[A-Za-z0-9]{30,}/.test(c)) + }) + test('nginx-Block: /api/workers/ → webklar-workers:8092 mit Resolver (wie /api/serverpuls/)', () => { + const n = read('deploy/nginx-workers.conf'); for (const l of ['location /api/workers/ {', 'resolver 127.0.0.11 valid=30s ipv6=off;', 'set $workers_upstream webklar-workers;', 'proxy_pass http://$workers_upstream:8092;', 'client_max_body_size 64k;']) assert.ok(n.includes(l), l) + }) + test('Dockerfile: node:22-alpine, npm ci --omit=dev, läuft als node, kein Chromium (kommt mit P4)', () => { + const d = read('Dockerfile'); assert.match(d, /^FROM node:22-alpine$/m); assert.match(d, /npm ci --omit=dev/); assert.match(d, /^USER node$/m); assert.match(d, /chown node:node \/data/); assert.match(d, /^CMD \["node", "src\/main\.js"\]$/m); assert.ok(!/chromium/.test(d.replace(/#.*$/gm, ''))) + }) + test('Serverpuls-Eintrag', () => assert.deepEqual(JSON.parse(read('deploy/serverpuls-apps-eintrag.json')), { app: 'Workers', match: ['webklar-workers*'], folders: ['workers'] })) + ``` +- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/deploy.test.js` → FAIL `ENOENT … deploy/compose-workers.yml`. +- [ ] **Step 3: Bausteine anlegen** (wörtlicher Inhalt): + `Dockerfile`: `FROM node:22-alpine` · `ENV NODE_ENV=production` · `WORKDIR /app` · `COPY package.json package-lock.json ./` · `RUN npm ci --omit=dev && npm cache clean --force` · `COPY src ./src` · `COPY scripts ./scripts` · `RUN mkdir -p /data && chown node:node /data` · Kommentar `# Chromium (apk add chromium) kommt erst mit P4 (Paul)` · `USER node` · `EXPOSE 8092` · `CMD ["node", "src/main.js"]`. `.dockerignore`: `node_modules`, `test`, `.git`, `.env`, `deploy`, `docs`. + `deploy/compose-workers.yml` (Einrückung wie unter `services:` in `/home/webklar/apps/docker-compose.yml`, 2 Leerzeichen): + ```yaml + workers: + build: ./workers + image: webklar-workers:latest + container_name: webklar-workers + restart: unless-stopped + init: true + mem_limit: 768m + memswap_limit: 768m + cpus: 1.0 + user: node + read_only: true + tmpfs: + - /tmp + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + logging: + driver: json-file + options: + max-size: "5m" + max-file: "2" + environment: + - TZ=Europe/Berlin + - NODE_OPTIONS=--max-old-space-size=384 + - APPWRITE_ENDPOINT=http://webklar-appwrite/v1 + - APPWRITE_PROJECT_ID=6a1058610003c5a13a05 + - APPWRITE_API_KEY=${WORKERS_APPWRITE_API_KEY:-} + - ANTHROPIC_API_KEY=${WORKERS_ANTHROPIC_API_KEY:-} + - WORKERS_SEARCH_MODEL=${WORKERS_SEARCH_MODEL:-} + - WORKERS_REPORT_SMTP_HOST=${WORKERS_REPORT_SMTP_HOST:-} + - WORKERS_REPORT_SMTP_PORT=${WORKERS_REPORT_SMTP_PORT:-} + - WORKERS_REPORT_SMTP_USER=${WORKERS_REPORT_SMTP_USER:-} + - WORKERS_REPORT_SMTP_PASS=${WORKERS_REPORT_SMTP_PASS:-} + - WORKERS_REPORT_SMTP_FROM=${WORKERS_REPORT_SMTP_FROM:-} + # ab P3/P4/P6 genutzt, hier leer vorbereitet (ein Eingriff in die Compose-Datei genügt) + - WORKERS_SMTP_HOST=${WORKERS_SMTP_HOST:-} + - WORKERS_SMTP_PORT=${WORKERS_SMTP_PORT:-} + - WORKERS_SMTP_USER=${WORKERS_SMTP_USER:-} + - WORKERS_SMTP_PASS=${WORKERS_SMTP_PASS:-} + - WORKERS_SMTP_FROM=${WORKERS_SMTP_FROM:-} + - WORKERS_IMAP_HOST=${WORKERS_IMAP_HOST:-} + - WORKERS_IMAP_PORT=${WORKERS_IMAP_PORT:-} + - WORKERS_IMAP_USER=${WORKERS_IMAP_USER:-} + - WORKERS_IMAP_PASS=${WORKERS_IMAP_PASS:-} + - WORKERS_ALERT_IMAP_HOST=${WORKERS_ALERT_IMAP_HOST:-} + - WORKERS_ALERT_IMAP_PORT=${WORKERS_ALERT_IMAP_PORT:-} + - WORKERS_ALERT_IMAP_USER=${WORKERS_ALERT_IMAP_USER:-} + - WORKERS_ALERT_IMAP_PASS=${WORKERS_ALERT_IMAP_PASS:-} + - WORKERS_PLACES_API_KEY=${WORKERS_PLACES_API_KEY:-} + - WORKERS_UNSUB_BASE=https://entwurf.project.webklar.com/abmelden + - WORKERS_UNSUB_SECRET=${WORKERS_UNSUB_SECRET:-} + volumes: + - workers_data:/data + networks: + - webklar-network + ``` + dazu im Kommentar der Datei die Zeile `# Unter "volumes:" ergänzen: workers_data:`. `deploy/nginx-workers.conf`: + ```nginx + # Workers (Kundenfindung, nur Mitarbeiter) - Same-Origin Proxy + location /api/workers/ { + resolver 127.0.0.11 valid=30s ipv6=off; + set $workers_upstream webklar-workers; + proxy_pass http://$workers_upstream:8092; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 60s; + client_max_body_size 64k; + } + ``` + `deploy/serverpuls-apps-eintrag.json`: `{ "app": "Workers", "match": ["webklar-workers*"], "folders": ["workers"] }`. +- [ ] **Step 4: Tests laufen lassen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/deploy.test.js` → PASS (4 Tests); danach der Gesamtlauf `timeout 300 node --max-old-space-size=1024 --test test/*.test.js` → alle Tests grün, 0 fail. +- [ ] **Step 5: Commit.** `git add Dockerfile .dockerignore deploy/compose-workers.yml deploy/nginx-workers.conf deploy/serverpuls-apps-eintrag.json test/deploy.test.js && git commit -m "P1-T27: Container-, nginx- und Serverpuls-Bausteine" -m "Co-Authored-By: Claude Opus 5.5 "` + +--- + +## Betriebsanleitung (Kenso) + +Reihenfolge einhalten. **[Claude]** = Schritt, den der ausführende Agent mit dem Gitea-MCP erledigt; **[Kenso]** = deine Eingabe auf dem Server. Geheimnisse nie in den Chat und nie in Befehle schreiben, die sie ausgeben; `ANTHROPIC_API_KEY` nie in der Shell von root exportieren. Alle Skripte laufen auf dem Host aus dem Dev-Checkout (Node 24, `node_modules` aus T01) und bekommen den Schlüssel nur für diesen einen Befehl (Subshell). + +1. **[Claude] Repo anlegen.** Gitea-MCP `create_repo` mit `name: "workers-dienst"`, `private: true`, `auto_init: false`, `default_branch: "main"`, **ohne** Organisation (Besitzer `knso`, nicht Org WEBklar: deren Webhook würde jeden Push als Kundenprojekt in WOMS anlegen). *Prüfen:* `list_my_repos` zeigt `knso/workers-dienst` als privat. +2. **[Kenso] Sicherungen.** + ``` + cd /home/webklar/apps + cp .env .env.bak-$(date +%F)-workers + cp docker-compose.yml docker-compose.yml.bak-$(date +%F)-workers + cp ticket.webklar.com/nginx/ticket.webklar.com.conf ticket.webklar.com/nginx/ticket.webklar.com.conf.bak-$(date +%F)-workers + ``` + *Prüfen:* `ls -l .env.bak-*-workers docker-compose.yml.bak-*-workers ticket.webklar.com/nginx/*.bak-*-workers` zeigt drei Dateien von heute. +3. **[Kenso] Appwrite-Schlüssel.** Appwrite-Konsole → Projekt `6a1058610003c5a13a05` → Integrationen → API-Schlüssel → Name `workers`, kein Ablauf, Bereiche `databases.read/write`, `collections.read/write`, `attributes.read/write`, `indexes.read/write`, `documents.read/write`. Dann: + `read -rs -p 'Appwrite-Key workers: ' K; echo; printf 'WORKERS_APPWRITE_API_KEY=%s\n' "$K" >> /home/webklar/apps/.env; unset K` + *Prüfen:* `command grep -c '^WORKERS_APPWRITE_API_KEY=.' /home/webklar/apps/.env` → `1`. +4. **[Kenso] Anthropic.** Console: Workspace „Workers“ anlegen, dort Ausgabenlimit 250 $/Monat setzen und einen API-Schlüssel erzeugen (Spec §12). Dann `read -rs -p 'Anthropic-Key Workers: ' K; echo; printf 'WORKERS_ANTHROPIC_API_KEY=%s\n' "$K" >> /home/webklar/apps/.env; unset K`. *Prüfen:* `command grep -c '^WORKERS_ANTHROPIC_API_KEY=.' /home/webklar/apps/.env` → `1`. +5. **[Kenso] Berichtskonto** (intern, kein Kaltkontakt; Passwort wird aus Serverpuls übernommen, ohne es zu zeigen): + ``` + cd /home/webklar/apps + printf 'WORKERS_REPORT_SMTP_HOST=mail.your-server.de\nWORKERS_REPORT_SMTP_PORT=587\nWORKERS_REPORT_SMTP_USER=server@webklar.com\nWORKERS_REPORT_SMTP_FROM=server@webklar.com\nWORKERS_REPORT_SMTP_PASS=%s\n' "$(sed -n 's/^SERVERPULS_SMTP_PASS=//p' .env | tail -n1)" >> .env + ``` + *Prüfen:* `command grep -c '^WORKERS_REPORT_SMTP_' .env` → `5`. Der Bericht geht an `kenso.gri@gmail.com` (Standard von `reportEmail`); anderer Empfänger später per `PUT /settings` oder im WOMS-Dialog (P2). +6. **[Kenso] Push und Produktions-Checkout.** + ``` + cd /home/webklar/apps/workers-dev + git remote add origin https://git.webklar.com/knso/workers-dienst.git # Anmeldung wie bei deinen anderen Gitea-Repos (Token) + git push -u origin p1-fundament:main + git clone --branch main https://git.webklar.com/knso/workers-dienst.git /home/webklar/apps/workers + ``` + *Prüfen:* `git ls-remote origin main | cut -c1-12` und `git rev-parse HEAD | cut -c1-12` sind gleich; `ls /home/webklar/apps/workers/Dockerfile` existiert. (Scheitert die Anmeldung beim Klonen: `git clone --branch p1-fundament /home/webklar/apps/workers-dev /home/webklar/apps/workers`.) +7. **[Kenso] Sammlungen anlegen (erst prüfen).** + ``` + cd /home/webklar/apps/workers-dev + ( export APPWRITE_API_KEY="$(sed -n 's/^WORKERS_APPWRITE_API_KEY=//p' /home/webklar/apps/.env | tail -n1)"; timeout 900 node --max-old-space-size=1024 scripts/setup-collections.mjs --check; echo "Exit $?" ) + ``` + *Erwartet:* je Sammlung Zeilen mit `FEHLT`, `Exit 1`. Dann derselbe Befehl **ohne** `--check` (Ende `Fertig.`; es sind rund 180 Attribute, Appwrite legt sie nacheinander an, das dauert einige Minuten; bei einem Abbruch durch `timeout` den Befehl einfach wiederholen, er zieht Fehlendes nach), danach wieder mit `--check` → `Alles vorhanden.`, `Exit 0`. Erscheint „Row width limit reached“, in `src/schema.js` die Größe des genannten Felds auf `TEXT_SIZE` setzen und neu laufen lassen (Skript ist idempotent). +8. **[Kenso] Modell-Prüfung (manuell, kein Test).** + `( ANTHROPIC_API_KEY="$(sed -n 's/^WORKERS_ANTHROPIC_API_KEY=//p' /home/webklar/apps/.env | tail -n1)" timeout 120 node --max-old-space-size=1024 scripts/check-modelle.mjs; echo "Exit $?" )` + *Erwartet:* Haiku-Zeile „web_search: unterstützt“, `Exit 0`. Meldet das Skript den Fallback, die Zeile `WORKERS_SEARCH_MODEL=claude-sonnet-5-5` an `/home/webklar/apps/.env` anhängen (die Compose-Datei reicht sie schon durch); `Exit 2` heißt „unklar“ (die Modell-Auskunft nennt Websuche vermutlich gar nicht): denselben Befehl mit `--probe` wiederholen (eine echte Probe-Suche, etwa 0,02 $) und dessen Ergebnis wie oben behandeln; `Exit 1`: Suche ist mit diesen Modellen nicht möglich, P3 nicht starten. +9. **[Kenso] Startbestand.** Erst `--dry-run`: + `( export APPWRITE_API_KEY="$(sed -n 's/^WORKERS_APPWRITE_API_KEY=//p' /home/webklar/apps/.env | tail -n1)"; timeout 900 node --max-old-space-size=1024 scripts/seed-register.mjs --dry-run )` + *Erwartet:* `TROCKENLAUF …`, `Alte Leads: 95 (blockiert: 2)`, `Kunden: ` (soll zur WOMS-Kundenliste inkl. Abgesagt/Archiviert passen), `Schlüssel neu: N · schon vorhanden: M`. Stimmt das, derselbe Befehl ohne `--dry-run`; ein zweiter Lauf meldet `Schlüssel neu: 0`. *Prüfen:* Appwrite-Konsole → `workersKeys` hat N Dokumente. Meldet dir jemand eine Antwort/einen Widerspruch eines der 95 alten Lackierer, sag Claude Bescheid (Eintrag in die Sperrliste, Spec §12). +10. **[Kenso] Container starten.** + ``` + cd /home/webklar/apps + # Inhalt von /home/webklar/apps/workers/deploy/compose-workers.yml unter "services:" einfügen (z. B. direkt nach dem Block "serverpuls:"), + # und unter dem Top-Level-"volumes:" die Zeile " workers_data:" ergänzen + docker compose config -q && echo KONFIG-OK + free -m | sed -n 2p + docker compose build workers + docker compose up -d --no-deps workers + ``` + *Prüfen:* `docker logs --tail 20 webklar-workers` enthält `workers listening on 8092` und keine Zeile mit dem Wort „fehlt“; `docker stats --no-stream webklar-workers` unter 768 MiB (erwartet < 150 MiB); `docker exec webklar-ticket-integrations wget -qO- http://webklar-workers:8092/api/workers/health` → `{"ok":true,"version":"0.1.0"}`. Nie ein nacktes `docker compose up -d` (startet Gestopptes). +11. **[Kenso] nginx.** In `/home/webklar/apps/ticket.webklar.com/nginx/ticket.webklar.com.conf` den Inhalt von `deploy/nginx-workers.conf` direkt nach dem Serverpuls-Block (`location /api/serverpuls/ { … }`) einfügen, dann `docker exec webklar-ticket nginx -t && docker exec webklar-ticket nginx -s reload`. *Prüfen:* `curl -s https://ticket.webklar.com/api/workers/health` → `{"ok":true,"version":"0.1.0"}`; `curl -s -o /dev/null -w '%{http_code}\n' https://ticket.webklar.com/api/workers/state` → `401`. +12. **[Kenso] Abnahme Bericht und Nachtplan.** Nach 06:30 Berlin holt der erste Tick den heutigen Bericht sofort nach: innerhalb von 2 Minuten nach dem Start kommt `[Workers] Nachtbericht TT.MM.JJJJ` bei `kenso.gri@gmail.com` an (auch Spam prüfen). Vor 06:30 oder zur Wiederholung: + `timeout 120 docker exec webklar-workers node scripts/jetzt.mjs bericht` und `timeout 120 docker exec webklar-workers node scripts/jetzt.mjs nachtplan` (nur solange `connectedRooms` leer ist). *Erwartet:* die Mail; `nachtplan` druckt `{"planned":[],"skipped":"keine_raeume"}` und erzeugt ein `leerlauf`-Ereignis von Otto in `workersEvents`. Meldet der Bericht `smtp_auth` im Ereignis „Morgenbericht nicht gesendet“: **nicht** wiederholen (der Dienst versucht es erst morgen wieder), Passwort prüfen – wiederholte falsche Logins sperren die Server-IP bei Hetzner. +13. **[Kenso] Abnahme API im Browser** (eingeloggt auf `https://ticket.webklar.com`, Entwicklerkonsole): + ```js + const j = await (await fetch('/v1/account/jwts', { method: 'POST', credentials: 'include', headers: { 'X-Appwrite-Project': '6a1058610003c5a13a05' } })).json() + for (const p of ['state', 'events?limit=5', 'settings']) { const r = await fetch('/api/workers/' + p, { headers: { 'X-Appwrite-JWT': j.jwt } }); console.log(p, r.status, await r.json()) } + ``` + *Erwartet:* `state` 200 mit 19 Workern (nur Otto nicht grau), `events` 200 mit dem `leerlauf`-Ereignis, `settings` 200 (du bist Admin). Schlägt schon `/v1/account/jwts` fehl, genügt der Test mit der WOMS-Oberfläche in P2. Antwortet die API trotz Login mit `502 Anmeldedienst nicht erreichbar.` oder `401 Sitzung abgelaufen.`, im Compose-Block `APPWRITE_ENDPOINT=https://appwrite.webklar.com/v1` setzen (wie Serverpuls; der interne Endpunkt hat die JWT-Prüfung nicht bestanden) und `docker compose up -d --no-deps workers` wiederholen. +14. **[Kenso] Serverpuls kennt die App.** Eintrag aus `deploy/serverpuls-apps-eintrag.json` in `/home/webklar/apps/serverpuls/config/apps.json` ergänzen (neben „Traefik“), dann + `cd /home/webklar/apps/serverpuls && git add config/apps.json && git commit -m "apps.json: Workers" && cd /home/webklar/apps && docker compose build serverpuls && docker compose up -d --no-deps serverpuls`. *Prüfen:* `docker logs --tail 5 webklar-serverpuls` ohne Fehler; nach dem nächsten Größenlauf (03:00 Berlin) steht „Workers“ in der App-Tabelle der Serverpuls-Seite. + +**Update später:** `cd /home/webklar/apps/workers && git pull --ff-only && cd .. && docker compose build workers && docker compose up -d --no-deps workers`. +**Rückbau:** `docker compose stop workers`; nginx-Sicherung zurückkopieren und `nginx -s reload`; die Daten in Appwrite bleiben (nichts wird gelöscht). +**Fertig, wenn:** Schritte 7, 9, 10, 11, 12 ihre „Erwartet“-Zeilen erfüllen und ab dem nächsten Morgen um 06:30 der Nachtbericht automatisch kommt. In WOMS ist noch nichts zu sehen (kommt mit P2). + diff --git a/docs/superpowers/plans/2026-10-17-workers-p2-hochhaus-woms.md b/docs/superpowers/plans/2026-10-17-workers-p2-hochhaus-woms.md new file mode 100644 index 0000000..4c5fb0e --- /dev/null +++ b/docs/superpowers/plans/2026-10-17-workers-p2-hochhaus-woms.md @@ -0,0 +1,1482 @@ +# Workers P2 – Hochhaus in WOMS – 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:** WOMS bekommt unter `/workers` das Pixel-Hochhaus: 10 Etagen, 19 Worker, echte Ereignisse als Animation, Kennzahlen, Worker-Karte mit Spur, Pausenschalter für alle Mitarbeiter und Einstellungen für Admins. + +**Architecture:** Eine Plain-Canvas-2D-Engine (ES-Module unter `src/components/workers/engine/`, Layout als Daten, Regie über reine Schritte aus `eventToSteps`) wird vom React-Teil nur eingehängt: `useWorkersFeed` holt Zustand (15 s) und Ereignisse (5 s) über die Dienst-API (Übersicht §6.2) und reicht sie an `mountTower` weiter; Klick auf eine Figur ruft `onSelect(workerId)`, React lädt die Spur. Die Seite wird per `React.lazy` geladen, damit Engine und Schrift nicht im Haupt-Bundle liegen. + +**Tech Stack:** React 18, Vite 5, Canvas 2D (kein PixiJS), `node:test` + esbuild-`loadModule`, `node:vm` für den Engine-Rauchtest, Playwright (headless shell) nur als manuelle Abnahme, Pixelify Sans (SIL OFL) selbst gehostet. + +**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. +- Pfad `WK=/home/webklar/apps/ticket.webklar.com/dev-workers` (Worktree, Branch `feature/workers`); Agenten pushen nie, Live nur durch Kenso (`git push origin feature/workers:test`); `git add` nur mit Dateinamen; Commit-Nachrichten enden mit `Co-Authored-By: Claude Opus 5.5 `. +- Testbefehl je Datei: `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/.test.mjs`; Gesamtlauf: `cd $WK && timeout 600 node --max-old-space-size=1024 --test tests/*.test.mjs tests/unit/*.test.mjs`; vor schweren Läufen `free -m`. +- Stil wie WOMS: ES-Module, keine Semikolons, einfache Anführungszeichen, deutsche Kommentare, UI-Texte deutsch mit Umlauten, keine Emojis; Dateien unter `src/lib/` und `engine/` importieren weder React noch Appwrite und greifen beim Import nicht auf `window`/`document` zu (laden mit `loadModule()` aus `tests/unit/load.mjs`). +- Keine neue npm-Abhängigkeit (kein PixiJS, kein Test-Framework); `package.json` bleibt unverändert. +- Welt 320 × 256 logische Pixel (5:4); Turm x 14..230, Räume ab x 42 (186 breit), Etage 22 hoch plus 1 Bodenlinie (Raster 23), Brücke 34 hoch, 10 Etagen in `FLOORS`-Reihenfolge, 19 Worker. +- Takt: höchstens 30 Bilder/s (`FRAME_MS` 33), `dt` ≤ 0,05 s, höchstens 3 Sprechblasen gleichzeitig (≤ 20 Zeichen), höchstens 80 gleichzeitige Effekte, Canvas-Breite ≤ 2400 Geräte-Pixel. +- Rückstau: je `pushEvents`-Aufruf animieren nur die neuesten 6 Ereignisse (`BACKLOG_KEEP`), ältere wirken still (nur Aufgabe/Pose); je Worker höchstens 4 wartende Gruppen; ein Schritt höchstens 45 s (`STEP_TIMEOUT_S`). +- Polling: Zustand alle 15 000 ms, Ereignisse alle 5 000 ms (`limit=200`, höchstens 5 Seiten je Runde), Spur `limit=50`; nichts bei `document.visibilityState === 'hidden'`, sofortiger Abruf beim Zurückkehren; JWT 10 min gecacht (`createJwtCache`), Header `X-Appwrite-JWT`, Basis `/api/workers`. +- Rechte: Pausenschalter für alle Mitarbeiter; `WorkersSettingsDialog` nur für `isAdmin`; `mailColdPerDay` ≤ 3 (Spec §8.1), `mailReplyCapPerDay` ≤ 15; der Dialog sendet nie `paused`. +- Weißes Theme (Konzept 2026-10-04): keine Karten/Kästen, nur Haarlinien `var(--line)`, nur Tokens aus `global.css` (keine Hex-Farben in `workers.css`), Klassen mit Präfix `wk-`, Zahlen `tabular-nums`, Breakpoints 1100 px und 860 px, „ד statt Icons. +- Schrift: Pixelify Sans (SIL OFL 1.1) als `public/fonts/PixelifySans-Variable.woff2` (Gewichte 400–700) plus Lizenztext `public/fonts/OFL-PixelifySans.txt`, `font-display: swap`, kein Google-Fonts-Link; Download nur in P2-T20, in einen neuen leeren Ordner, Prüfung der Magic Bytes `wOF2`. +- Bewegung und Lebenszyklus: `prefers-reduced-motion` ⇒ keine Schleife, Standbild je Änderung; `visibilitychange` ⇒ `setPaused`; `mountTower`/`destroy` idempotent (React StrictMode: mount, destroy, mount). +- Playwright/Chromium nur seriell unter `systemd-run --scope -q -p MemoryMax=700M -p MemorySwapMax=0`, vite-Build unter `MemoryMax=1500M` mit `NODE_OPTIONS=--max-old-space-size=1200`; Ausgabeordner immer neu unter `$TMPDIR`. +- Keine `.env`-, Compose- oder nginx-Änderung in P2 (der `/api/workers/`-Block gehört zu P1); P2 schreibt nie nach Appwrite. + +> **Ergänzung zur Übersicht (nur P2; vor Start in `…-00-uebersicht.md` §7 nachtragen)** +> - **E1 Fixture-Form** `tests/fixtures/workers-roster.json`: `{ "floors": [<10 Etagen-Ids>], "roles": [{ "workerId", "name", "floor", "room", "model", "role", "caps" }] }`, 19 Einträge in Reihenfolge der Übersicht §3, `model` = `opus|sonnet|haiku`, `role` = Spalte „Einzige Rolle“ wörtlich, `caps` = Spalte „Erlaubte Fähigkeiten“ (bei „wie ali“ aufgelöst), 2 Leerzeichen Einrückung, Zeilenumbruch am Ende. P1 legt dieselbe Datei byte-gleich ab (existiert sie dort schon, wird sie byte-identisch kopiert). +> - **E2 JWT:** `getJwt(opts?: { fresh?: boolean }) → Promise`; bei 401 ruft `createWorkersApi` einmal `getJwt({ fresh: true })`. Neu `src/lib/workersClient.js` exportiert den verdrahteten `workersApi`. +> - **E3 Zusätzliche Dateien:** `src/lib/workersFeed.js`, `src/lib/workersSettingsForm.js`, `src/components/workers/{WorkersBoard,WorkerList}.jsx`, `engine/{camera,rooms}.js`, Prüfstand `scripts/workers-harness/`. +> - **E4 `mountTower(canvas, opts)`:** `opts = { onSelect, reducedMotion?, coarsePointer?, fontsReady?, dpr?, raf?, caf?, now? }`; der Handle hat zusätzlich `resize()`, `zoomBy(faktor)`, `resetView()`, `locate(workerId)`, `stats()`, `debug()`. +> - **E5 API-Formate, die §6.2 offenlässt:** `state.workers[].counters` und `trace.today.counters` = Objekt `{ : Zahl }`; `trace.today.tokens` = Zahl oder `{ in, out }` (P1 liefert `{ in, out }`); `trace.worker` = ROLES-Eintrag plus `modelId` (voller Modellname, P1-E2; fehlt er, nimmt WOMS `MODEL_IDS[model]`); `trace.events` in beliebiger Reihenfolge (P1: neueste zuerst; WOMS sortiert selbst absteigend); `connectedRooms` setzt allein der Dienst (P3-T08); WOMS zeigt es nur an und sendet es nie (die Prüfung P1-T07 würde es annehmen, deshalb steht es in `READONLY_KEYS`); `link` = `https://…`, `http://…` oder interner Pfad `/…`; fehlt ein Worker in `state.workers`, gilt er als nicht angeschlossen. +> - **E6 Ereignis-`meta`, das WOMS liest (alle optional):** `count` (`gelesen`), `score` (`bewertet`, Zahl 0–100, neu: P3/P6 sollen es senden), `classification` (`sortiert`), `round` (`pruefung_zurueck`), `paused` (`pause`), `by` (`gesendet`). +> - **E7 Abgrenzung und Reiter:** Das Tagesziel („3 Erstkontakte, 1 Vorschau“, Spec §5) braucht Felder im `state`, die §6.2 nicht liefert, und fehlt deshalb in P2. `WorkersPage.jsx` hält `WORKERS_TABS` mit dem einen Reiter `hochhaus` sowie die `api`-Instanz (`const api = workersApi`); es gibt **keinen** Platzhalter-Reiter: P3 hängt `freigabe` an (P3-E8), P6 `botleads` (Tab-Ids: `hochhaus`, `freigabe`, `botleads`; Links wie `/workers?tab=botleads&bot=` laufen als interne `Link`; ein unbekannter `?tab=` fällt auf `hochhaus` zurück). Die Reiterleiste (`Tabs`) erscheint erst ab zwei Reitern. Der Einstellungs-Dialog ändert `connectedRooms` nie. + +## Review Focus + +1. Dienst nicht erreichbar oder nginx-Block fehlt (HTML-Seite mit Status 200, 502, Netzfehler) → deutsche Meldung „Der Workers-Dienst antwortet nicht richtig.“ bzw. „… ist gerade nicht erreichbar.“ statt Absturz oder `NaN`; die Seite bleibt bedienbar und fragt weiter ab. Tests: P2-T02 (`kpiTiles` ohne Daten), P2-T04 (Fehlertexte), P2-T19 (Board-Zustände). +2. Tab lange im Hintergrund, danach 200+ Ereignisse auf einmal → keine Animationsflut: höchstens 6 Gruppen laufen, der Rest wirkt still, alle Warteschlangen sind nach ≤ 60 s Simulationszeit leer; der Feed holt seitenweise (200, höchstens 5 je Runde). Tests: P2-T05, P2-T13. +3. Dasselbe Ereignis kommt in zwei Abfragen (gleicher Zeitstempel am Cursor) und die Vorgeschichte vor dem Öffnen der Seite → nie doppelt abgespielt, Vorgeschichte nicht nachgespielt. Tests: P2-T03 (`takeNewEvents`), P2-T05 (Cursor aus dem Snapshot). +4. Raum ohne Quelle, Worker fehlt im Zustand, unbekannte Ereignisart oder unbekannte `workerId` → grauer Raum mit Schild „noch nicht angeschlossen“, schlafende Figur, keine vorgespielte Arbeit, kein Wurf. Tests: P2-T03, P2-T11, P2-T12, P2-T13. +5. `link` im Ereignisstrom mit `javascript:`, `data:` oder `//fremder.host` → nur Text, nie ein `href`; externe Links mit `rel="noopener noreferrer"`. Tests: P2-T02 (`safeLink`), P2-T17 (Quelltext-Test der Karte). + +## File Structure + +Alles unter `$WK`. **Neu:** + +| Datei | Verantwortung | +|---|---| +| `src/lib/workersRoster.js` | Spiegel von Übersicht §3: `ROLES`, `FLOORS`, `FLOOR_TITLES`, `ROOM_TITLES`, `MODEL_IDS`, `hatForModel`, `hatForWorker`, `roleOf` | +| `tests/fixtures/workers-roster.json` | gemeinsame Fixture (E1) | +| `src/lib/workersView.js` | reine Anzeige-Ableitungen (Formate, Status, Kacheln, Zähler, `safeLink`, Spur-Zeilen, Reiterwahl) | +| `src/lib/workersEvents.js` | `EVENT_KINDS`, Deduplizierung, `reduceEvents`, `eventToSteps`, `needsStateRefresh` | +| `src/lib/workersApi.js` · `src/lib/workersClient.js` | API-Client mit Fehlertexten (fetch injiziert) · Verdrahtung mit Appwrite-JWT | +| `src/lib/workersFeed.js` · `src/hooks/useWorkersFeed.js` | Polling-Steuerung (Timer injiziert, testbar) · React-Hooks `useWorkersFeed`, `useWorkerTrace` | +| `src/lib/workersSettingsForm.js` | Formularlogik der Einstellungen (Umwandlung, Prüfung, Patch) | +| `engine/sprites.js` | Raster `setGrid/px`, Palette, Figuren mit Posen/Grau, Text, Sprechblasen (Quelle `entwuerfe/sprites.js` Z. 7–142, 277–307) | +| `engine/props.js` | Möbel/Requisiten und 7×7-Branchen-Icons (Quelle `sprites.js` Z. 146–273) | +| `engine/layout.js` · `engine/camera.js` | Etagen/Räume/Plätze als Daten · Kamera, Zoom, Umkehr-Transformation, Treffer-Auswahl | +| `engine/fx.js` · `engine/lift.js` | Effekt-System · Aufzug (Quelle `scene-turm.js` Z. 366–393, 316–330, 354–492) | +| `engine/rooms.js` · `engine/building.js` | Raumzeichnung nach `kind`, Pokal-Seiten · Gebäudehülle, Schacht, Etagenschilder, Pausenschleier (Quelle `scene-turm.js` Z. 99–314) | +| `engine/director.js` | Agenten, Snapshot ohne Animation, Schritt-Regie, Rückstau-Kappung, Leerlauf, Wachhund (Quelle `scene-turm.js` Z. 416–464, 864–905) | +| `engine/scene.js` | `mountTower`: Größe, Schleife, Eingabe, Kamera, Lebenszyklus | +| `src/components/workers/{WorkersKpis,WorkerCard,WorkerList,WorkersSettingsDialog,WorkersStage,WorkersBoard}.jsx` | UI-Teile (Board = Inhalt des Reiters „Hochhaus“, ohne Auth, damit der Prüfstand ihn mounten kann) | +| `src/pages/WorkersPage.jsx` · `src/styles/workers.css` | Seite mit `WORKERS_TABS` (zunächst nur `hochhaus`) und der `api`-Instanz · Stil (`wk-`, `@font-face`) | +| `public/fonts/PixelifySans-Variable.woff2` · `public/fonts/OFL-PixelifySans.txt` | Schrift und Lizenz | +| `scripts/workers-harness/{build.mjs,entry.jsx,index.html,scripted-api.js,shot.cjs}` | Prüfstand für die manuelle Playwright-Abnahme | +| `tests/unit/helpers/{fakeCtx,workersFixtures,engineVm}.mjs` | Test-Hilfen (aufzeichnender Kontext, 19-Worker-Zustand, vm-Sandbox) | +| `tests/unit/workers{Roster,View,Events,Api,Feed,Sprites,Props,Layout,Camera,Fx,Lift,Rooms,Director,Scene,Smoke,SettingsForm,Components,Css,Font,Route,Harness}.test.mjs` | je ein Test pro Baustein | + +**Ändern:** `src/App.jsx` (lazy Route, `/leads` → `/workers`), `src/lib/navLinks.js` (Eintrag `workers`), `tests/unit/navLinks.test.mjs`. +**Kein Offscreen-Cache** für die Kulisse (Entscheidung: erst messen; `stats().avgDrawMs` wird in P2-T21 geprüft). + +--- + +### P2-T01: Roster-Spiegel und gemeinsame Fixture + +**Files:** +- Create: `src/lib/workersRoster.js`, `tests/fixtures/workers-roster.json` +- Test: `tests/unit/workersRoster.test.mjs` + +**Interfaces:** +- Consumes: Übersicht §3 (Tabelle), E1. +- Produces: + ```js + export const FLOORS // ['bruecke','anfragen','ohne_website','bewertung','gespraeche','werkstatt','pruefung','freigabe','tresor','keller'] + export const ROLES // 19 × { workerId, name, floor, room, model:'opus'|'sonnet'|'haiku', role, caps } + export const MODEL_IDS // { opus:'claude-opus-5-5', sonnet:'claude-sonnet-5-5', haiku:'claude-haiku-5-5' } + export const FLOOR_TITLES // id → 'Kommandobrücke' | 'Anfragen' | 'Ohne Website' | 'Bewertung' | 'Gespräche' | 'Werkstatt' | 'Prüfung' | 'Freigabe' | 'Tresor' | 'Keller' + export const ROOM_TITLES // 21 Räume (19 Worker-Räume + 'pult','tresor') → Titel, Werte siehe Schritt 3 + export function hatForModel(model): 'opus'|'sonnet'|'haiku' // Kurzname oder voller Modellname, sonst 'haiku' + export function hatForWorker(workerId): 'overseer'|'opus'|'sonnet'|'haiku' // otto → 'overseer' + export function roleOf(workerId): object | null + ``` + +- [ ] **Step 1: Failing test schreiben** (`tests/unit/workersRoster.test.mjs`) + ```js + import test from 'node:test' + import assert from 'node:assert/strict' + import { readFileSync } from 'node:fs' + import { loadModule } from './load.mjs' + const { ROLES, FLOORS, FLOOR_TITLES, ROOM_TITLES, MODEL_IDS, hatForModel, hatForWorker, roleOf } = await loadModule('src/lib/workersRoster.js') + const fixture = JSON.parse(readFileSync(new URL('../fixtures/workers-roster.json', import.meta.url), 'utf8')) + + test('Spiegel ist gleich der gemeinsamen Fixture', () => { + assert.deepEqual([...FLOORS], fixture.floors) + assert.deepEqual(JSON.parse(JSON.stringify(ROLES)), fixture.roles) + }) + test('19 Worker, workerId und Raum einmalig, Etagen und Titel vollständig', () => { + assert.equal(ROLES.length, 19) + assert.equal(new Set(ROLES.map((r) => r.workerId)).size, 19) + assert.equal(new Set(ROLES.map((r) => r.room)).size, 19) + for (const r of ROLES) assert.ok(FLOORS.includes(r.floor), r.workerId) + assert.equal(FLOORS.length, 10) + assert.deepEqual(Object.keys(FLOOR_TITLES).sort(), [...FLOORS].sort()) + assert.deepEqual(Object.keys(ROOM_TITLES).sort(), [...ROLES.map((r) => r.room), 'pult', 'tresor'].sort()) + }) + test('Hut zeigt das Modell, Otto trägt die Kommandomütze', () => { + assert.deepEqual(['opus', 'claude-sonnet-5-5', 'claude-haiku-5-5', 'claude-xyz', undefined].map(hatForModel), ['opus', 'sonnet', 'haiku', 'haiku', 'haiku']) + assert.deepEqual(['otto', 'bob', 'bea', 'kim', 'zed'].map(hatForWorker), ['overseer', 'opus', 'sonnet', 'haiku', 'haiku']) + assert.equal(roleOf('paul').room, 'pruefung') + assert.equal(roleOf('zed'), null) + assert.deepEqual(MODEL_IDS, { opus: 'claude-opus-5-5', sonnet: 'claude-sonnet-5-5', haiku: 'claude-haiku-5-5' }) + }) + ``` +- [ ] **Step 2: Test laufen lassen, Scheitern sehen** + Einmalig: `cd $WK && git status --short` (keine geänderten Dateien; `??`-Zeilen für noch nicht eingecheckte Plan-Dokumente unter `docs/superpowers/plans/` sind erlaubt) und `ln -s ../build/node_modules node_modules` (erscheint als `?? node_modules`, nie committen). + Run: `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersRoster.test.mjs` + Expected: FAIL, esbuild `Could not resolve …/src/lib/workersRoster.js`. +- [ ] **Step 3: Implementieren** in `src/lib/workersRoster.js` und `tests/fixtures/workers-roster.json` + Fixture und `ROLES` aus Übersicht §3 erzeugen (E1). Liegt `/home/webklar/apps/workers-dev/test/fixtures/workers-roster.json` (P1) schon vor, wird sie byte-identisch kopiert und `ROLES` danach ausgerichtet. `ROLES`/`FLOORS` mit `Object.freeze` (tief genug für `deepEqual` nach JSON-Rundlauf). `ROOM_TITLES`: netz „Netz-Radar“, kleinanzeigen „Kleinanzeigen“, portale „Portale“, foren „Foren & Fragen“, aerzte „Ärzte & Praxen“, handwerk „Handwerk“, selbst „Selbstständige“, laden „Läden & Gastro“, waage „Waage“, mail „Mail-Stube“, forum „Forum-Stube“, posteingang „Posteingang“, abschluss „Abschluss“, tisch1/2/3 „Tisch 1/2/3“, pruefung „Prüftisch“, archiv „Archiv & Register“, bruecke „Brücke“, pult „Dein Pult“, tresor „Tresor“. `hatForModel`: Treffer auf `/opus|sonnet|haiku/` im Text, sonst `'haiku'`. +- [ ] **Step 4: Test laufen lassen, Bestehen sehen** — gleicher Befehl wie Step 2. Expected: `# pass 3`, `# fail 0`. +- [ ] **Step 5: Commit** + ```bash + cd $WK && git add src/lib/workersRoster.js tests/fixtures/workers-roster.json tests/unit/workersRoster.test.mjs + git commit -m "Workers P2: Roster-Spiegel und gemeinsame Fixture" -m "Co-Authored-By: Claude Opus 5.5 " + ``` + +--- + +### P2-T02: Anzeige-Logik `workersView.js` + +**Files:** +- Create: `src/lib/workersView.js` +- Test: `tests/unit/workersView.test.mjs` + +**Interfaces:** +- Consumes: `workersRoster.js` (`MODEL_IDS`). +- Produces (Namen aus Übersicht §7 fett): + ```js + export function formatUsd(n): string // 7.84 → '7,84 $' (NBSP vor $), null/NaN/Text → '–' + export function formatEur(n): string // 120 → '120 €', 19.5 → '19,50 €' (NBSP) + export function formatCount(n): string // 1234 → '1.234', sonst '–' + export function formatTime(iso, now = Date.now()): string // Europe/Berlin; gleicher Tag 'HH:MM', sonst 'TT.MM. HH:MM'; ungültig '–' + export function workerStatusLabel(status): string + export function statusTone(status): 'green'|'orange'|'plain'|'red' + export function kpiTiles(kpis): [{ key, label, value, sub, tone }] // 6 Kacheln + export function counterLines(counters): [{ key, label, value }] + export function tokenTotal(tokens): number // Zahl oder { in, out } + export function safeLink(link): { href, external } | null + export function traceRows(events, now?): [{ id, time, text, link }] // neueste zuerst, höchstens 50 + export function pauseLabel(paused): string + export function pickTab(tabs, id): tab // unbekannt → erster Reiter + ``` + +- [ ] **Step 1: Failing test schreiben** (`tests/unit/workersView.test.mjs`, `const V = await loadModule('src/lib/workersView.js')`) + ```js + const NB = String.fromCharCode(160) // geschütztes Leerzeichen vor $ und € + const K = { newAbosMonth: 3, mrrNewEur: 60, dealsMonth: 3, openThreads: 7, pultOpen: 2, botLeads: 112, apiTodayUsd: 8.5, apiMonthUsd: 50, dailyBudgetUsd: 10, monthlyBudgetUsd: 250 } + test('Zahlenformate', () => { + assert.equal(V.formatUsd(7.84), `7,84${NB}$`); assert.equal(V.formatUsd(1234.5), `1.234,50${NB}$`); assert.equal(V.formatUsd(0), `0,00${NB}$`) + for (const bad of [null, undefined, NaN, 'x']) assert.equal(V.formatUsd(bad), '–') + assert.equal(V.formatEur(120), `120${NB}€`); assert.equal(V.formatEur(19.5), `19,50${NB}€`) + assert.equal(V.formatCount(1234), '1.234'); assert.equal(V.formatCount(null), '–') + }) + test('formatTime: Berlin-Zeit, heute nur Uhrzeit', () => { + const now = Date.parse('2026-10-17T10:00:00Z') + assert.equal(V.formatTime('2026-10-17T20:30:00Z', now), '22:30') + assert.equal(V.formatTime('2026-10-16T21:30:00Z', now), '16.10. 23:30') + assert.equal(V.formatTime('kaputt', now), '–'); assert.equal(V.formatTime(null, now), '–') + }) + test('Status: Texte und Töne', () => { + const s = ['arbeitet', 'wartet', 'leerlauf', 'grau', 'fehler', 'x'] + assert.deepEqual(s.map(V.workerStatusLabel), ['Arbeitet', 'Wartet auf Auftrag', 'Leerlauf', 'Noch nicht angeschlossen', 'Fehler', 'Unbekannt']) + assert.deepEqual(s.map(V.statusTone), ['green', 'orange', 'plain', 'plain', 'red', 'plain']) + }) + test('kpiTiles: sechs Kacheln, Töne nach 80-%-Regel', () => { + const t = V.kpiTiles(K) + assert.deepEqual(t.map((x) => x.key), ['abos', 'deals', 'threads', 'pult', 'botleads', 'api']) + assert.deepEqual(t.map((x) => x.label), ['Neue Abos (Monat)', 'Deals (Monat)', 'Gespräche offen', 'Wartet auf dich', 'Bot-Leads gesamt', 'API heute']) + assert.deepEqual(t.map((x) => x.value), ['3', '3', '7', '2', '112', `8,50${NB}$`]) + assert.equal(t[0].sub, `60${NB}€ neu pro Monat`); assert.equal(t[5].sub, `von 10,00${NB}$`) + assert.deepEqual(t.map((x) => x.tone), ['', '', '', 'warn', '', 'warn']) + assert.equal(V.kpiTiles({ ...K, apiTodayUsd: 10 })[5].tone, 'neg'); assert.equal(V.kpiTiles({ ...K, apiTodayUsd: 7.9 })[5].tone, '') + assert.equal(V.kpiTiles({ ...K, dailyBudgetUsd: 0 })[5].tone, ''); assert.equal(V.kpiTiles({ ...K, pultOpen: 0 })[3].tone, '') + }) + test('kpiTiles ohne Daten: Striche, kein NaN (Review Focus 1)', () => { + for (const k of [null, undefined, {}]) for (const x of V.kpiTiles(k)) { assert.equal(x.value, '–'); assert.ok(!/NaN|undefined/.test(x.sub)); assert.equal(x.tone, '') } + }) + test('counterLines: bekannte Zähler zuerst, Rest alphabetisch, unbekannter Schlüssel als Beschriftung', () => { + assert.deepEqual(V.counterLines({ zeta: 1, gesendet: 3, gelesen: 41 }), [ + { key: 'gelesen', label: 'gelesen', value: '41' }, { key: 'gesendet', label: 'gesendet', value: '3' }, { key: 'zeta', label: 'zeta', value: '1' }]) + assert.equal(V.counterLines({ entwuerfe: 2 })[0].label, 'Entwürfe'); assert.deepEqual(V.counterLines(null), []) + assert.equal(V.tokenTotal(1200), 1200); assert.equal(V.tokenTotal({ in: 1000, out: 234 }), 1234); assert.equal(V.tokenTotal(undefined), 0) + }) + test('safeLink: nur http(s) und interne Pfade (Review Focus 5)', () => { + assert.deepEqual(V.safeLink('https://x.de/a'), { href: 'https://x.de/a', external: true }) + assert.deepEqual(V.safeLink('/customers/1'), { href: '/customers/1', external: false }) + for (const bad of ['javascript:alert(1)', ' JavaScript:alert(1)', 'data:text/html,x', '//evil.example', 'mailto:a@b.de', '', null, 42]) assert.equal(V.safeLink(bad), null, String(bad)) + }) + test('traceRows: neueste zuerst, höchstens 50, unsicherer Link bleibt Text', () => { + const evs = Array.from({ length: 60 }, (_, i) => ({ id: `e${i}`, ts: new Date(Date.UTC(2026, 9, 17, 8, 0, i)).toISOString(), text: `T${i}`, link: i === 59 ? 'javascript:x' : null })) + const rows = V.traceRows(evs, Date.parse('2026-10-17T12:00:00Z')) + assert.equal(rows.length, 50); assert.equal(rows[0].id, 'e59'); assert.equal(rows[0].link, null); assert.equal(rows[0].text, 'T59') + }) + test('pauseLabel und pickTab', () => { + assert.equal(V.pauseLabel(false), 'Alles anhalten'); assert.equal(V.pauseLabel(true), 'Fortsetzen') + const tabs = [{ id: 'hochhaus' }, { id: 'pult' }] + assert.equal(V.pickTab(tabs, 'pult').id, 'pult'); assert.equal(V.pickTab(tabs, 'nope').id, 'hochhaus'); assert.equal(V.pickTab(tabs, null).id, 'hochhaus') + }) + ``` +- [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersView.test.mjs`. Expected: FAIL `Could not resolve …/workersView.js`. +- [ ] **Step 3: Implementieren** `src/lib/workersView.js` + Zahlen über `Intl.NumberFormat('de-DE')`; nur `typeof n === 'number' && Number.isFinite(n)` zählt, sonst `'–'`; Zeichen vor `$`/`€` ist das geschützte Leerzeichen (Code 160, im Test `NB`). `formatEur` ohne Nachkommastellen bei ganzen Zahlen. `formatTime` über `Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', … })`, „gleicher Tag“ = gleiches Berliner Datum wie `now`. Kachel-Ton `api`: `ratio = apiTodayUsd / dailyBudgetUsd` nur bei `dailyBudgetUsd > 0`: ≥ 1 ⇒ `'neg'`, ≥ 0,8 ⇒ `'warn'`, sonst `''`; Kachel `pult` `'warn'` bei `pultOpen > 0`. Bekannte Zähler (Reihenfolge, Beschriftung): `gelesen`, `gefunden`, `passend`, `bewertet`, `entwuerfe` „Entwürfe“, `gesendet`, `gebaut`, `geprueft` „geprüft“. `safeLink` trimmt, prüft `/^https?:\/\//i` (extern) oder `/^\/(?!\/)/` (intern). `traceRows` sortiert per `Date.parse` absteigend, ungültige Zeiten zuletzt. +- [ ] **Step 4: Bestehen sehen** — gleicher Befehl. Expected: `# pass 9`, `# fail 0`. +- [ ] **Step 5: Commit** + ```bash + cd $WK && git add src/lib/workersView.js tests/unit/workersView.test.mjs + git commit -m "Workers P2: Anzeige-Logik (Formate, Kacheln, safeLink, Spur-Zeilen)" -m "Co-Authored-By: Claude Opus 5.5 " + ``` + +--- + +### P2-T03: Ereignis-Logik `workersEvents.js` + +**Files:** +- Create: `src/lib/workersEvents.js` +- Test: `tests/unit/workersEvents.test.mjs` + +**Interfaces:** +- Consumes: `workersRoster.js` (`ROLES`). +- Produces (`reduceEvents`, `eventToSteps` laut Übersicht §7): + ```js + export const EVENT_KINDS // die 26 Arten aus Übersicht §5, in dieser Reihenfolge + export const STEP_OPS // ['task','say','pose','give','fx','cheer'] + export const SPOTS // ['pult','tresor','aussen','archiv'] + export function takeNewEvents(seen: Set, events): event[] // neu = Id noch nicht gesehen, aufsteigend nach ts + export function latestTs(events, prev = null): string | null + export function indexWorkers(state): { [workerId]: { status, task, lastTs, connected, counters } } + export function reduceEvents(stateByWorker, events): { [workerId]: { status, task, lastTs, … } } // unverändert eingegebenes Objekt + export function eventToSteps(event): step[] + export function needsStateRefresh(events): boolean + export function cutBubble(text): string // höchstens 20 Zeichen + ``` + Schritte: `{op:'task',who,text}` · `{op:'say',who,text}` · `{op:'pose',who,pose:'type'|'stand'|'coffee'|'sleep'}` · `{op:'give',from,to}` · `{op:'fx',fx:'paper'|'envelope'|'coin'|'stampOk'|'stampNo'|'sparkle'|'alert'|'drawer',at,to?,delay?}` (`at`/`to` = workerId oder Platz aus `SPOTS`) · `{op:'cheer',who}`. + +- [ ] **Step 1: Failing test schreiben** (`tests/unit/workersEvents.test.mjs`, `const E = await loadModule('src/lib/workersEvents.js')`, Roster per zweitem `loadModule` für `ROLES`) + ```js + const TS = '2026-10-17T20:00:00.000Z' + const ev = (kind, workerId, extra = {}) => ({ id: `${kind}-${workerId}`, ts: TS, workerId, kind, text: 'Text', ...extra }) + + test('EVENT_KINDS: 26 Arten aus Übersicht §5', () => { + assert.deepEqual([...E.EVENT_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('takeNewEvents: dedupliziert nach id, sortiert nach ts, Gedächtnis begrenzt (Review Focus 3)', () => { + const seen = new Set(), a = { id: 'a', ts: '2026-10-17T20:00:02Z' }, b = { id: 'b', ts: '2026-10-17T20:00:01Z' } + assert.deepEqual(E.takeNewEvents(seen, [a, b]).map((e) => e.id), ['b', 'a']) + assert.deepEqual(E.takeNewEvents(seen, [a, b, { id: 'c', ts: '2026-10-17T20:00:03Z' }]).map((e) => e.id), ['c']) + const noId = { ts: '2026-10-17T20:00:04Z', workerId: 'kim', kind: 'gelesen', text: 'x' } + assert.equal(E.takeNewEvents(seen, [noId]).length, 1); assert.equal(E.takeNewEvents(seen, [noId]).length, 0) + assert.deepEqual(E.takeNewEvents(seen, [null, {}, { id: 'z' }]), []) + const big = new Set(); E.takeNewEvents(big, Array.from({ length: 1500 }, (_, i) => ({ id: `i${i}`, ts: new Date(Date.UTC(2026, 9, 17, 0, 0, i)).toISOString() }))) + assert.ok(big.size <= 1000) + }) + test('latestTs: größter gültiger Zeitstempel', () => { + assert.equal(E.latestTs([{ ts: '2026-10-17T20:00:01+00:00' }, { ts: '2026-10-17T20:00:05Z' }, { ts: 'kaputt' }], null), '2026-10-17T20:00:05Z') + assert.equal(E.latestTs([], 'alt'), 'alt'); assert.equal(E.latestTs([], null), null) + }) + test('reduceEvents: Status aus der Ereignisart, grau bleibt grau, Altes und Fremdes wird ignoriert (Review Focus 4)', () => { + const base = { kim: { status: 'wartet', task: '', lastTs: null }, rita: { status: 'grau', task: '', lastTs: null }, bea: { status: 'arbeitet', task: 'alt', lastTs: '2026-10-17T20:00:09Z' } } + const copy = JSON.parse(JSON.stringify(base)) + const out = E.reduceEvents(base, [ev('gelesen', 'kim', { text: 'Kim hat 3 Gesuche gelesen' }), ev('fund', 'rita'), ev('fehler', 'bea'), ev('pause', 'system'), ev('fund', 'zed'), ev('auftrag', 'otto', { to: 'kim' })]) + assert.deepEqual(base, copy) + assert.deepEqual(out.kim, { status: 'wartet', task: 'Text', lastTs: TS }) // zuletzt: Auftrag von Otto + assert.equal(out.rita.status, 'grau'); assert.equal(out.bea.status, 'arbeitet'); assert.equal(out.bea.task, 'alt') // Ereignis älter als lastTs + assert.equal(E.reduceEvents(base, [ev('leerlauf', 'kim')]).kim.status, 'leerlauf') + assert.equal(E.reduceEvents(base, [ev('fehler', 'kim')]).kim.status, 'fehler') + assert.equal(E.indexWorkers({ lastEventTs: TS, workers: [{ workerId: 'kim', status: 'arbeitet', task: 't', connected: false, counters: { a: 1 } }] }).kim.status, 'grau') + }) + test('eventToSteps: feste Zeilen', () => { + assert.deepEqual(E.eventToSteps(ev('gelesen', 'kim', { meta: { count: 3 } })), [{ op: 'pose', who: 'kim', pose: 'type' }, { op: 'task', who: 'kim', text: 'Text' }, { op: 'say', who: 'kim', text: '3 gelesen' }]) + assert.deepEqual(E.eventToSteps(ev('doppelt', 'arne')), [{ op: 'task', who: 'arne', text: 'Text' }, { op: 'say', who: 'arne', text: 'Schon bekannt' }, { op: 'fx', fx: 'stampNo', at: 'arne' }]) + assert.deepEqual(E.eventToSteps(ev('uebergabe', 'rita', { to: 'bea' })), [{ op: 'task', who: 'rita', text: 'Text' }, { op: 'give', from: 'rita', to: 'bea' }]) + assert.deepEqual(E.eventToSteps(ev('pruefung_zurueck', 'paul', { to: 'bob', meta: { round: 2 } })), [{ op: 'task', who: 'paul', text: 'Text' }, { op: 'fx', fx: 'stampNo', at: 'paul' }, { op: 'say', who: 'paul', text: 'Runde 2 zurück' }, { op: 'give', from: 'paul', to: 'bob' }]) + assert.deepEqual(E.eventToSteps(ev('deal', 'system')), [0, 0.4, 0.8].map((delay) => ({ op: 'fx', fx: 'coin', at: 'aussen', to: 'tresor', delay })).concat([{ op: 'cheer', who: 'otto' }, { op: 'say', who: 'otto', text: 'Deal! Super!' }])) + assert.deepEqual(E.eventToSteps(ev('gesperrt', 'system')), [{ op: 'fx', fx: 'stampNo', at: 'arne' }, { op: 'say', who: 'arne', text: 'Gesperrt' }]) // system: kein task-Schritt, Arne stempelt + }) + test('eventToSteps: jede Art liefert gültige Schritte, Blasen ≤ 20 Zeichen, Unbekanntes wird verworfen (Review Focus 4)', () => { + const ids = new Set(ROLES.map((r) => r.workerId)), known = (v) => ids.has(v) || E.SPOTS.includes(v) + for (const kind of E.EVENT_KINDS) for (const w of ['kim', 'system', 'zed']) { + for (const s of E.eventToSteps(ev(kind, w, { to: 'bea', meta: { count: 99999999, score: 123456789, round: 2, classification: 'ja' } }))) { + assert.ok(E.STEP_OPS.includes(s.op), kind) + if (s.op === 'say') assert.ok(s.text.length <= 20, s.text) + if (s.op === 'fx') { assert.ok(known(s.at), `${kind}/${w}: at=${s.at}`); if (s.to) assert.ok(known(s.to)) } + else for (const k of ['who', 'from', 'to']) if (s[k]) assert.ok(ids.has(s[k]), `${kind}/${w}: ${k}=${s[k]}`) + } + } + assert.deepEqual(E.eventToSteps(ev('fund', 'zed')), []); assert.deepEqual(E.eventToSteps(ev('fund', 'system')), []) + assert.deepEqual(E.eventToSteps(ev('neueArt', 'kim')), [{ op: 'task', who: 'kim', text: 'Text' }]); assert.deepEqual(E.eventToSteps(ev('neueArt', 'zed')), []) + assert.deepEqual(E.eventToSteps(ev('uebergabe', 'rita', { to: 'zed' })), [{ op: 'task', who: 'rita', text: 'Text' }]) + }) + test('needsStateRefresh und cutBubble', () => { + assert.equal(E.needsStateRefresh([ev('gelesen', 'kim'), ev('pult', 'clara')]), true); assert.equal(E.needsStateRefresh([ev('gelesen', 'kim'), ev('fund', 'kim')]), false) + for (const k of ['pult', 'gesendet', 'deal', 'botlead', 'gesperrt', 'budget_warnung', 'budget_stopp', 'pause', 'top3', 'angebot']) assert.equal(E.needsStateRefresh([ev(k, 'otto')]), true, k) + assert.equal(E.cutBubble('Dies ist ein sehr langer Satz!'), 'Dies ist ein sehr la'); assert.equal(E.cutBubble(null), '') + }) + ``` + Hinweis zu `gesperrt`: Bei `workerId: 'system'` fällt der `task`-Schritt weg, weil `task` nur für bekannte Worker erzeugt wird; Arne stempelt trotzdem (Tabelle: `A = arne`). +- [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersEvents.test.mjs`. Expected: FAIL `Could not resolve …/workersEvents.js`. +- [ ] **Step 3: Implementieren** `src/lib/workersEvents.js` + `takeNewEvents`: Schlüssel = `id`, ohne `id` `ts|workerId|kind|text`; ohne gültiges `ts` verwerfen; Set auf 1000 Einträge begrenzen (ältesten zuerst löschen); Ergebnis stabil nach `Date.parse(ts)` aufsteigend. `reduceEvents`: nur Worker, die in `stateByWorker` stehen **und** in `ROLES` vorkommen; ein Ereignis wirkt nur, wenn `Date.parse(ts) >= Date.parse(lastTs)` (oder `lastTs` leer); `grau` bleibt `grau`; Status: `leerlauf` ⇒ `'leerlauf'`, `fehler` ⇒ `'fehler'`, jede andere Art ⇒ `'arbeitet'`, `task = text`, `lastTs = ts`; der Empfänger `to` von `auftrag`/`uebergabe` wird (falls nicht grau und nicht neuer) `'wartet'` mit `task = text`; Rückgabe ist ein neues Objekt, Einträge als Kopien (Zusatzfelder wie `connected`/`counters` bleiben). `indexWorkers`: `status = connected === false ? 'grau' : status`, `lastTs = state.lastEventTs ?? null`. `eventToSteps`: erst Schritte nach der Tabelle bilden, dann **alle Schritte verwerfen**, die auf unbekannte `workerId`/Plätze zeigen (`system` und `zed` fallen weg); `meta` darf Objekt, JSON-String oder `null` sein (P1 liefert `null`, wenn leer); `W` = `event.workerId`, `T` = `text`: + + | kind | Schritte in dieser Reihenfolge | + |---|---| + | auftrag | task W; say W `${Name(to)}, los!`; say to `Verstanden` | + | leerlauf | task W; pose W coffee | + | gelesen | pose W type; task W; say W (`meta.count` Zahl ? `${count} gelesen` : `Gelesen`) | + | fund | pose W type; task W; say W `Neuer Fund!`; fx paper at W | + | doppelt | task W; say W `Schon bekannt`; fx stampNo at W | + | uebergabe | task W; give W→to | + | bewertet | pose W type; task W; say W (`meta.score` Zahl ? `${score} Punkte` : `Bewertet`) | + | top3 | task W; say W `Top 3!`; fx stampOk at W | + | botlead | task W; say W `Abgelegt`; fx drawer at W | + | entwurf | pose W type; task W; say W `Entwurf fertig` | + | pult | task W; say W `Zur Freigabe`; fx envelope at W to pult | + | gesendet | task W; (W bekannt: say W `Raus damit!`; fx envelope at W to aussen) sonst: fx envelope at pult to aussen | + | eingang | task W; fx envelope at aussen to W; say W `Antwort!` | + | sortiert | task W; fx stampOk at W; say W (Beschriftung zu `meta.classification`: ja `Ja!`, frage `Frage`, nein `Nein`, abmelden `Abmeldung`, abwesend `Abwesend`, unzustellbar `Unzustellbar`, beschwerde `Beschwerde!`, unklar `Unklar`, sonst `Sortiert`) | + | bau_start | pose W type; task W; say W `Los geht’s` | + | bau_fertig | task W; say W `Fertig!`; give W→paul | + | pruefung_zurueck | task W; fx stampNo at W; say W (`meta.round` Zahl ? `Runde ${round} zurück` : `Zurück`); give W→to | + | pruefung_ok | task W; fx stampOk at W; say W `Passt!` | + | vorschau_link | task W; say W `Link raus`; fx envelope at W to aussen | + | angebot | task W; say W `Kauf-Link!`; fx paper at W | + | deal | fx coin at aussen to tresor mit `delay` 0, 0.4, 0.8; cheer otto; say otto `Deal! Super!` | + | gesperrt | task W (entfällt bei `system`); fx stampNo at A; say A `Gesperrt` mit A = W bekannt ? W : arne | + | budget_warnung | task W; say otto `Budget 80 %`; fx alert at otto | + | budget_stopp | task W; say otto `Budget leer`; fx alert at otto | + | pause | (keine) | + | fehler | task W; fx alert at W; say W `Fehler` | + | andere Art | task W | + + Alle `say`-Texte laufen durch `cutBubble`. `needsStateRefresh`: wahr bei `pult`, `gesendet`, `deal`, `botlead`, `gesperrt`, `budget_warnung`, `budget_stopp`, `pause`, `top3`, `angebot`. +- [ ] **Step 4: Bestehen sehen** — gleicher Befehl wie Step 2. Expected: `# pass 7`, `# fail 0`. +- [ ] **Step 5: Commit** + ```bash + cd $WK && git add src/lib/workersEvents.js tests/unit/workersEvents.test.mjs + git commit -m "Workers P2: Ereignis-Logik (Dedup, reduceEvents, eventToSteps)" -m "Co-Authored-By: Claude Opus 5.5 " + ``` + +--- + +### P2-T04: API-Client `workersApi.js` und `workersClient.js` + +**Files:** +- Create: `src/lib/workersApi.js`, `src/lib/workersClient.js` +- Test: `tests/unit/workersApi.test.mjs` + +**Interfaces:** +- Consumes: Übersicht §6.2 (Pfade), E2; `createJwtCache` aus `serverpulsAuth.js` (nur `workersClient.js`). +- Produces (Übersicht §7): + ```js + export function createWorkersApi({ getJwt, fetchImpl }): { + state(), events(after, limit = 200), trace(workerId, limit = 50), pause(paused), + settings(), saveSettings(patch), pult(status = 'offen'), pultAction(id, action, body?), + botleads({ room, q, page }), prospect(id) } + export const DOWN_TEXT, BAD_REPLY_TEXT, SESSION_TEXT, FORBIDDEN_TEXT // 'Der Workers-Dienst ist gerade nicht erreichbar.' · 'Der Workers-Dienst antwortet nicht richtig.' · 'Anmeldung abgelaufen. Bitte neu anmelden.' · 'Dafür fehlt dir die Berechtigung.' + // workersClient.js + export const workersApi // createWorkersApi mit account.createJWT, 10-min-Cache + ``` + Fehler sind `Error` mit `message` (deutsch) und `status`. + +- [ ] **Step 1: Failing test schreiben** (`tests/unit/workersApi.test.mjs`) + ```js + const { createWorkersApi } = await loadModule('src/lib/workersApi.js') + const jwts = [] + const getJwt = async (o) => { jwts.push(o?.fresh === true); return o?.fresh ? 'jwt-neu' : 'jwt-alt' } + function fakeFetch(replies) { + const calls = [] + const fn = async (url, init) => { calls.push({ url, init }); const r = replies.shift(); if (r instanceof Error) throw r + return { ok: r.status >= 200 && r.status < 300, status: r.status, json: async () => { if (r.body === undefined) throw new SyntaxError('kein JSON'); return r.body } } } + fn.calls = calls; return fn + } + test('URLs, Methoden, Header, Body', async () => { + const f = fakeFetch(Array.from({ length: 11 }, () => ({ status: 200, body: {} }))) + const api = createWorkersApi({ getJwt, fetchImpl: f }) + await api.state(); await api.events('2026-10-17T20:00:00.000+00:00'); await api.events(null, 500); await api.trace('kim'); await api.pause(true) + await api.settings(); await api.saveSettings({ warnPct: 70 }); await api.pult(); await api.pultAction('p1', 'claim') + await api.botleads({ room: 'netz', q: 'Bäcker', page: 2 }); await api.prospect('x/y') + assert.deepEqual(f.calls.map((c) => [c.init.method || 'GET', c.url]), [ + ['GET', '/api/workers/state'], + ['GET', '/api/workers/events?after=2026-10-17T20%3A00%3A00.000%2B00%3A00&limit=200'], + ['GET', '/api/workers/events?limit=200'], + ['GET', '/api/workers/worker/kim/trace?limit=50'], + ['POST', '/api/workers/pause'], ['GET', '/api/workers/settings'], ['PUT', '/api/workers/settings'], + ['GET', '/api/workers/pult?status=offen'], ['POST', '/api/workers/pult/p1/claim'], + ['GET', '/api/workers/botleads?room=netz&q=B%C3%A4cker&page=2'], ['GET', '/api/workers/prospect/x%2Fy']]) + assert.equal(f.calls[0].init.headers['X-Appwrite-JWT'], 'jwt-alt') + assert.equal(f.calls[4].init.body, JSON.stringify({ paused: true })); assert.equal(f.calls[4].init.headers['Content-Type'], 'application/json') + assert.equal(f.calls[6].init.body, JSON.stringify({ warnPct: 70 })) + }) + test('Fehlertexte (Review Focus 1)', async () => { + const run = (reply) => createWorkersApi({ getJwt, fetchImpl: fakeFetch([reply]) }).state().then(() => null, (e) => [e.message, e.status]) + assert.deepEqual(await run({ status: 200, body: undefined }), ['Der Workers-Dienst antwortet nicht richtig.', 200]) // nginx liefert index.html + assert.deepEqual(await run({ status: 404, body: undefined }), ['Der Workers-Dienst antwortet nicht richtig.', 404]) + assert.deepEqual(await run({ status: 502, body: undefined }), ['Der Workers-Dienst ist gerade nicht erreichbar.', 502]) + assert.deepEqual(await run(new TypeError('fetch failed')), ['Der Workers-Dienst ist gerade nicht erreichbar.', 0]) + assert.deepEqual(await run({ status: 403, body: {} }), ['Dafür fehlt dir die Berechtigung.', 403]) + assert.deepEqual(await run({ status: 400, body: { error: 'Tagesgrenze ungültig.' } }), ['Tagesgrenze ungültig.', 400]) + assert.deepEqual(await run({ status: 500, body: {} }), ['Workers-Fehler 500', 500]) + await assert.rejects(createWorkersApi({ getJwt: async () => { throw new Error('x') }, fetchImpl: fakeFetch([]) }).state(), { message: 'Anmeldung abgelaufen. Bitte neu anmelden.', status: 401 }) + }) + test('401 ⇒ einmal mit frischem JWT wiederholen, danach Meldung', async () => { + jwts.length = 0 + const f = fakeFetch([{ status: 401, body: {} }, { status: 200, body: { ok: 1 } }]) + assert.deepEqual(await createWorkersApi({ getJwt, fetchImpl: f }).state(), { ok: 1 }) + assert.deepEqual(jwts, [false, true]); assert.equal(f.calls[1].init.headers['X-Appwrite-JWT'], 'jwt-neu') + await assert.rejects(createWorkersApi({ getJwt, fetchImpl: fakeFetch([{ status: 401, body: {} }, { status: 401, body: {} }]) }).state(), { message: 'Anmeldung abgelaufen. Bitte neu anmelden.', status: 401 }) + }) + test('pultAction: nur bekannte Aktionen, Pfad-Tricks abgewiesen', async () => { + const f = fakeFetch(Array.from({ length: 6 }, () => ({ status: 200, body: {} }))); const api = createWorkersApi({ getJwt, fetchImpl: f }) + for (const bad of ['../x', 'delete', '']) await assert.rejects(api.pultAction('p1', bad), /Unbekannte Aktion/) + assert.equal(f.calls.length, 0) + for (const a of ['claim', 'sent', 'discard', 'done', 'reply', 'approve-preview']) await api.pultAction('p1', a, a === 'reply' ? { text: 'x' } : undefined) + assert.deepEqual(f.calls.map((c) => c.url.split('/').pop()), ['claim', 'sent', 'discard', 'done', 'reply', 'approve-preview']); assert.equal(f.calls[4].init.body, JSON.stringify({ text: 'x' })) + }) + test('limit wird geklemmt (Ereignisse 1..200, Spur 1..50)', async () => { + const f = fakeFetch(Array.from({ length: 3 }, () => ({ status: 200, body: {} }))); const api = createWorkersApi({ getJwt, fetchImpl: f }) + await api.trace('kim', 999); await api.events(null, 0); await api.events(null, 150) + assert.deepEqual(f.calls.map((c) => c.url), ['/api/workers/worker/kim/trace?limit=50', '/api/workers/events?limit=1', '/api/workers/events?limit=150']) + }) + ``` +- [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersApi.test.mjs`. Expected: FAIL `Could not resolve …/workersApi.js`. +- [ ] **Step 3: Implementieren** + `src/lib/workersApi.js`: ein `request(path, { method, body })` nach Muster `financeRequest.js`: `fetchImpl` wirft ⇒ `DOWN_TEXT` (status 0); `getJwt` wirft ⇒ `SESSION_TEXT` (401); Antwort 401 ⇒ einmal `getJwt({ fresh: true })`, danach erneut 401 ⇒ `SESSION_TEXT`; JSON-Parsefehler ⇒ bei 502/503/504 `DOWN_TEXT`, sonst `BAD_REPLY_TEXT` (mit dem echten Status); 403 ohne `error` ⇒ `FORBIDDEN_TEXT`; sonstiger Fehlerstatus ⇒ `data.error` oder `Workers-Fehler `. Erlaubte `pultAction`-Namen: `claim`, `sent`, `discard`, `done`, `reply`, `approve-preview`. `limit` hat den Standard (200 bzw. 50) nur bei `undefined` (nicht bei 0) und wird auf 1..200 (Ereignisse) bzw. 1..50 (Spur) geklemmt; leere Werte fallen aus der Query. `src/lib/workersClient.js` (nicht unter Node geladen, wie `serverpulsApi.js`): `account.createJWT` + `createJwtCache` (10 min), `getJwt = (o) => { if (o?.fresh) cache.invalidate(); return cache.get() }`, `export const workersApi = createWorkersApi({ getJwt })`. +- [ ] **Step 4: Bestehen sehen** — gleicher Befehl. Expected: `# pass 5`, `# fail 0`. +- [ ] **Step 5: Commit** + ```bash + cd $WK && git add src/lib/workersApi.js src/lib/workersClient.js tests/unit/workersApi.test.mjs + git commit -m "Workers P2: API-Client mit deutschen Fehlertexten" -m "Co-Authored-By: Claude Opus 5.5 " + ``` + +--- + +### P2-T05: Feed-Steuerung `workersFeed.js` und Hooks + +**Files:** +- Create: `src/lib/workersFeed.js`, `src/hooks/useWorkersFeed.js` +- Test: `tests/unit/workersFeed.test.mjs` + +**Interfaces:** +- Consumes: `takeNewEvents`, `latestTs`, `needsStateRefresh` (P2-T03); `api.state()`, `api.events(after, limit)`, `api.trace(id)` (P2-T04); `createRequestGuard` (`src/lib/requestGuard.js`). +- Produces: + ```js + export function createFeedController({ api, setTimer, clearTimer, isVisible, onSnapshot, onEvents, onError, + stateMs = 15000, eventsMs = 5000, pageLimit = 200, maxPages = 5 }): { start(), stop(), visibilityChanged(), refreshState() } + // useWorkersFeed.js + export function useWorkersFeed(api, { onEvents }): { state, error, loaded, refresh } + export function useWorkerTrace(api, workerId): { trace, loading, error } + ``` + +- [ ] **Step 1: Failing test schreiben** (`tests/unit/workersFeed.test.mjs`) + Hilfsfunktion `makeFeedHarness({ state, eventReplies, eventsHang })` im Testfile: Fake-Timer (`setTimer(fn, ms)`/`clearTimer`, `timers` als Map, `advance(ms)` feuert fällige Timer in Zeitreihenfolge und wartet je Timer einen `setImmediate`), `settle()` (zwei `setImmediate`), Fake-`api` mit `state()` (zählt in `log.state`, gibt `state` zurück, Standard `{ lastEventTs: '2026-10-17T20:00:00.000Z', workers: [] }`) und `events(after, limit)` (hängt `[after, limit]` an `log.events`; antwortet mit `eventReplies.shift() ?? []`, ein `Error` in der Liste wird als Ablehnung zurückgegeben; mit `eventsHang` kehrt der Aufruf nie zurück), Schalter `visible.v`, Protokolle `log.snapshots` (je Aufruf von `onSnapshot` der Zustand), `log.batches` (je `onEvents` die Liste) und `log.errors` (je `onError(e)` der Eintrag `e ? e.message : null`). + ```js + const mk = (i) => ({ id: `e${i}`, ts: new Date(Date.UTC(2026, 9, 17, 20, 0, 1 + i)).toISOString(), workerId: 'kim', kind: 'fund', text: 't' }) + test('Start: Zustand, Cursor aus lastEventTs, dann Ereignisse (Review Focus 3)', async () => { + const h = makeFeedHarness({}); h.c.start(); await h.settle() + assert.equal(h.log.snapshots.length, 1); assert.deepEqual(h.log.events[0], ['2026-10-17T20:00:00.000Z', 200]) + const h2 = makeFeedHarness({ state: { workers: [] } }); h2.c.start(); await h2.settle() + assert.equal(h2.log.events[0][0], '1970-01-01T00:00:00.000Z') + }) + test('Takt: Ereignisse alle 5 s mit weitergeschobenem Cursor, Zustand alle 15 s', async () => { + const h = makeFeedHarness({ eventReplies: [[mk(1)], [], []] }); h.c.start(); await h.settle() + await h.advance(5000); assert.equal(h.log.events[1][0], mk(1).ts) + await h.advance(10000); assert.equal(h.log.state, 2); assert.equal(h.log.events.length, 4) + }) + test('Hintergrund: keine Abrufe, beim Zurückkehren sofort beide', async () => { + const h = makeFeedHarness({}); h.c.start(); await h.settle() + h.visible.v = false; await h.advance(60000); assert.equal(h.log.state, 1); assert.equal(h.log.events.length, 1) + h.visible.v = true; h.c.visibilityChanged(); await h.settle(); assert.equal(h.log.state, 2); assert.equal(h.log.events.length, 2) + await h.advance(5000); assert.equal(h.log.events.length, 3); assert.equal(h.timers.size, 2) // eine Kette je Schleife, kein doppelter Timer nach der Rückkehr + }) + test('Doppelte Ereignisse werden nie zweimal gemeldet (Review Focus 3)', async () => { + const h = makeFeedHarness({ eventReplies: [[mk(1), mk(2)], [mk(1), mk(2), mk(3)]] }); h.c.start(); await h.settle(); await h.advance(5000) + assert.deepEqual(h.log.batches.map((b) => b.map((e) => e.id)), [['e1', 'e2'], ['e3']]) + }) + test('Rückstau: volle Seiten werden sofort nachgeladen, höchstens 5 je Runde (Review Focus 2)', async () => { + const page = (n) => Array.from({ length: 200 }, (_, i) => mk(n * 200 + i)) + const h = makeFeedHarness({ eventReplies: [page(0), page(1), page(2), page(3), page(4), page(5)] }); h.c.start(); await h.settle() + assert.equal(h.log.events.length, 5); assert.equal(h.log.events[1][0], mk(199).ts) + }) + test('keine überlappenden Abrufe, Fehler laufen weiter, Zustand-Auffrischung bei wichtigen Ereignissen', async () => { + const hang = makeFeedHarness({ eventsHang: true }); hang.c.start(); await hang.settle(); await hang.advance(60000) + assert.equal(hang.log.events.length, 1) + const err = makeFeedHarness({ eventReplies: [new Error('Der Workers-Dienst ist gerade nicht erreichbar.'), []] }); err.c.start(); await err.settle(); await err.advance(5000) + assert.deepEqual(err.log.errors, ['Der Workers-Dienst ist gerade nicht erreichbar.', null]) + const deal = makeFeedHarness({ eventReplies: [[{ ...mk(1), kind: 'deal' }]] }); deal.c.start(); await deal.settle(); assert.equal(deal.log.state, 2) + }) + test('stop räumt auf, start ist idempotent, späte Antworten werden verworfen', async () => { + const h = makeFeedHarness({}); h.c.start(); h.c.start(); await h.settle(); assert.equal(h.log.state, 1) + h.c.stop(); assert.equal(h.timers.size, 0); await h.advance(60000); assert.equal(h.log.state, 1) + }) + test('Hooks: Sichtbarkeit und Aufräumen im Quelltext', () => { + const src = readFileSync(new URL('../../src/hooks/useWorkersFeed.js', import.meta.url), 'utf8') + assert.match(src, /visibilitychange/); assert.match(src, /removeEventListener\('visibilitychange'/); assert.match(src, /\.stop\(\)/); assert.match(src, /createRequestGuard/) + }) + ``` +- [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersFeed.test.mjs`. Expected: FAIL `Could not resolve …/workersFeed.js`. +- [ ] **Step 3: Implementieren** + `createFeedController` (Regeln): Zustand und Ereignisse laufen mit je **einem** `setTimer` nach Abschluss des vorherigen Abrufs (kein Intervall, kein Überlappen; `inFlight.state`/`inFlight.events`; je Schleife höchstens **ein** wartender Timer: `visibilityChanged()` und `refreshState()` löschen ihn vor dem sofortigen Abruf, nach dem Abruf wird neu gesetzt); Start: sichtbar ⇒ `loadState()`, bei Erfolg `onSnapshot(state)`, beim ersten Erfolg `cursor = state.lastEventTs || '1970-01-01T00:00:00.000Z'` und danach `loadEvents()`; `loadEvents`: `api.events(cursor, pageLimit)` ⇒ `fresh = takeNewEvents(seen, res.events)`, `cursor = latestTs(res.events, cursor)`, `fresh.length` ⇒ `onEvents(fresh)`, `needsStateRefresh(fresh)` ⇒ `refreshState()`; kam eine volle Seite (`res.events.length >= pageLimit`) und `pages < maxPages`, sofort die nächste; Fehler ⇒ `onError(err)` einmal je Fehlerfolge, nächster Erfolg ⇒ `onError(null)`; Timer feuern bei `!isVisible()` ohne Abruf und planen neu; `visibilityChanged()` sichtbar ⇒ sofort `loadState()` und (Cursor vorhanden) `loadEvents()`; `stop()` löscht Timer, erhöht eine Generationszahl (späte Antworten ändern nichts), `start()` nach `stop()` beginnt neu. `useWorkersFeed`: Controller mit echten Timern und `document.visibilityState`, `visibilitychange`-Listener, `onEvents` über `useRef` (stabil), Cleanup `stop()` + Listener entfernen; `refresh` = `controller.refreshState()`. `useWorkerTrace`: lädt bei `workerId`-Wechsel und alle 15 s bei sichtbarem Tab, `createRequestGuard` verwirft späte Antworten, `workerId` null ⇒ `{ trace: null }`. +- [ ] **Step 4: Bestehen sehen** — gleicher Befehl. Expected: `# pass 8`, `# fail 0`. +- [ ] **Step 5: Commit** + ```bash + cd $WK && git add src/lib/workersFeed.js src/hooks/useWorkersFeed.js tests/unit/workersFeed.test.mjs + git commit -m "Workers P2: Feed-Steuerung und Hooks (Zustand 15 s, Ereignisse 5 s)" -m "Co-Authored-By: Claude Opus 5.5 " + ``` + +--- + +### P2-T06: Engine-Grundlagen `sprites.js` (Raster, Figuren, Text) + +**Files:** +- Create: `src/components/workers/engine/sprites.js`, `tests/unit/helpers/fakeCtx.mjs` +- Test: `tests/unit/workersSprites.test.mjs` + +**Interfaces:** +- Consumes: nichts. Quelle der Portierung: `/root/reports/workers-2026-10-16/entwuerfe/sprites.js` Z. 7–142 (Palette, Sprite-Karten, `drawMap`, `drawWorker`, `drawSelect`, `drawPaper`) und Z. 277–307 (`label`, `drawBubble`). ES5-IIFE ⇒ benannte ES-Exporte; die Konstante `S` entfällt zugunsten des Rasters. +- Produces: + ```js + export const PAL, HAT, TEAM, FONT_FAMILY // wie Vorlage; FONT_FAMILY = '"Pixelify Sans", ui-monospace, Menlo, monospace' + export function setGrid(ctx, { k, ox = 0, oy = 0 }) // k = Geräte-Pixel je logischer Einheit; speichert auf ctx.wkGrid; ohne Aufruf k = 3, ox = oy = 0 + export function px(ctx, x, y, w, h, color) // links = round(x*k+ox), rechts = round((x+w)*k+ox), analog y; Breite/Höhe mindestens 1 + export function tint(ctx, x, y, w, h, color, alpha) + export function label(ctx, text, x, y, { size = 12, color = PAL.ink, weight = 500, align = 'left', bg } = {}) // Schrift = size*k/3 px + export function drawBubble(ctx, x, y, text) // Spitze bei (x,y), klemmt in ctx.canvas.width (Rand 2) + export function drawAlertMark(ctx, x, y) // rotes „!“ (persistent bei Status fehler) + export function drawPaper(ctx, x, y) + export function drawWorker(ctx, x, y, { hat, frame = 0, facing = 1, carrying = false, scale = 1, selected = false, pose = 'stand', grey = false, t = 0 }): { x, y, w, h } + ``` + `tests/unit/helpers/fakeCtx.mjs`: `makeCtx({ width = 960, height = 768 } = {}) → { ctx, rects:[{x,y,w,h,fill}], texts:[{text,x,y,font}], counts:{save,restore}, alphas:[] }`: `Proxy` nach Muster `smoke.js` (`fillRect`/`fillText` aufzeichnen mit aktuellem `fillStyle`/`font`, `fillText` mit Nicht-String wirft, `measureText` ⇒ `{ width: länge*7 }`, `globalAlpha`-Zuweisungen in `alphas`, `canvas = { width, height }`, alles Übrige No-op). + +- [ ] **Step 1: Failing test schreiben** (`tests/unit/workersSprites.test.mjs`, `const S = await loadModule('src/components/workers/engine/sprites.js')`, `import { makeCtx } from './helpers/fakeCtx.mjs'`) + ```js + test('px: gerundete Kanten ohne Naht, Standardraster 3, Versatz, Mindestgröße', () => { + let { ctx, rects } = makeCtx(); S.setGrid(ctx, { k: 3.4, ox: 0, oy: 0 }) + S.px(ctx, 0, 0, 1, 1, '#000'); S.px(ctx, 1, 0, 1, 1, '#111'); S.px(ctx, 2, 0, 1, 1, '#222') + assert.equal(rects[0].x + rects[0].w, rects[1].x); assert.equal(rects[1].x + rects[1].w, rects[2].x) + ;({ ctx, rects } = makeCtx()); S.px(ctx, 1, 2, 3, 4, '#fff'); assert.deepEqual([rects[0].x, rects[0].y, rects[0].w, rects[0].h], [3, 6, 9, 12]) + ;({ ctx, rects } = makeCtx()); S.setGrid(ctx, { k: 2, ox: -10, oy: 0 }); S.px(ctx, 5, 0, 0.1, 1, '#fff'); assert.equal(rects[0].x, 0); assert.ok(rects[0].w >= 1) + }) + test('label: Schriftgröße wächst mit dem Raster', () => { + const { ctx, texts } = makeCtx(); S.setGrid(ctx, { k: 6 }); S.label(ctx, 'Hallo', 1, 1, { size: 11 }); assert.match(texts[0].font, /22px/) + }) + test('drawWorker: Trefferfläche', () => { + const { ctx } = makeCtx() + assert.deepEqual(S.drawWorker(ctx, 10, 20, { hat: 'haiku' }), { x: 10, y: 18, w: 12, h: 16 }) + assert.equal(S.drawWorker(ctx, 10, 20, { hat: 'haiku', carrying: true }).w, 14) + assert.deepEqual(S.drawWorker(ctx, 10, 20, { hat: 'overseer', scale: 2 }), { x: 10, y: 16, w: 24, h: 32 }) + }) + test('drawWorker: Hutfarbe zeigt das Modell, Augen je Pose, Grau mit Alpha', () => { + for (const hat of ['opus', 'sonnet', 'haiku', 'overseer']) { const { ctx, rects } = makeCtx(); S.drawWorker(ctx, 0, 0, { hat }); assert.ok(rects.some((r) => r.fill === S.HAT[hat][0]), hat) } + const eyes = (pose) => { const { ctx, rects } = makeCtx(); S.drawWorker(ctx, 0, 0, { hat: 'haiku', pose }); return rects.filter((r) => r.fill === S.PAL.e).length } + assert.equal(eyes('stand'), 4); assert.equal(eyes('sleep'), 2) + for (const pose of ['stand', 'type', 'coffee', 'sleep']) { const { ctx, rects } = makeCtx(); S.drawWorker(ctx, 0, 0, { hat: 'sonnet', pose, t: 1.3 }); assert.ok(rects.length > 20, pose) } + const g = makeCtx(); S.drawWorker(g.ctx, 0, 0, { hat: 'opus', grey: true }); assert.ok(g.alphas.includes(0.55)); assert.equal(g.counts.save, g.counts.restore) + }) + test('drawBubble bleibt im Bild (Spitze nahe am linken und rechten Rand, Raster 3, Breite 960)', () => { + const l = makeCtx(); S.drawBubble(l.ctx, 2, 30, 'Hallo'); assert.ok(Math.min(...l.rects.map((r) => r.x)) >= 0) + const r = makeCtx(); S.drawBubble(r.ctx, 318, 30, 'Hallo'); assert.ok(Math.max(...r.rects.map((q) => q.x + q.w)) <= 960) + }) + ``` +- [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersSprites.test.mjs`. Expected: FAIL `Could not resolve …/engine/sprites.js`. +- [ ] **Step 3: Implementieren** `engine/sprites.js` und `tests/unit/helpers/fakeCtx.mjs` + Portieren wie oben. Neu gegenüber der Vorlage: (1) Raster über `ctx.wkGrid`; (2) Posen: `stand` und `type` zeichnen gleich (das Wippen macht die Regie), `sleep` ersetzt die Augenzeile 4 durch Körperfarbe (nur die Zeile 5 bleibt, also 2 Augenpunkte) und setzt ein kleines „z“ aus 3 Pixeln über den Hut (steigt mit `t`), `coffee` zeichnet eine 3×3-Tasse an der Hand und 1 Dampfpixel (flackert mit `t`); (3) `grey`: `ctx.save()`, `ctx.globalAlpha = 0.55`, Körper-/Hutfarben auf Grautöne abbilden, Trefferfläche unverändert, `ctx.restore()`; (4) `drawAlertMark`: 3×7-Pixel-„!“ in `PAL.red` mit Umriss. `drawBubble` klemmt `bx` in `[2, ctx.canvas.width - bw - 2]` (ohne `ctx.canvas` Standard 960). +- [ ] **Step 4: Bestehen sehen** — gleicher Befehl. Expected: `# pass 5`, `# fail 0`. +- [ ] **Step 5: Commit** + ```bash + cd $WK && git add src/components/workers/engine/sprites.js tests/unit/helpers/fakeCtx.mjs tests/unit/workersSprites.test.mjs + git commit -m "Workers P2: Engine-Sprites (Raster, Figuren mit Posen, Text, Blasen)" -m "Co-Authored-By: Claude Opus 5.5 " + ``` + +--- + +### P2-T07: Requisiten `props.js` + +**Files:** +- Create: `src/components/workers/engine/props.js` +- Test: `tests/unit/workersProps.test.mjs` + +**Interfaces:** +- Consumes: `px`, `label`, `PAL`, `TEAM`, `HAT` aus `sprites.js`. Quelle: `entwuerfe/sprites.js` Z. 146–273. +- Produces: + ```js + export const ICONS // { netz, kleinanzeigen, portale, foren, aerzte, handwerk, selbst, laden } → 7 Zeilen à 7 Zeichen ('.' leer, 'x' Linie, 'a' Akzent) + export function drawIcon(ctx, name, x, y, color) + export function drawDesk(ctx, x, y, w, accent) · drawMonitor(ctx, x, y, t, color, on = true) · drawCabinet(ctx, x, y, drawers) + export function drawPlant(ctx, x, y) · drawCoin(ctx, x, y, t) · drawTrophy(ctx, x, y) · drawStamp(ctx, x, y, ok) + export function drawRadar(ctx, x, y, t) · drawMailbox(ctx, x, y, count) · drawVault(ctx, x, y, t) · drawScale(ctx, x, y, tilt) + export function drawTray(ctx, x, y, filled) // Ablagekorb/Eingangskorb 10×4 + export function drawSign(ctx, x, y, w, text) // weißes Schild mit dunklem Rand, Text zentriert + ``` + +- [ ] **Step 1: Failing test schreiben** (`const P = await loadModule('…/engine/props.js')`) + ```js + test('alle Requisiten zeichnen ohne Wurf und mindestens ein Rechteck', () => { + const calls = { desk: (c) => P.drawDesk(c, 0, 0, 18, '#f00'), monitor: (c) => P.drawMonitor(c, 0, 0, 1, '#7fb2ff'), cabinet: (c) => P.drawCabinet(c, 0, 0, 3), plant: (c) => P.drawPlant(c, 0, 0), + coin: (c) => P.drawCoin(c, 0, 0, 0.3), trophy: (c) => P.drawTrophy(c, 0, 0), stampOk: (c) => P.drawStamp(c, 0, 0, true), stampNo: (c) => P.drawStamp(c, 0, 0, false), + radar: (c) => P.drawRadar(c, 0, 0, 1), mailbox: (c) => P.drawMailbox(c, 0, 0, 2), vault: (c) => P.drawVault(c, 0, 0, 1), scale: (c) => P.drawScale(c, 0, 0, 2), tray: (c) => P.drawTray(c, 0, 0, true) } + for (const [name, fn] of Object.entries(calls)) { const { ctx, rects } = makeCtx(); fn(ctx); assert.ok(rects.length >= 1, name) } + }) + test('Briefkasten-Abzeichen nur bei count > 0; Monitor aus zeigt keine Inhaltszeilen', () => { + const red = (n) => { const { ctx, rects } = makeCtx(); P.drawMailbox(ctx, 0, 0, n); return rects.filter((r) => r.fill === PAL.red).length } + assert.equal(red(0), 0); assert.ok(red(3) >= 1) + const lines = (on) => { const { ctx, rects } = makeCtx(); P.drawMonitor(ctx, 0, 0, 1, '#abcdef', on); return rects.filter((r) => r.fill === '#abcdef').length } + assert.ok(lines(true) >= 2); assert.equal(lines(false), 0) + }) + test('ICONS: genau die 8 Suchräume, je 7 Zeilen à 7 Zeichen; drawSign schreibt den Text', () => { + assert.deepEqual(Object.keys(P.ICONS).sort(), ['aerzte', 'foren', 'handwerk', 'kleinanzeigen', 'laden', 'netz', 'portale', 'selbst']) + for (const [k, rows] of Object.entries(P.ICONS)) { assert.equal(rows.length, 7, k); for (const r of rows) assert.match(r, /^[.xa]{7}$/, k) } + const { ctx, texts, rects } = makeCtx(); P.drawSign(ctx, 0, 0, 40, 'noch nicht angeschlossen'); assert.equal(texts[0].text, 'noch nicht angeschlossen'); assert.ok(rects.length >= 2) + const t = makeCtx(); P.drawIcon(t.ctx, 'aerzte', 0, 0, '#3fb6b2'); assert.ok(t.rects.length >= 5) + }) + ``` + (`PAL` aus `sprites.js` per eigenem `loadModule` im Test importieren.) +- [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersProps.test.mjs`. Expected: FAIL `Could not resolve …/engine/props.js`. +- [ ] **Step 3: Implementieren** `engine/props.js` + Die elf Vorlagenfunktionen (`drawDesk` bis `drawScale`) 1:1 portieren (`px` aus `sprites.js`); `drawTray`, `drawSign`, `drawIcon` und `ICONS` sind neu; `drawMonitor` bekommt `on` (aus ⇒ nur Rahmen und Schirm, keine Zeilen, kein Blinken). `ICONS` als 7×7-Zeichenkarten (`aerzte` Kreuz, `handwerk` Hammer, `selbst` Person, `laden` Markise, `netz` Funkwellen, `kleinanzeigen` Umschlag mit @-Punkt, `portale` Zeilenliste, `foren` Sprechblase); `drawIcon` zeichnet `x` in `color`, `a` in `PAL.gold`. Entfällt aus der Vorlage: `drawConveyor`, `drawZone`. +- [ ] **Step 4: Bestehen sehen** — gleicher Befehl. Expected: `# pass 3`, `# fail 0`. +- [ ] **Step 5: Commit** + ```bash + cd $WK && git add src/components/workers/engine/props.js tests/unit/workersProps.test.mjs + git commit -m "Workers P2: Engine-Requisiten und Branchen-Icons" -m "Co-Authored-By: Claude Opus 5.5 " + ``` + +--- + +### P2-T08: Layout als Daten und Kamera + +**Files:** +- Create: `src/components/workers/engine/layout.js`, `src/components/workers/engine/camera.js` +- Test: `tests/unit/workersLayout.test.mjs`, `tests/unit/workersCamera.test.mjs` + +**Interfaces:** +- Consumes: `ROLES`, `FLOORS`, `ROOM_TITLES` (`workersRoster.js`). +- Produces: + ```js + // layout.js + export const WORLD = { w: 320, h: 256 } + export const FLOOR_PITCH = 23, BRIDGE_H = 34 + export const ROOM_META // roomId → { kind, team }; kinds: bruecke radar inbox feed board scout waage mail schreiber sortier vertrag tisch pruefung pult tresor archiv + export function buildLayout(roles = ROLES, floors = FLOORS): { floors:[{ id, index, title, top, floorY, h, rooms:[{ id, kind, title, team, x, w, workerId|null }] }], homes:{ [workerId]: { x, y, floor, room } }, spots:{ pult, tresor, aussen, archiv } } + export function floorIndex(layout, id) · roomById(layout, roomId) · homeOf(layout, workerId) · fy(layout, i) · cabTop(layout, i) + // camera.js + export const ZOOM_MIN = 1, ZOOM_MAX = 3 + export function fitZoom(cssWidth): number + export function createCamera(): { zoom:1, x:0, y:0 } + export function clampCamera(cam, world = WORLD): cam + export function zoomAt(cam, factor, fx, fy): cam // fx, fy = Bruchteile 0..1 der Ansicht; der Weltpunkt unter (fx,fy) bleibt stehen + export function panBy(cam, dxFrac, dyFrac): cam // in Bruchteilen der Ansicht + export function centerOn(cam, x, y): cam + export function viewToWorld(cam, vx, vy): { x, y } // vx, vy = Bruchteile 0..1 der Ansicht → logische Weltkoordinaten + export function worldToView(cam, x, y): { vx, vy } + export function pickAgent(boxes, x, y, pad = 0): id | null // boxes: [{ id, box:{x,y,w,h} }] in Zeichenreihenfolge, der letzte liegt oben + ``` + +- [ ] **Step 1: Failing tests schreiben** + `tests/unit/workersLayout.test.mjs`: + ```js + const { buildLayout, homeOf, roomById, fy, cabTop, WORLD } = await loadModule('src/components/workers/engine/layout.js') + const { ROLES, FLOORS } = await loadModule('src/lib/workersRoster.js') + const layout = buildLayout() + const rooms = (id) => layout.floors.find((f) => f.id === id).rooms.map((r) => [r.id, r.x, r.w]) + test('10 Etagen in FLOORS-Reihenfolge mit festen Höhen, Welt 320×256', () => { + assert.deepEqual(layout.floors.map((f) => f.id), [...FLOORS]); assert.deepEqual([WORLD.w, WORLD.h], [320, 256]) + assert.deepEqual(layout.floors.map((f) => [f.top, f.floorY]), [[6, 40], [41, 63], [64, 86], [87, 109], [110, 132], [133, 155], [156, 178], [179, 201], [202, 224], [225, 247]]) + assert.equal(fy(layout, 3), 95); assert.equal(cabTop(layout, 3), 88) + }) + test('Räume je Etage', () => { + assert.deepEqual(rooms('anfragen'), [['netz', 42, 46], ['kleinanzeigen', 88, 46], ['portale', 134, 46], ['foren', 180, 48]]) + assert.deepEqual(rooms('gespraeche').map((r) => r[0]), ['mail', 'forum', 'posteingang', 'abschluss']) + assert.deepEqual(rooms('werkstatt'), [['tisch1', 42, 62], ['tisch2', 104, 62], ['tisch3', 166, 62]]) + for (const id of ['bruecke', 'bewertung', 'pruefung', 'freigabe', 'tresor', 'keller']) assert.deepEqual(rooms(id).map((r) => [r[1], r[2]]), [[42, 186]], id) + }) + test('19 Worker haben genau einen Platz im eigenen Raum; Freigabe und Tresor ohne Worker', () => { + for (const r of ROLES) { const h = homeOf(layout, r.workerId), room = roomById(layout, r.room); const wide = r.workerId === 'otto' ? 24 : 12 + assert.ok(h.x >= room.x && h.x + wide <= room.x + room.w, r.workerId); assert.equal(h.floor, FLOORS.indexOf(r.floor)) } + assert.equal(homeOf(layout, 'otto').y, 13); assert.equal(homeOf(layout, 'kim').y, 49) + assert.deepEqual(layout.floors.filter((f) => f.rooms.some((r) => !r.workerId)).map((f) => f.id), ['freigabe', 'tresor']) + assert.deepEqual(layout.spots, { pult: { x: 135, y: 186 }, tresor: { x: 163, y: 213 }, aussen: { x: 324, y: 212 }, archiv: { x: 130, y: 231 } }) + }) + test('neue Räume kommen als Daten; unbekannter Raum wirft', () => { + assert.throws(() => buildLayout([{ workerId: 'x', name: 'X', floor: 'anfragen', room: 'zz', model: 'haiku', role: '', caps: [] }], ['anfragen']), /Unbekannter Raum: zz/) + }) + ``` + `tests/unit/workersCamera.test.mjs`: + ```js + const C = await loadModule('src/components/workers/engine/camera.js') + test('fitZoom: ab 720 px 1, darunter wachsend bis 2,2', () => { + assert.equal(C.fitZoom(1280), 1); assert.equal(C.fitZoom(720), 1); assert.ok(Math.abs(C.fitZoom(390) - 720 / 390) < 1e-9); assert.equal(C.fitZoom(200), 2.2) + }) + test('zoomAt hält den Punkt unter dem Zeiger fest, klemmt Zoom und Rand', () => { + const p0 = C.viewToWorld(C.createCamera(), 0.25, 0.75) + const cam = C.zoomAt(C.createCamera(), 2, 0.25, 0.75) + assert.equal(cam.zoom, 2); const p1 = C.viewToWorld(cam, 0.25, 0.75); assert.ok(Math.abs(p1.x - p0.x) < 1e-9 && Math.abs(p1.y - p0.y) < 1e-9) + assert.equal(C.zoomAt(C.createCamera(), 10, 0.5, 0.5).zoom, 3); assert.equal(C.zoomAt(C.createCamera(), 0.1, 0.5, 0.5).zoom, 1) + const edge = C.panBy({ zoom: 2, x: 0, y: 0 }, 5, 5); assert.deepEqual([edge.x, edge.y], [160, 128]) + assert.deepEqual([C.panBy({ zoom: 2, x: 10, y: 10 }, -5, -5).x, C.panBy({ zoom: 2, x: 10, y: 10 }, -5, -5).y], [0, 0]) + }) + test('viewToWorld und worldToView sind zueinander invers; centerOn', () => { + const cam = { zoom: 2.5, x: 40, y: 60 } + const w = C.viewToWorld(cam, 0.3, 0.6), v = C.worldToView(cam, w.x, w.y); assert.ok(Math.abs(v.vx - 0.3) < 1e-9 && Math.abs(v.vy - 0.6) < 1e-9) + const c = C.centerOn({ zoom: 2, x: 0, y: 0 }, 160, 128); assert.deepEqual([c.x, c.y], [80, 64]) + }) + test('pickAgent: oberste Figur gewinnt, Polster vergrößert die Fläche, Treffer nur im Kasten', () => { + const boxes = [{ id: 'a', box: { x: 10, y: 10, w: 12, h: 16 } }, { id: 'b', box: { x: 16, y: 10, w: 12, h: 16 } }] + assert.equal(C.pickAgent(boxes, 18, 14), 'b'); assert.equal(C.pickAgent(boxes, 11, 14), 'a'); assert.equal(C.pickAgent(boxes, 40, 14), null) + assert.equal(C.pickAgent(boxes, 30, 14, 0), null); assert.equal(C.pickAgent(boxes, 30, 14, 3), 'b') + }) + ``` +- [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersLayout.test.mjs tests/unit/workersCamera.test.mjs`. Expected: FAIL `Could not resolve …/layout.js`. +- [ ] **Step 3: Implementieren** + `layout.js`: Etage 0 `top` 6 / `floorY` 40, Etage `i ≥ 1`: `top = 41 + 23*(i-1)`, `floorY = top + 22`; `fy(layout, i) = floorY − 14`, `cabTop(layout, i) = floorY − 21` (Kabinenhöhe als Zahl, `layout.js` importiert `lift.js` nicht). Räume je Etage aus `roles` (Reihenfolge der Rollen) plus `{ id:'pult' }` in `freigabe` und `{ id:'tresor' }` in `tresor`; Aufteilung: 4 Räume ⇒ x `[42, 88, 134, 180]`, w `[46, 46, 46, 48]`; 3 Räume ⇒ x `[42, 104, 166]`, w 62; 1 Raum ⇒ 42/186; sonst gleichmäßig. Heim: `x = room.x + floor((room.w - breite)/2)` mit `breite` 24 für Otto, sonst 12; `y = fy` = `floorY - 14`, Otto `floorY - 27`. `ROOM_META` aus der Tabelle (netz→radar, kleinanzeigen→inbox, portale→feed, foren→board, aerzte/handwerk/selbst/laden→scout, waage, mail, forum→schreiber, posteingang→sortier, abschluss→vertrag, tisch1–3→tisch, pruefung, pult, tresor, archiv, bruecke); Team-Farbschlüssel aus `TEAM` (Suche-Räume `radar` bzw. eigener Branchenschlüssel, Gespräche/Brücke/Pult `freigabe`, Tische `werkstatt`, Waage `bewertung`). `spots` wie im Test. `camera.js`: Sichtfeld = `W/zoom × H/zoom`; `zoomAt` setzt `zoom = clamp(zoom*factor, 1, 3)` und `x = wx - fx*W/zoom'` (mit `wx` vorher aus `viewToWorld`), danach `clampCamera`; `fitZoom = w >= 720 ? 1 : min(2.2, 720/w)`; `pickAgent` prüft von hinten nach vorn die um `pad` vergrößerten Kästen. +- [ ] **Step 4: Bestehen sehen** — gleicher Befehl wie Step 2. Expected: `# pass 8`, `# fail 0`. +- [ ] **Step 5: Commit** + ```bash + cd $WK && git add src/components/workers/engine/layout.js src/components/workers/engine/camera.js tests/unit/workersLayout.test.mjs tests/unit/workersCamera.test.mjs + git commit -m "Workers P2: Layout als Daten (10 Etagen, 19 Plätze) und Kamera mit Umkehr-Transformation" -m "Co-Authored-By: Claude Opus 5.5 " + ``` + +--- + +### P2-T09: Effekt-System `fx.js` + +**Files:** +- Create: `src/components/workers/engine/fx.js` +- Test: `tests/unit/workersFx.test.mjs` + +**Interfaces:** +- Consumes: `px`, `PAL` (`sprites.js`), `drawCoin`, `drawStamp` (`props.js`). Quelle: `scene-turm.js` Z. 366–393 (`addFx`/`updateFx`/`drawFx`/`envelope`). +- Produces: + ```js + export const FX_NAMES, MAX_FX // ['paper','envelope','coin','stampOk','stampNo','sparkle','alert','drawer'], 80 + export function createFx(): { add(life, draw, onEnd?, delay = 0), update(dt), draw(ctx), clear(), size() } + export function spawnFx(fx, name, { from, to, delay = 0, onEnd }): void // from/to = { x, y } in Weltkoordinaten + ``` + `draw(ctx, progress 0..1, effect)`; `onEnd` genau einmal beim Ablauf; `clear()` ruft kein `onEnd`; über `MAX_FX` hinaus wird der neue Effekt verworfen und sein `onEnd` sofort gerufen. + +- [ ] **Step 1: Failing test schreiben** (`const F = await loadModule('…/engine/fx.js')`) + ```js + test('Verzögerung, Lebensdauer, onEnd genau einmal', () => { + const fx = F.createFx(), log = []; fx.add(1, (c, p) => log.push(['draw', p]), () => log.push(['end']), 0.5) + const { ctx } = makeCtx(); fx.update(0.4); fx.draw(ctx); assert.equal(log.length, 0) + fx.update(0.4); fx.draw(ctx); assert.equal(log.at(-1)[0], 'draw') + fx.update(1); assert.deepEqual(log.at(-1), ['end']); fx.update(1); assert.equal(log.filter((l) => l[0] === 'end').length, 1); assert.equal(fx.size(), 0) + }) + test('Obergrenze 80: Überschuss wird verworfen und meldet onEnd; clear ruft kein onEnd', () => { + const fx = F.createFx(); let ended = 0 + for (let i = 0; i < 200; i++) fx.add(5, () => {}, () => { ended++ }) + assert.equal(fx.size(), F.MAX_FX); assert.equal(ended, 120); fx.clear(); assert.equal(fx.size(), 0); assert.equal(ended, 120) + }) + test('jeder Effektname zeichnet und endet in höchstens 6 s', () => { + for (const name of F.FX_NAMES) { const fx = F.createFx(), { ctx, rects } = makeCtx(); let ended = false + F.spawnFx(fx, name, { from: { x: 50, y: 100 }, to: { x: 150, y: 60 }, onEnd: () => { ended = true } }) + for (let i = 0; i < 60; i++) { fx.update(0.1); fx.draw(ctx) } + assert.ok(rects.length > 0, name); assert.equal(fx.size(), 0, name); assert.ok(ended, name) } + }) + ``` +- [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersFx.test.mjs`. Expected: FAIL `Could not resolve …/engine/fx.js`. +- [ ] **Step 3: Implementieren** `engine/fx.js` + Lebensdauern: `paper` 0,9 s (Bogen von `from` nach `to`), `envelope` 3,4 s (Bogen mit Höhe 12, wie `sentOut`), `coin` 0,7 s (Bogen, 14 hoch), `stampOk`/`stampNo` 1,2 s (Stempel hüpft über `from`), `sparkle` 1,0 s, `alert` 1,5 s (blinkendes „!“ über `from`), `drawer` 0,9 s (Papier fliegt von `from` nach `to`, Ende kürzer 3 px). Jeder Effekt zeichnet nur mit `px`/`label`. +- [ ] **Step 4: Bestehen sehen** — gleicher Befehl. Expected: `# pass 3`, `# fail 0`. +- [ ] **Step 5: Commit** + ```bash + cd $WK && git add src/components/workers/engine/fx.js tests/unit/workersFx.test.mjs + git commit -m "Workers P2: Effekt-System mit Obergrenze" -m "Co-Authored-By: Claude Opus 5.5 " + ``` + +--- + +### P2-T10: Aufzug `lift.js` + +**Files:** +- Create: `src/components/workers/engine/lift.js` +- Test: `tests/unit/workersLift.test.mjs` + +**Interfaces:** +- Consumes: `cabTop` (`layout.js`), `px`, `tint` (`sprites.js`). Quelle: `scene-turm.js` Z. 316–330 (Kabine), 354–357, 466–492 (Zustandsautomat). +- Produces: + ```js + export const LIFT = { cabWx: 22, exitX: 50, slot0: 52, slotStep: 14, speed: 110, cabH: 21 } + export function createLift(layout): { y, state, request(agent, from, to), update(dt), cancel(agent), reset(), at(floorIndex), drawBack(ctx), drawFront(ctx) } + ``` + `request` ⇒ `req = { a, from, to, st }` mit `st`: `queued → open → boarded → arrived → out`, außerdem `cancelled`; die Übergänge `boarded` und `out` setzt der Fahrgast (Regie), alle anderen der Aufzug. `state`: `idle|toPick|boarding|closing|riding|unloading`. Während der Fahrt folgt `req.a`: `a.y = lift.y + 7`, `a.x = LIFT.cabWx`, `a.frame = 0`. + +- [ ] **Step 1: Failing test schreiben** (`const { createLift, LIFT } = await loadModule('…/engine/lift.js')`, `buildLayout`, `cabTop` aus `layout.js`) + ```js + const layout = buildLayout() + function until(lift, fn, max = 12) { let t = 0; while (!fn() && t < max) { lift.update(0.05); t += 0.05 } return t } + test('Fahrt 1 → 8: queued, open, boarded, arrived, out; Fahrgast folgt der Kabine; danach idle', () => { + const lift = createLift(layout), a = { x: 0, y: 0, frame: 1 }, req = lift.request(a, 1, 8) + assert.equal(req.st, 'queued') + let t = until(lift, () => req.st === 'open'); assert.ok(lift.at(1)); assert.equal(lift.state, 'boarding') + req.st = 'boarded'; t += until(lift, () => req.st === 'arrived'); assert.ok(lift.at(8)); assert.equal(a.y, cabTop(layout, 8) + 7); assert.equal(a.x, LIFT.cabWx) + assert.ok(t < 12); req.st = 'out'; lift.update(0.05); assert.equal(lift.state, 'idle') + }) + test('FIFO: zweite Anfrage wartet bis die erste ausgestiegen ist', () => { + const lift = createLift(layout), r1 = lift.request({}, 1, 4), r2 = lift.request({}, 5, 2) + until(lift, () => r1.st === 'open'); r1.st = 'boarded'; until(lift, () => r1.st === 'arrived'); assert.equal(r2.st, 'queued') + r1.st = 'out'; until(lift, () => r2.st === 'open'); assert.equal(r2.st, 'open') + }) + test('cancel und reset geben die Kabine frei (kein Festhängen)', () => { + const lift = createLift(layout), a = {}, r1 = lift.request(a, 7, 1), r2 = lift.request({}, 2, 3) + lift.update(0.05); lift.cancel(a); assert.equal(r1.st, 'cancelled'); until(lift, () => r2.st === 'open'); assert.equal(r2.st, 'open') + lift.reset(); assert.equal(r2.st, 'cancelled'); assert.equal(lift.state, 'idle') + }) + ``` +- [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersLift.test.mjs`. Expected: FAIL `Could not resolve …/engine/lift.js`. +- [ ] **Step 3: Implementieren** `engine/lift.js` + Zustandsautomat 1:1 aus der Vorlage (Z. 466–492) mit `cabMove` (`speed` 110) und `follow`; neu: `cancel(agent)` markiert Anfragen des Agenten `cancelled`, entfernt sie aus der Warteschlange und setzt bei der laufenden Anfrage `req = null`, `state = 'idle'`; `reset()` macht dasselbe für alle; `update` überspringt `cancelled`. Startposition `y = cabTop(layout, 2)`. `drawBack`/`drawFront` aus Z. 316–330 (Kabine 20 × 21 bei x 18, Seil, Lampe), Etagenlampen zeichnet `building.js` über `at(i)`. +- [ ] **Step 4: Bestehen sehen** — gleicher Befehl. Expected: `# pass 3`, `# fail 0`. +- [ ] **Step 5: Commit** + ```bash + cd $WK && git add src/components/workers/engine/lift.js tests/unit/workersLift.test.mjs + git commit -m "Workers P2: Aufzug mit Abbruch und Reset" -m "Co-Authored-By: Claude Opus 5.5 " + ``` + +--- + +### P2-T11: Räume und Gebäude (`rooms.js`, `building.js`) + +**Files:** +- Create: `src/components/workers/engine/rooms.js`, `src/components/workers/engine/building.js` +- Test: `tests/unit/workersRooms.test.mjs` + +**Interfaces:** +- Consumes: `layout.js` (`buildLayout`, `ROOM_META`), `props.js`, `sprites.js`, `ROOM_TITLES`/`FLOOR_TITLES` (`workersRoster.js`), `lift.at`. Quelle: `scene-turm.js` Z. 99–314. +- Produces: + ```js + // rooms.js + export function createLook(): { greyRooms:Set, busyRooms:Set, pultOpen:0, dealsMonth:0, botLeads:0, trophyPage:0, pageT:0, trophyPop:0, vaultFlash:0, paused:false } + export function trophyPaging(total, page, perPage = 8): { shown, page, pages, from } + export function drawRooms(ctx, layout, t, look): void + // building.js + export function drawOutside(ctx): void // Boden, Erde, Bodenlinie bei y 224 (Tresor-Etage), Dachlinie + export function drawTower(ctx, layout, t, lift): void // Hülle, Schacht, Etagenböden/-wände, Lampen, Etagenschilder rechts außen (x 234) + export function drawPauseVeil(ctx, look): void // weißer Schleier 45 % und „Pausiert“, nur bei look.paused + ``` + Raumtabelle (`kind` ⇒ Inhalt): bruecke Konsole mit 2 Monitoren, Tafel, Fenster, Pflanze · radar `drawRadar` + Tisch · inbox Tisch, Monitor, `drawTray` · feed Tisch, Monitor mit Zeilen · board Tisch, Monitor, Sprechblasen-Tafel · scout Tisch in Teamfarbe, Monitor, Branchen-Icon · waage `drawScale`, Tisch · mail Tisch, Monitor, Briefstapel · schreiber Tisch, Monitor (Schreib-Animation) · sortier Tisch, 3 Ablagekörbe · vertrag Tisch, Vertrag, Stempel · tisch Tisch, Monitor · pruefung Tisch, 2 Stempel, Handy · pult großer `drawMailbox` mit Zahl · tresor Pokalbrett (`trophyPaging`), `drawVault`, Zähler `${dealsMonth} Deals` · archiv 4 Schränke, Kartons, Zähler `${botLeads} Bot-Leads`. Jeder Raum bekommt Titel (`ROOM_TITLES`) und Teamfarbton (Alpha 0,10); Monitore laufen nur in Räumen aus `look.busyRooms`; Räume in `look.greyRooms`: Grautönung, Monitore aus, `drawSign` „noch nicht angeschlossen“ mittig. + +- [ ] **Step 1: Failing test schreiben** (`const R = await loadModule('…/engine/rooms.js')`, `B = …/building.js`, Layout/Roster/`PAL`) + ```js + const layout = buildLayout() + const draw = (look, t = 1) => { const m = makeCtx(); R.drawRooms(m.ctx, layout, t, look); return m } + test('trophyPaging: 8 je Seite, Seite wird geklemmt', () => { + assert.deepEqual(R.trophyPaging(0, 0), { shown: 0, page: 0, pages: 1, from: 0 }); assert.deepEqual(R.trophyPaging(8, 0), { shown: 8, page: 0, pages: 1, from: 0 }) + assert.deepEqual(R.trophyPaging(9, 1), { shown: 1, page: 1, pages: 2, from: 8 }); assert.deepEqual(R.trophyPaging(9, 5), { shown: 1, page: 1, pages: 2, from: 8 }) + assert.deepEqual(R.trophyPaging(25, -1), { shown: 8, page: 0, pages: 4, from: 0 }) + }) + test('alle 21 Räume zeichnen, jeder Titel erscheint', () => { + const { texts, rects } = draw(R.createLook()); assert.ok(rects.length > 300) + for (const title of Object.values(ROOM_TITLES)) assert.ok(texts.some((x) => x.text === title), title) + }) + test('graue Räume tragen das Schild, sonst keins (Review Focus 4)', () => { + const look = R.createLook(); look.greyRooms = new Set(['kleinanzeigen', 'portale']) + assert.equal(draw(look).texts.filter((x) => x.text === 'noch nicht angeschlossen').length, 2) + assert.equal(draw(R.createLook()).texts.filter((x) => x.text === 'noch nicht angeschlossen').length, 0) + }) + test('Pult-Zahl, Pokal- und Bot-Lead-Zähler', () => { + const look = R.createLook(); look.pultOpen = 7; look.dealsMonth = 9; look.botLeads = 112 + const t = draw(look).texts.map((x) => x.text); assert.ok(t.includes('7')); assert.ok(t.includes('9 Deals')); assert.ok(t.includes('112 Bot-Leads')) + assert.ok(!draw(R.createLook()).texts.some((x) => x.text === '7')) + }) + test('Gebäude: Etagenschilder, Lampen, Pausenschleier', () => { + const m = makeCtx(); B.drawTower(m.ctx, layout, 1, { at: (i) => i === 3 }); for (const title of Object.values(FLOOR_TITLES)) assert.ok(m.texts.some((x) => x.text === title), title) + const on = makeCtx(); const look = R.createLook(); look.paused = true; B.drawPauseVeil(on.ctx, look); assert.ok(on.texts.some((x) => x.text === 'Pausiert')) + const off = makeCtx(); B.drawPauseVeil(off.ctx, R.createLook()); assert.equal(off.texts.length, 0); B.drawOutside(makeCtx().ctx) + }) + ``` +- [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersRooms.test.mjs`. Expected: FAIL `Could not resolve …/engine/rooms.js`. +- [ ] **Step 3: Implementieren** `engine/rooms.js`, `engine/building.js` + Eine `drawRoom(ctx, room, floor, t, look)` je `kind` (Tabelle oben) über ein Objekt `ROOM_DRAWERS[kind]`, alle Koordinaten relativ zu `room.x`/`floor.floorY`; Schriften nur über `label`. Räume mit `workerId` null (`pult`, `tresor`) werden nie grau. `trophyPaging`: `pages = max(1, ceil(total/perPage))`, `page` auf `0..pages-1` geklemmt, `from = page*perPage`, `shown = min(perPage, total-from)`. `drawTower`: Hülle/Schacht/Böden/Schachttüren aus Vorlage Z. 118–172 auf 10 Etagen, Etagenschild rechts außen als Teamfarbpunkt plus `FLOOR_TITLES`-Text bei x 234. Die Stadtsilhouette der Vorlage entfällt. +- [ ] **Step 4: Bestehen sehen** — gleicher Befehl. Expected: `# pass 5`, `# fail 0`. +- [ ] **Step 5: Commit** + ```bash + cd $WK && git add src/components/workers/engine/rooms.js src/components/workers/engine/building.js tests/unit/workersRooms.test.mjs + git commit -m "Workers P2: Räume nach kind, Pokal-Seiten, graue Räume, Gebäudehülle" -m "Co-Authored-By: Claude Opus 5.5 " + ``` + +--- + +### P2-T12: Regie 1 – Agenten, Snapshot, Posen, Leerlauf (`director.js`) + +**Files:** +- Create: `src/components/workers/engine/director.js`, `tests/unit/helpers/workersFixtures.mjs` +- Test: `tests/unit/workersDirector.test.mjs` + +**Interfaces:** +- Consumes: `layout.js` (`homeOf`, `fy`), `lift.js`, `fx.js`, `rooms.js` (`createLook`, `trophyPaging`), `sprites.js` (`drawWorker`, `drawBubble`, `drawAlertMark`), `camera.js` (`pickAgent`), `workersRoster.js` (`ROLES`, `hatForWorker`), `workersEvents.js` (`indexWorkers`). +- Produces: + ```js + export const BACKLOG_KEEP = 6, GROUPS_PER_AGENT = 4, IDLE_COFFEE_S = 25, STEP_TIMEOUT_S = 45, BUBBLE_MAX = 3 + export function createDirector({ layout, lift, fx, look, roles = ROLES, rnd = Math.random }): { + agents, byId, applyState(state), pushEvents(events), update(dt), flush(), pending(), boxes(), reset(), debug() } + export function drawAgents(ctx, director, selectedId, t): void // y-sortiert, setzt agent.box, Blasen, Auswahlpfeil, „!“ bei alert + ``` + Agent: `{ id, name, hat, scale, x, y, floor, facing, frame, pose:'stand'|'type'|'coffee'|'sleep', carrying, stackN, say, bubT, grey, alert, status, task, counters, lane, bob, idleT, cheer, q, cur, stepT, groups, box, home }`. + `tests/unit/helpers/workersFixtures.mjs`: `IDS` (die 19 workerIds in Rollenreihenfolge), `makeState({ statuses = {}, connected = {}, kpis = {}, paused = false, lastEventTs } = {})` ⇒ Zustand mit 19 `workers` (`{ workerId, status: statuses[id] ?? 'arbeitet', task: '', counters: {}, connected: connected[id] ?? true }`) und `kpis` (Standard `{ newAbosMonth: 3, mrrNewEur: 60, dealsMonth: 3, openThreads: 7, pultOpen: 2, botLeads: 112, apiTodayUsd: 8.5, apiMonthUsd: 50, dailyBudgetUsd: 10, monthlyBudgetUsd: 250 }`, überschrieben durch `kpis`), `ev(kind, workerId, extra)` ⇒ Ereignis mit eindeutiger `id`, `ts` (zählt je Aufruf um 1 s hoch), `text: 'Text'`. + +- [ ] **Step 1: Failing test schreiben** (`tests/unit/workersDirector.test.mjs`) + ```js + function setup() { const layout = buildLayout(), lift = createLift(layout), fx = createFx(), look = createLook(); return { layout, lift, fx, look, d: createDirector({ layout, lift, fx, look, rnd: () => 0.5 }) } } + test('erster Snapshot: alle 19 an ihrem Platz, Pose nach Status, „!“ bei Fehler', () => { + const { d, layout } = setup(); d.applyState(makeState({ statuses: { kim: 'leerlauf', rita: 'wartet', bea: 'fehler' } })) + assert.equal(d.agents.length, 19) + for (const a of d.agents) { const h = homeOf(layout, a.id); assert.deepEqual([a.x, a.y], [h.x, h.y], a.id) } + assert.deepEqual([d.byId.kim.pose, d.byId.rita.pose, d.byId.otto.pose, d.byId.bea.alert], ['coffee', 'stand', 'type', true]) + }) + test('nicht angeschlossen oder im Zustand fehlend ⇒ grau, schläft, Raum in look.greyRooms (Review Focus 4)', () => { + const { d, look } = setup(); const st = makeState({ connected: { kim: false } }); st.workers = st.workers.filter((w) => w.workerId !== 'pia') + d.applyState(st) + assert.deepEqual([d.byId.kim.grey, d.byId.kim.pose, d.byId.pia.grey, d.byId.pia.pose, d.byId.bob.grey], [true, 'sleep', true, 'sleep', false]) + assert.deepEqual([...look.greyRooms].sort(), ['kleinanzeigen', 'portale']) + }) + test('Kennzahlen landen in look; neuer Pokal blinkt nur bei steigender Zahl, nicht beim ersten Snapshot', () => { + const { d, look } = setup(); d.applyState(makeState({ kpis: { pultOpen: 4, dealsMonth: 9, botLeads: 112 } })) + assert.deepEqual([look.pultOpen, look.dealsMonth, look.botLeads, look.trophyPop], [4, 9, 112, 0]) + d.applyState(makeState({ kpis: { pultOpen: 4, dealsMonth: 10, botLeads: 112 } })); assert.ok(look.trophyPop > 0) + d.applyState(makeState({ paused: true })); assert.equal(look.paused, true) + }) + test('Leerlauf: erst Kaffee, nach 25 s Schlaf; Pokal-Seiten blättern alle 6 s', () => { + const { d, look } = setup(); d.applyState(makeState({ statuses: { kim: 'leerlauf' }, kpis: { dealsMonth: 9 } })) + const step = (n) => { for (let i = 0; i < n; i++) d.update(0.1) } + step(70); assert.equal(look.trophyPage, 1) // 7 s: erste Blätterung bei 6 s + step(60); assert.equal(look.trophyPage, 0); assert.equal(d.byId.kim.pose, 'coffee') // 13 s: zweite Blätterung bei 12 s + step(140); assert.equal(d.byId.kim.pose, 'sleep') // 27 s > 25 s + }) + test('drawAgents: Trefferflächen für alle 19, jeder ist anklickbar', () => { + const { d } = setup(); d.applyState(makeState({ connected: { kim: false }, statuses: { rita: 'fehler' } })); const { ctx } = makeCtx(); drawAgents(ctx, d, 'bob', 1) + const boxes = d.boxes(); assert.equal(boxes.length, 19) + for (const { id, box } of boxes) assert.equal(pickAgent(boxes, box.x + box.w / 2, box.y + box.h / 2), id, id) + }) + ``` +- [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersDirector.test.mjs`. Expected: FAIL `Could not resolve …/engine/director.js`. +- [ ] **Step 3: Implementieren** `engine/director.js` (Teil 1) und `tests/unit/helpers/workersFixtures.mjs` + `applyState(state)`: `workers = indexWorkers(state)`; je Agent `w` = Eintrag oder fehlend ⇒ `grey`; `grey = status === 'grau'`; `look.greyRooms` aus den Räumen grauer Agenten; `look.busyRooms` = Räume mit Status `arbeitet`; Kennzahlen nach `look`; steigt `kpis.dealsMonth` gegenüber dem vorigen Snapshot (nicht beim ersten), `look.trophyPop = 1.2`; `look.paused = !!state.paused`. Pose-Regel `poseFor(a)`: grau ⇒ `sleep`; `leerlauf` ⇒ `idleT >= 25 ? sleep : coffee`; `arbeitet` ⇒ `type`; sonst `stand`; `alert = status === 'fehler'`. Der **erste** Snapshot setzt alle Agenten auf `homeOf`; spätere Snapshots bewegen nie, sie setzen Status/Pose nur bei Agenten ohne laufende Gruppe (sonst nach Ende der Gruppe). `update(dt)`: bei `look.paused` sofort zurück; sonst Agenten (`idleT` wächst bei Status `leerlauf`, Pose jedes Bild neu nach `poseFor` für Agenten ohne laufende Gruppe, Wippen, Blasen-Timer), `lift.update`, `fx.update`, `look.trophyPop/vaultFlash` herunterzählen, `look.pageT += dt` ⇒ bei ≥ 6 `trophyPage = (trophyPage + 1) % pages`. `drawAgents`: nach `y + lane + bob + 13*scale` sortieren, `drawWorker` mit `pose/grey/t`, `agent.box` merken, Stapel-Papiere und „!“ zeichnen, Blasen zuletzt (Otto seitlich wie in der Vorlage Z. 929–944). `bubble(a, text)`: `cutBubble`, höchstens `BUBBLE_MAX` gleichzeitig, sonst stillschweigend verworfen. `pushEvents`, `flush`, `reset`, `debug` kommen erst in P2-T13; hier liefert `pending()` vorerst 0. +- [ ] **Step 4: Bestehen sehen** — gleicher Befehl. Expected: `# pass 5`, `# fail 0`. +- [ ] **Step 5: Commit** + ```bash + cd $WK && git add src/components/workers/engine/director.js tests/unit/helpers/workersFixtures.mjs tests/unit/workersDirector.test.mjs + git commit -m "Workers P2: Regie 1 (Agenten, Snapshot ohne Animation, Posen, Leerlauf)" -m "Co-Authored-By: Claude Opus 5.5 " + ``` + +--- + +### P2-T13: Regie 2 – Schritte, Aufzugfahrten, Rückstau, Wachhund + +**Files:** +- Modify: `src/components/workers/engine/director.js` +- Test: `tests/unit/workersDirector.test.mjs` (erweitern) + +**Interfaces:** +- Consumes: `eventToSteps`, `reduceEvents`, `cutBubble` (P2-T03), `LIFT` (P2-T10), `spawnFx` (P2-T09), `layout.spots`. +- Produces (ergänzt P2-T12): `pushEvents(events)`, `flush()`, `pending()` (wartende + laufende Gruppen über alle Agenten), `reset()` (Warteschlangen leeren, Aufzug und Effekte zurücksetzen, alle Agenten heim), `debug()` ⇒ `{ door: number[10], groups: number }`. + +- [ ] **Step 1: Failing tests ergänzen** (`ev`, `IDS`, `makeState` aus den Fixtures) + ```js + const run = (d, max = 60) => { let t = 0; while (d.pending() > 0 && t < max) { d.update(0.05); t += 0.05 } return t } + test('Übergabe über zwei Etagen: Rita bringt Bea ein Dokument, fährt Aufzug, steht danach wieder zuhause', () => { + const { d, lift, layout } = setup(); d.applyState(makeState()); d.pushEvents([ev('uebergabe', 'rita', { to: 'bea', text: 'Rita bringt 2 Funde zu Bea' })]) + assert.ok(d.pending() >= 1); let ride = false, t = 0; while (d.pending() > 0 && t < 60) { d.update(0.05); t += 0.05; if (lift.state !== 'idle') ride = true } + assert.ok(ride); assert.ok(t < 40, String(t)); const h = homeOf(layout, 'rita') + assert.deepEqual([Math.round(d.byId.rita.x), Math.round(d.byId.rita.y), d.byId.rita.carrying, lift.state], [h.x, h.y, false, 'idle']); assert.equal(d.byId.rita.task, 'Rita bringt 2 Funde zu Bea') + }) + test('gleiche Etage: kein Aufzug', () => { + const { d, lift } = setup(); d.applyState(makeState()); d.pushEvents([ev('uebergabe', 'kim', { to: 'pia' })]); let ride = false; for (let i = 0; i < 1200 && d.pending() > 0; i++) { d.update(0.05); if (lift.state !== 'idle') ride = true } + assert.equal(ride, false); assert.equal(d.pending(), 0) + }) + test('Rückstau: 300 Ereignisse ⇒ höchstens 6 Gruppen, Rest still, nach ≤ 60 s ruhig, letzte Aufgaben stehen (Review Focus 2)', () => { + const { d, lift, fx } = setup(); d.applyState(makeState()) + d.pushEvents(Array.from({ length: 300 }, (_, i) => ev('uebergabe', IDS[i % 19], { to: IDS[(i + 3) % 19], text: `Aufgabe ${i}` }))) + assert.ok(d.pending() <= 6, String(d.pending())); assert.ok(run(d) < 60); assert.equal(d.pending(), 0); assert.equal(lift.state, 'idle') + for (let k = 0; k < 19; k++) { const last = 299 - ((299 - k) % 19); assert.equal(d.byId[IDS[k]].task, `Aufgabe ${last}`, IDS[k]) } + d.pushEvents(Array.from({ length: 10 }, (_, i) => ev('fund', 'kim', { text: `K${i}` }))); assert.ok(d.byId.kim.groups <= 4) + }) + test('graue, fremde und unbekannte Ereignisse: keine Arbeit, kein Wurf (Review Focus 4)', () => { + const { d } = setup(); d.applyState(makeState({ connected: { kim: false } })) + d.pushEvents([ev('gelesen', 'kim'), ev('fund', 'zed'), ev('neueArt', 'rita'), ev('uebergabe', 'kim', { to: 'bea' })]) + assert.equal(d.byId.kim.say, null); assert.equal(d.byId.kim.pose, 'sleep'); assert.equal(d.byId.kim.groups, 0) + }) + test('Blasen: höchstens drei gleichzeitig', () => { + const { d } = setup(); d.applyState(makeState()); d.pushEvents(['kim', 'rita', 'pia', 'finn', 'ali'].map((w) => ev('fund', w))); d.update(0.05) + assert.ok(d.agents.filter((a) => a.say).length <= 3) + }) + test('Pause friert ein, Fortsetzen läuft weiter', () => { + const { d } = setup(); d.applyState(makeState({ paused: true })); d.pushEvents([ev('uebergabe', 'kim', { to: 'pia' })]); const x0 = d.byId.kim.x + for (let i = 0; i < 400; i++) d.update(0.05); assert.equal(d.byId.kim.x, x0) + d.applyState(makeState({ paused: false })); for (let i = 0; i < 40; i++) d.update(0.05); assert.notEqual(d.byId.kim.x, x0) + }) + test('Aufzug zurückgesetzt mitten in der Fahrt: Fahrgast erholt sich, Türzähler 0; hängender Aufzug: Wachhund nach 45 s', () => { + const a = setup(); a.d.applyState(makeState()); a.d.pushEvents([ev('uebergabe', 'rita', { to: 'bea' })]) + let g = 0; while (a.lift.state !== 'riding' && g++ < 400) a.d.update(0.05) + assert.equal(a.lift.state, 'riding'); a.lift.reset(); assert.ok(run(a.d) < 10); assert.deepEqual(a.d.debug().door, new Array(10).fill(0)) + const layout = buildLayout(), fx = createFx(), look = createLook(), stuck = { y: 0, state: 'idle', request: (ag, f, t) => ({ a: ag, from: f, to: t, st: 'queued' }), update() {}, cancel() {}, reset() {}, at: () => false, drawBack() {}, drawFront() {} } + const d = createDirector({ layout, lift: stuck, fx, look }); d.applyState(makeState()); d.pushEvents([ev('uebergabe', 'rita', { to: 'bea' })]) + const t = run(d, 80); assert.ok(t >= 45 && t < 60, String(t)); assert.deepEqual(d.debug().door, new Array(10).fill(0)) + }) + test('flush: alles sofort ruhig, alle zuhause; Deal löst Münzen und Jubel aus', () => { + const { d, lift, fx, layout } = setup(); d.applyState(makeState()); d.pushEvents(Array.from({ length: 20 }, (_, i) => ev('uebergabe', IDS[i % 19], { to: IDS[(i + 5) % 19] }))) + d.flush(); assert.equal(d.pending(), 0); assert.equal(lift.state, 'idle'); assert.equal(fx.size(), 0) + for (const a of d.agents) { const h = homeOf(layout, a.id); assert.deepEqual([Math.round(a.x), Math.round(a.y)], [h.x, h.y], a.id) } + d.pushEvents([ev('deal', 'system')]); d.update(0.1); assert.ok(fx.size() >= 3); assert.ok(d.byId.otto.cheer > 0); run(d); for (let i = 0; i < 100; i++) d.update(0.1); assert.equal(fx.size(), 0) + }) + ``` +- [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersDirector.test.mjs`. Expected: FAIL (`d.pushEvents is not a function` bzw. Assertion-Fehler bei `pending`). +- [ ] **Step 3: Implementieren** in `engine/director.js` + `pushEvents(events)`: (1) `workers = reduceEvents(workers, events)`, **nur den Status** auf die Agenten übertragen (`agent.task` setzen allein `task`-Schritte und der Snapshot) und `look.busyRooms` sowie `look.greyRooms` aus den Agenten neu bilden (dieselbe Hilfsfunktion wie in `applyState`); (2) je Ereignis `ops = eventToSteps(e)`, Besitzer der Gruppe = `from` des ersten `give`, sonst `W` (wenn in `ROLES`), sonst `otto`; (3) die letzten `BACKLOG_KEEP` Gruppen sind **animiert**, alle älteren **still**; zusätzlich wird eine Gruppe still, wenn der Besitzer grau ist oder schon `GROUPS_PER_AGENT` Gruppen hält; (4) stille Gruppen führen sofort nur `task`/`pose` aus, animierte werden an `agent.q` gehängt (`groups++`). Besitzer-Queue wie in der Vorlage (`nextStep`/`stepAgent`, Z. 864–883), jeder Op wird zu Schritten: `task` setzt `agent.task`; `say` ruft `bubble` (Ziel `who`); `pose` setzt Pose und `idleT = 0` (bei grauen Agenten wirkungslos); `cheer` setzt `cheer = 2`; `fx` ruft `spawnFx` mit `from = posOf(at)`, `to = posOf(to)` (Agent: Kopfmitte, Platz: `layout.spots`; `drawer` ohne `to` ⇒ `spots.archiv`; bei `coin`-Ende `look.vaultFlash = 0.7`); `give` = Folge: Dokument aufnehmen (`carrying = true`) → zum Empfänger (bei anderer Etage `rideStep(vonEtage, nachEtage)`, Algorithmus unverändert aus der Vorlage Z. 435–464 mit `lift.request`, Wartepositionen `LIFT.slot0 + LIFT.slotStep * door[etage]++`) → neben den Empfänger laufen (links oder rechts, je nach Seite, Abstand 12), Empfänger `bubble('Danke!')` → `carrying = false` → Rückfahrt → `walkTo(home.x)` → Blickrichtung und `poseFor`. `rideStep` bricht in jeder Phase bei `req.st === 'cancelled'` ab (Agent sofort heim; der Türzähler wird nur zurückgenommen, wenn der Agent noch mitgezählt ist, also vor dem Einsteigen). Wachhund: läuft ein Schritt länger als `STEP_TIMEOUT_S`, wird `lift.cancel(agent)` gerufen, der Türzähler zurückgenommen, der Agent heimgesetzt und die Gruppe beendet. `pending()` = Summe `groups` + laufende. `flush()`: wiederholt `update(0.25)` (höchstens 2000 Mal) bis `pending() === 0`, setzt alle Agenten heim, `lift.reset()`, `fx.clear()`. `reset()` = leeren + heim. +- [ ] **Step 4: Bestehen sehen** — gleicher Befehl. Expected: `# pass 13`, `# fail 0` (5 aus P2-T12 + 8 neue). +- [ ] **Step 5: Commit** + ```bash + cd $WK && git add src/components/workers/engine/director.js tests/unit/workersDirector.test.mjs + git commit -m "Workers P2: Regie 2 (Schritte, Aufzugfahrten, Rückstau-Kappung, Wachhund)" -m "Co-Authored-By: Claude Opus 5.5 " + ``` + +--- + +### P2-T14: Szene `scene.js` (`mountTower`) + +**Files:** +- Create: `src/components/workers/engine/scene.js`, `tests/unit/helpers/engineVm.mjs` +- Test: `tests/unit/workersScene.test.mjs` + +**Interfaces:** +- Consumes: alle Engine-Module, `pickAgent`/Kamera, `createDirector`, `drawAgents`, `drawOutside/drawTower/drawPauseVeil`, `drawRooms`, `createLift`, `createFx`, `createLook`. +- Produces (Übersicht §7 plus E4): + ```js + export function mountTower(canvas, { onSelect, reducedMotion = false, coarsePointer = false, fontsReady, dpr = 1, raf, caf, now }): { + destroy(), setState(state), pushEvents(events), select(workerId|null), setPaused(bool), + resize(), zoomBy(faktor), resetView(), locate(workerId): { x, y } | null, stats(): { frames, avgDrawMs, zoom }, debug() } + export const FRAME_MS = 33, DT_MAX = 0.05, CANVAS_MAX_W = 2400, ZOOM_STEP = 1.4, DRAG_PX = 4 + ``` + `raf/caf/now` Standard: `globalThis.requestAnimationFrame` usw. beim **Aufruf** (nie beim Import). `onSelect` kommt nur bei Klicks des Nutzers, nie bei `select()`. + `tests/unit/helpers/engineVm.mjs`: `createEngineSandbox({ cssWidth = 960, dpr = 1 } = {})` bündelt `scene.js` mit esbuild als IIFE (`platform: 'neutral'`, `globalName: 'WKEngine'`), führt es in `vm.createContext` aus (Sandbox ohne `window`/`document`, nur `console`, `Math`, `Date`, `JSON`) und liefert `{ mountTower, canvas, counts, seenTexts, listeners, opts(extra), tick(seconds), rafPending(), click(cssX, cssY), pointer(type, cssX, cssY) }` (`counts = { fillRect, fillText }` zählt nur, es speichert keine Aufrufe, sonst sprengen 7 000 Bilder den Speicher; `seenTexts` = `Set` der verschiedenen gezeichneten Texte): Fake-Canvas (`getContext` ⇒ Proxy-Kontext nach `smoke.js`, `addEventListener`/`removeEventListener` führen Buch, `getBoundingClientRect` ⇒ `{ left:0, top:0, width: cssWidth, height: cssWidth*0.8 }`, `style`, `width`, `height`), `opts(extra)` ⇒ Optionen mit `raf/caf/now/dpr` aus dem Sandbox-Takt, `tick(s)` rückt `now` um 1000/60 ms je Schritt vor und führt fällige `raf`-Rückrufe aus. + +- [ ] **Step 1: Failing tests schreiben** (`tests/unit/workersScene.test.mjs`; Imports: `createEngineSandbox` aus `./helpers/engineVm.mjs`, `makeState`, `ev`, `IDS` aus `./helpers/workersFixtures.mjs`) + ```js + const sel = [] + const open = async (o = {}, extra = {}) => { const sb = await createEngineSandbox(o); const h = sb.mountTower(sb.canvas, sb.opts({ onSelect: (id) => sel.push(id), ...extra })); return { sb, h } } + test('Handle hat die Methoden aus Übersicht §7 und E4; Mount zeichnet sofort', async () => { + const { sb, h } = await open(); for (const m of ['destroy', 'setState', 'pushEvents', 'select', 'setPaused', 'resize', 'zoomBy', 'resetView', 'locate', 'stats', 'debug']) assert.equal(typeof h[m], 'function', m) + assert.ok(sb.counts.fillRect > 100); h.destroy() + }) + test('Größe: Breite·dpr (max. 2400), Höhe 4/5 davon', async () => { + let { sb, h } = await open({ cssWidth: 960, dpr: 1 }); assert.deepEqual([sb.canvas.width, sb.canvas.height], [960, 768]); h.destroy() + ;({ sb, h } = await open({ cssWidth: 1500, dpr: 2 })); assert.deepEqual([sb.canvas.width, sb.canvas.height], [2400, 1920]); h.destroy() + }) + test('Takt: höchstens 30 Bilder je Sekunde', async () => { + const { sb, h } = await open(); h.setState(makeState()); const f0 = h.stats().frames; sb.tick(1); const f = h.stats().frames - f0; assert.ok(f >= 28 && f <= 32, String(f)); h.destroy() + }) + test('Lebenszyklus: destroy doppelt, nichts danach, Listener weg; StrictMode mount → destroy → mount', async () => { + const sb = await createEngineSandbox(); const a = sb.mountTower(sb.canvas, sb.opts({})); a.destroy(); a.destroy() + const n = sb.counts.fillRect; sb.tick(1); assert.equal(sb.counts.fillRect, n); assert.equal(Object.values(sb.listeners).flat().length, 0) + const b = sb.mountTower(sb.canvas, sb.opts({})); sb.tick(0.5); assert.ok(sb.counts.fillRect > n); a.setState(makeState()); a.pushEvents([ev('fund', 'kim')]); b.destroy() + assert.equal(Object.values(sb.listeners).flat().length, 0) + }) + test('fontsReady: Ablehnung oder Zerstörung davor lässt nichts mehr zeichnen', async () => { + let resolve; const p = new Promise((r) => { resolve = r }); const { sb, h } = await open({}, { fontsReady: p }); h.destroy(); const n = sb.counts.fillRect; resolve(); await new Promise((r) => setImmediate(r)); assert.equal(sb.counts.fillRect, n) + const sb2 = await createEngineSandbox(); sb2.mountTower(sb2.canvas, sb2.opts({ fontsReady: Promise.reject(new Error('x')) })).destroy() // abgelehntes Versprechen erst nach dem Sandbox-Aufbau erzeugen, sonst „unhandled rejection“ + }) + test('reducedMotion: keine Schleife, jede Änderung zeichnet ein Standbild und beruhigt die Regie', async () => { + const { sb, h } = await open({}, { reducedMotion: true }); assert.equal(sb.rafPending(), 0); const n = sb.counts.fillRect + h.setState(makeState()); h.pushEvents(Array.from({ length: 30 }, (_, i) => ev('uebergabe', IDS[i % 19], { to: IDS[(i + 4) % 19] }))); assert.ok(sb.counts.fillRect > n); assert.equal(h.debug().pending, 0); assert.equal(sb.rafPending(), 0); h.destroy() + }) + test('setPaused hält die Schleife an und setzt sie fort', async () => { + const { sb, h } = await open(); h.setPaused(true); sb.tick(0.2); const n = sb.counts.fillRect; sb.tick(1); assert.equal(sb.counts.fillRect, n); h.setPaused(false); sb.tick(0.5); assert.ok(sb.counts.fillRect > n); h.destroy() + }) + test('Klick trifft über die Umkehr-Transformation, auch gezoomt und verschoben', async () => { + const { sb, h } = await open(); h.setState(makeState()); sel.length = 0 + for (const id of ['otto', 'ben', 'arne']) { const p = h.locate(id); sb.click(p.x, p.y); assert.equal(sel.at(-1), id) } + h.zoomBy(2.5); h.select('ben'); const p = h.locate('ben'); assert.ok(p); sb.click(p.x, p.y); assert.equal(sel.at(-1), 'ben'); assert.equal(h.stats().zoom, 2.5) + h.resetView(); assert.equal(h.stats().zoom, 1); sel.length = 0; h.select('kim'); assert.equal(sel.length, 0); h.destroy() + }) + test('Ziehen verschiebt bei Zoom > 1 und unterdrückt den Klick', async () => { + const { sb, h } = await open(); h.setState(makeState()); h.zoomBy(2); h.select('bea'); const before = h.locate('bea'); sel.length = 0 + sb.pointer('pointerdown', 400, 300); sb.pointer('pointermove', 460, 300); sb.pointer('pointerup', 460, 300); sb.click(460, 300); assert.equal(sel.length, 0) + const after = h.locate('bea'); assert.ok(Math.abs(after.x - (before.x + 60)) < 2); assert.ok(Math.abs(after.y - before.y) < 1e-6); h.destroy() // 60 CSS-Pixel nach rechts gezogen + }) + ``` +- [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersScene.test.mjs`. Expected: FAIL `Could not resolve …/engine/scene.js`. +- [ ] **Step 3: Implementieren** `engine/scene.js` und `tests/unit/helpers/engineVm.mjs` + Regeln: `resize()` liest `canvas.getBoundingClientRect().width` (unter 10 ⇒ überspringen), `canvas.width = min(2400, round(cssW*dpr))`, `canvas.height = round(width*256/320)`; Raster je Bild: `k = canvas.width / (320/zoom)`, `ox = -cam.x*k`, `oy = -cam.y*k`; Startzoom `fitZoom(cssW)`. Reihenfolge je Bild: Weiß füllen → `drawOutside` → `drawTower` → `drawRooms` → `lift.drawBack` → `drawAgents` → `lift.drawFront` → `fx.draw` → `drawPauseVeil`. Schleife: `raf`-Rückruf, Bild höchstens alle `FRAME_MS` (dazwischen nur neu planen), `dt = min(DT_MAX, Δt)`, `director.update(dt)` vor dem Zeichnen; `stats()` misst `avgDrawMs` mit `now()`. Eingabe: `click` ⇒ `viewToWorld` aus Klickposition ⇒ `pickAgent(boxes, x, y, pad)` mit `pad = (coarsePointer ? 10 : 4) / (cssW / (320/zoom))` ⇒ `onSelect(id|null)`; `pointerdown/move/up` verschieben die Kamera (`panBy`) nur bei `zoom > 1`, Bewegung über `DRAG_PX` unterdrückt den nächsten `click`; `wheel` zoomt nur mit `ctrlKey` (`passive: false`); `style.touchAction` = `'none'` bei Zoom > 1, sonst `'pan-y'`. `select(id)` setzt die Markierung und zentriert bei Zoom > 1. `setState` und `pushEvents` zeichnen sofort ein Bild (`drawOnce`), damit Klick und `locate` frische Trefferflächen haben. `locate(id)` ⇒ Mitte der Trefferfläche (Weltkoordinaten der letzten Zeichnung, durch die Kamera in CSS-Pixel umgerechnet) oder `null` außerhalb der Ansicht. `reducedMotion`: kein `raf`; `setState/pushEvents/select/resize/zoomBy` rufen `director.flush()` (nur bei `pushEvents`) und zeichnen ein Standbild. `fontsReady.then(drawOnce, () => {})` mit Zerstörungswächter. `destroy()`: Flagge, `caf`, alle Listener entfernen, spätere Aufrufe ohne Wirkung. `debug()` ⇒ `{ pending, agents:[{ id, x, y, floor, pose, grey }], lift: lift.state, fx: fx.size() }`. +- [ ] **Step 4: Bestehen sehen** — gleicher Befehl. Expected: `# pass 9`, `# fail 0`. +- [ ] **Step 5: Commit** + ```bash + cd $WK && git add src/components/workers/engine/scene.js tests/unit/helpers/engineVm.mjs tests/unit/workersScene.test.mjs + git commit -m "Workers P2: mountTower (Größe, 30-fps-Schleife, Eingabe, Kamera, Lebenszyklus)" -m "Co-Authored-By: Claude Opus 5.5 " + ``` + +--- + +### P2-T15: Engine-Rauchtest (19 anklickbar, 150 s Ereignisfeed) + +**Files:** +- Test: `tests/unit/workersSmoke.test.mjs` + +**Interfaces:** +- Consumes: `createEngineSandbox` (P2-T14), `makeState`/`ev`/`IDS` (P2-T12), `EVENT_KINDS` (P2-T03). Muster: `/root/reports/workers-2026-10-16/entwuerfe/smoke.js`. +- Produces: nichts (reine Absicherung). + +- [ ] **Step 1: Test schreiben** (`tests/unit/workersSmoke.test.mjs`) + Gerüst: `scripted(sec)` baut die Ereignisse für Sekunde `sec`: alle 26 Arten aus `EVENT_KINDS` verteilt auf die Sekunden 3, 8, 13, … (eine Art alle 5 s, in der Reihenfolge der folgenden Liste, Arbeiter passend zur Art: `gelesen` kim, `fund` rita, `doppelt` arne, `uebergabe` rita→bea, `bewertet` bea, `top3` bea, `botlead` arne, `entwurf` max, `pult` clara, `gesendet` max, `eingang` nora, `sortiert` nora, `bau_start` bob, `bau_fertig` bob, `pruefung_zurueck` paul→bob (`meta.round` 1), `pruefung_ok` paul, `vorschau_link` max, `angebot` theo, `deal` system, `gesperrt` system, `budget_warnung` otto, `budget_stopp` otto, `pause` system, `fehler` ben, `auftrag` otto→kim, `leerlauf` pia); zusätzlich ab Sekunde 100 ein Stoß von 120 `uebergabe`-Ereignissen auf einmal. + ```js + test('alle 19 Worker sind anklickbar (Auswahl liefert die richtige workerId)', async () => { + const sb = await createEngineSandbox(), picked = []; const h = sb.mountTower(sb.canvas, sb.opts({ onSelect: (id) => picked.push(id) })); h.setState(makeState()) + for (const id of IDS) { const p = h.locate(id); assert.ok(p, id); sb.click(p.x, p.y); assert.equal(picked.at(-1), id) } + assert.deepEqual([...new Set(picked)].sort(), [...IDS].sort()); h.destroy() + }) + test('150 s mit allen 26 Ereignisarten: kein Wurf, alle endlich und im Bild, danach ruhig', async () => { + const sb = await createEngineSandbox(), h = sb.mountTower(sb.canvas, sb.opts({ onSelect() {} })); h.setState(makeState({ connected: { kim: false, pia: false, finn: false } })) + for (let s = 0; s < 150; s++) { const evs = scripted(s); if (evs.length) h.pushEvents(evs); if (s % 15 === 0) h.setState(makeState({ connected: { kim: false, pia: false, finn: false }, kpis: { pultOpen: s % 7, dealsMonth: 3 + Math.floor(s / 40), botLeads: 112 + s } })); sb.tick(1) + if (s % 2 === 0) for (let x = 10; x < 960; x += 40) for (let y = 10; y < 768; y += 40) sb.click(x, y) } + for (let s = 0; s < 90; s++) sb.tick(1) + const dbg = h.debug(); assert.equal(dbg.pending, 0); assert.equal(dbg.lift, 'idle'); assert.equal(dbg.fx, 0) + for (const a of dbg.agents) assert.ok(Number.isFinite(a.x) && Number.isFinite(a.y) && a.x > -30 && a.x < 350 && a.floor >= 0 && a.floor <= 9, a.id) + assert.ok(sb.counts.fillRect > 100000); assert.ok(sb.counts.fillText > 1000); assert.ok(sb.seenTexts.has('noch nicht angeschlossen')) + h.destroy(); assert.equal(Object.values(sb.listeners).flat().length, 0) + }) + ``` +- [ ] **Step 2: Laufen lassen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersSmoke.test.mjs`. Expected: PASS ohne weitere Implementierung, wenn P2-T06–T14 stimmen; ein Fehlschlag zeigt den Fehler in der Engine (dann dort beheben, nicht den Test lockern). Zeitlimit des Laufs 300 s; typisch < 60 s. +- [ ] **Step 3: Falls nötig Engine-Fehler beheben** (kleinste Änderung am betroffenen Engine-Modul, zugehörigen Modultest ergänzen), erneut laufen lassen. Expected: `# pass 2`, `# fail 0`. +- [ ] **Step 4: Gesamte Engine-Gruppe gegenprüfen** — `cd $WK && timeout 600 node --max-old-space-size=1024 --test tests/unit/workers*.test.mjs`. Expected: `# fail 0`. +- [ ] **Step 5: Commit** + ```bash + cd $WK && git add tests/unit/workersSmoke.test.mjs + git commit -m "Workers P2: Engine-Rauchtest (19 anklickbar, 150 s Ereignisfeed)" -m "Co-Authored-By: Claude Opus 5.5 " + ``` + +--- + +### P2-T16: Formularlogik der Einstellungen + +**Files:** +- Create: `src/lib/workersSettingsForm.js` +- Test: `tests/unit/workersSettingsForm.test.mjs` + +**Interfaces:** +- Consumes: `ROLES`, `ROOM_TITLES` (`workersRoster.js`); `workersSettings` aus Übersicht §4.1. +- Produces: + ```js + export const ROOM_CHOICES // die 8 Suchräume [{ id, title }] (Räume der Etagen anfragen und ohne_website, Rollenreihenfolge) + export const READONLY_KEYS // ['paused','previewsSent','updatedAt','updatedBy','offerProductKey','connectedRooms'] + export function settingsToForm(settings): form // Zahlen als Text, regions als Zeilen, Räume als Id-Listen + export function validateForm(form): { ok, errors: { [feld]: 'Text' } } + export function formToPatch(form, original): object // nur geänderte, getypte Felder; nie READONLY_KEYS + ``` + Regeln, gleich oder enger als die Prüfung des Dienstes (P1-T07; Fehlertext in Klammern): `dailyBudgetUsd` Zahl > 0 (`Tagesgrenze muss größer als 0 sein.`) und ≤ `monthlyBudgetUsd` (`Die Tagesgrenze darf nicht über der Monatsgrenze liegen.`); `monthlyBudgetUsd` > 0 (`Monatsgrenze muss größer als 0 sein.`); `warnPct` ganz 1..100 (`Warnschwelle: ganze Zahl von 1 bis 100.`); `mailColdPerDay` ganz 0..3 (`Höchstens 3 unverlangte Mails pro Tag.`); `mailReplyCapPerDay` ganz 0..15 (`Höchstens 15 Antwort-Mails pro Tag.`); `sendWindowStart`/`sendWindowEnd` `HH:MM`, Beginn vor Ende (`Uhrzeit als HH:MM, Beginn vor Ende.`); `runsPerNight` ganz 1..2 (`Läufe pro Nacht: 1 oder 2.`); `candidatesPerRun` ganz 1..20 (`Kandidaten pro Lauf: 1 bis 20.`); `regions` 1..30 Zeilen, je 1..64 Zeichen (`Mindestens eine Region, höchstens 30, je Zeile höchstens 64 Zeichen.`); `previewGate` ∈ `erste_n|immer|nie`; `previewGateN` ganz 0..1000; `offerText` 1..1000 Zeichen (`Angebotstext: 1 bis 1000 Zeichen.`); `pultStaleDays` ganz 1..30; `followupDays`/`previewReminderDays` ganz 1..60; `retentionDays` ganz 7..365 (`Aufbewahrung: 7 bis 365 Tage.`); `previewDeleteDays` ganz 7..365; `reportEmail` einfache Adresse. Dezimalkomma wird akzeptiert (`12,5`). + +- [ ] **Step 1: Failing test schreiben** (`const F = await loadModule('src/lib/workersSettingsForm.js')`) + ```js + const S0 = { paused: true, dailyBudgetUsd: 10, monthlyBudgetUsd: 250, warnPct: 80, mailColdPerDay: 3, mailReplyCapPerDay: 15, sendWindowStart: '09:00', sendWindowEnd: '16:00', sendWeekdaysOnly: true, runsPerNight: 1, candidatesPerRun: 10, + activeRooms: ['netz', 'kleinanzeigen', 'portale', 'foren', 'aerzte', 'handwerk', 'selbst', 'laden'], connectedRooms: [], regions: ['Rheinland-Pfalz'], previewGate: 'erste_n', previewGateN: 10, previewsSent: 4, offerText: 'Gratis-Vorschau …', offerProductKey: 'hosting', + pultStaleDays: 3, retentionDays: 90, followupDays: 7, previewReminderDays: 5, previewDeleteDays: 60, reportEmail: 'kenso.gri@gmail.com', updatedAt: '2026-10-17T10:00:00Z', updatedBy: 'x' } + test('unverändertes Formular ⇒ leerer Patch; Pflichtfelder nie im Patch', () => { + const f = F.settingsToForm(S0); assert.deepEqual(F.formToPatch(f, S0), {}); assert.equal(F.validateForm(f).ok, true) + assert.deepEqual(F.formToPatch({ ...f, paused: false, previewsSent: 99, updatedBy: 'y', offerProductKey: 'x', connectedRooms: ['netz'] }, S0), {}) + }) + test('Patch enthält nur Geändertes, getypt; Dezimalkomma, Zeilen, Räume', () => { + const f = { ...F.settingsToForm(S0), dailyBudgetUsd: '12,5', regions: 'Rheinland-Pfalz\n\n Hessen ', activeRooms: S0.activeRooms.filter((r) => r !== 'kleinanzeigen'), previewGate: 'immer' } + assert.deepEqual(F.formToPatch(f, S0), { dailyBudgetUsd: 12.5, regions: ['Rheinland-Pfalz', 'Hessen'], activeRooms: ['netz', 'portale', 'foren', 'aerzte', 'handwerk', 'selbst', 'laden'], previewGate: 'immer' }) + }) + test('Prüfung mit den Spec-Grenzen', () => { + const bad = (over) => F.validateForm({ ...F.settingsToForm(S0), ...over }).errors + assert.equal(bad({ mailColdPerDay: '4' }).mailColdPerDay, 'Höchstens 3 unverlangte Mails pro Tag.'); assert.equal(bad({ mailReplyCapPerDay: '16' }).mailReplyCapPerDay, 'Höchstens 15 Antwort-Mails pro Tag.') + assert.equal(bad({ dailyBudgetUsd: '300' }).dailyBudgetUsd, 'Die Tagesgrenze darf nicht über der Monatsgrenze liegen.'); assert.equal(bad({ dailyBudgetUsd: '0' }).dailyBudgetUsd, 'Tagesgrenze muss größer als 0 sein.') + assert.equal(bad({ sendWindowStart: '16:00', sendWindowEnd: '09:00' }).sendWindowStart, 'Uhrzeit als HH:MM, Beginn vor Ende.'); assert.equal(bad({ regions: ' \n' }).regions, 'Mindestens eine Region, höchstens 30, je Zeile höchstens 64 Zeichen.') + assert.equal(bad({ offerText: '' }).offerText, 'Angebotstext: 1 bis 1000 Zeichen.'); assert.equal(bad({ retentionDays: '5' }).retentionDays, 'Aufbewahrung: 7 bis 365 Tage.'); assert.equal(bad({ warnPct: 'x' }).warnPct, 'Warnschwelle: ganze Zahl von 1 bis 100.') + assert.equal(bad({ runsPerNight: '3' }).runsPerNight, 'Läufe pro Nacht: 1 oder 2.'); assert.equal(bad({ candidatesPerRun: '21' }).candidatesPerRun, 'Kandidaten pro Lauf: 1 bis 20.') + assert.equal(F.validateForm(F.settingsToForm(S0)).ok, true); assert.deepEqual(F.ROOM_CHOICES.map((r) => r.id), S0.activeRooms) + }) + ``` +- [ ] **Step 2: Scheitern sehen** — `cd $WK && timeout 300 node --max-old-space-size=1024 --test tests/unit/workersSettingsForm.test.mjs`. Expected: FAIL `Could not resolve …/workersSettingsForm.js`. +- [ ] **Step 3: Implementieren** `src/lib/workersSettingsForm.js` nach den Regeln oben; Feldregeln als Datentabelle `RULES` (Typ, Grenzen, Fehlertext), `formToPatch` vergleicht typisiert mit `original` (Listen für Räume reihenfolgeunabhängig, in `ROOM_CHOICES`-Reihenfolge ausgegeben; `regions` reihenfolgetreu) und lässt `READONLY_KEYS` immer weg. +- [ ] **Step 4: Bestehen sehen** — gleicher Befehl. Expected: `# pass 3`, `# fail 0`. +- [ ] **Step 5: Commit** + ```bash + cd $WK && git add src/lib/workersSettingsForm.js tests/unit/workersSettingsForm.test.mjs + git commit -m "Workers P2: Formularlogik der Einstellungen (Prüfung, Patch nur Geändertes)" -m "Co-Authored-By: Claude Opus 5.5 " + ``` + +--- + +### P2-T17: Kennzahlen, Worker-Karte, Worker-Liste + +**Files:** +- Create: `src/components/workers/WorkersKpis.jsx`, `src/components/workers/WorkerCard.jsx`, `src/components/workers/WorkerList.jsx` +- Test: `tests/unit/workersComponents.test.mjs` + +**Interfaces:** +- Consumes: `Stat` (`../ui/Stat`), `Badge` (`../ui/Badge`), `kpiTiles`, `counterLines`, `tokenTotal`, `traceRows`, `workerStatusLabel`, `statusTone`, `formatUsd`, `formatCount` (`workersView.js`), `ROLES`, `FLOOR_TITLES`, `MODEL_IDS`, `roleOf`, `hatForWorker` (`workersRoster.js`), `drawWorker`, `setGrid` (`engine/sprites.js`), react-router `Link`. +- Produces: + ```jsx + export default function WorkersKpis({ kpis }) //
+ Stat je Kachel + export default function WorkerCard({ workerId, worker, trace, loading, error, onClose }) + // worker = state.workers-Eintrag (status, task, counters); trace = Antwort von api.trace + // Aufbau: wk-card · Porträt (Canvas, drawWorker) · Name (wk-name) · Rolle · Modell-Chip wk-chip wk-chip- mit MODEL_IDS-Text · Status-Badge + // · „Macht gerade“ · Zähler heute · „Tokens heute“ · „Kosten heute“ · „Letzte Aktionen“ (wk-trace, bis 50 Zeilen, Zeit + Text + Link) + export default function WorkerList({ workers, selectedId, onSelect }) // Liste aller Worker je Etage: