35 KiB
Workers – Plan-Übersicht und gemeinsame Schnittstellen
Dieses Dokument ist die verbindliche Quelle für alle Workers-Teilpläne P1–P6. Jeder Teilplan verweist hierauf und darf die hier festgelegten Namen, Felder, Pfade und Signaturen nicht abweichend verwenden. Wer etwas ändern muss, ändert es hier und in allen betroffenen Plänen.
Spec: docs/superpowers/specs/2026-10-17-workers-design.md (freigegeben 17.10.2026)
Code-Landkarten (Fakten zum Bestand): /root/reports/workers-2026-10-16/codekarten/{woms,portal,infra,mail-claude,daten-spiel}.md
Pixel-Vorlage: /root/reports/workers-2026-10-16/entwuerfe/{sprites.js,roster.js,scene-turm.js,index.html,smoke.js,shot.cjs}
1. Teilpläne, Reihenfolge, Orte
| Plan | Datei | Inhalt | Repo / Arbeitsort | Hängt ab von |
|---|---|---|---|---|
| P1 Dienst-Fundament | 2026-10-17-workers-p1-dienst-fundament.md |
Dienst-Gerüst, Appwrite-Sammlungen + Setup-Skript, Normalisierung + Nie-doppelt-Register + Startbestand, Ereignisstrom, Budget-Wächter + Claude-Hülle, Rollen-Register mit Werkzeug-/Kanal-Sperre, Zeitplan-Tick, Otto (Nachtplan + Morgenbericht), HTTP-API, Container + Betrieb | neues Repo knso/workers-dienst |
– |
| P2 Hochhaus in WOMS | 2026-10-17-workers-p2-hochhaus-woms.md |
Menüpunkt + Route /workers, WorkersPage, Pixel-Engine (Port aus der Vorlage, Etagen/Räume als Daten, Lift, Ereignis-Regie, Leerlauf-/Grau-Zustände), Polling-Feed, Worker-Karte mit Spur, Kennzahlen, Pausenschalter, Einstellungen (Admin) |
WOMS JUSN/tickte-system, Worktree dev-workers, Branch feature/workers |
P1 (API) |
| P3 Etage „Anfragen“ + Freigabe-Pult | 2026-10-17-workers-p3-anfragen-pult.md |
Rita/Kim/Pia/Finn, Alarm-Postfach lesen, Bea-Bewertung für Anfragen, Clara-Erstantwort, Pult im Dienst + Pult-UI in WOMS | Dienst + WOMS | P1, P2 |
| P4 Gespräche, Werkstatt, Prüfung | 2026-10-17-workers-p4-gespraeche-werkstatt.md |
Max als einziger Absender (SMTP-Tor), Nora-Posteingang (IMAP + Einordnung), Fäden, Theo, Bob/Bianca/Ben-Bau, Paul-Prüfung mit Screenshots, Vorschau-Server entwurf.project.webklar.com, Vorschau-Freigabe, Erinnerungen |
Dienst (+ WOMS-Pult-Erweiterung) | P1–P3 |
| P5 Kaufweg im Portal | 2026-10-17-workers-p5-portal-kaufweg.md |
„Website übernehmen“-Seite + Routen ohne Login, Stripe-Checkout ohne Kunde, Webhook-Zweig legt Kunde an, Nachweis, K44-Mail, Vorschau → Kundenprojekt, Status zurück an Workers | Portal knso/Webklar-Kundenbereich, Worktree dev-workers-kauf, Branch feature/workers-kaufweg |
P1, P4 (Angebots-Doc) |
| P6 Etage „Ohne Website“ + Kaltmail | 2026-10-17-workers-p6-ohne-website.md |
Ali/Hanna/Sven/Lea (Places/OSM/Impressum), Top-3 über alle Räume, Bot-Lead-Liste (Dienst + WOMS-Ansicht), Max-Kaltmail mit allen Pflichtangaben, Nachfassen, Aufbewahrung/Kürzung, Datenschutz-Abschnitt „Interessenten“ | Dienst + WOMS + webklar.com-Rechtstext | P1–P4 |
Reihenfolge der Umsetzung: P1 → P2 → P3 → P4 → P5 → P6. P5 kann nach P1 parallel zu P3/P4 laufen (eigene Dateien im Portal), solange das Angebots-Dokument aus §4.12 feststeht.
1.1 Repos, Checkouts, Branches
| Was | Ort | Regel |
|---|---|---|
| Workers-Dienst (neu) | Gitea knso/workers-dienst (nicht in der Org WEBklar: deren Org-Webhook legt für jeden Push ein websiteProjects-Dokument an). Entwicklung: /home/webklar/apps/workers-dev (Branch je Plan: p1-fundament, p3-anfragen, p4-gespraeche, p6-ohne-website). Betrieb: /home/webklar/apps/workers (Branch main). |
Repo anlegen per Gitea-MCP create_repo (privat). Kenso merged/pusht nach main. |
| WOMS | /home/webklar/apps/ticket.webklar.com/dev-workers, Branch feature/workers |
Live geht nur, wenn Kenso git push origin feature/workers:test ausführt. |
| Portal | Worktree /home/webklar/apps/kundenbereich.webklar.com/dev-workers-kauf von origin/main, Branch feature/workers-kaufweg |
Live: Kenso pusht nach main (Deploy-Webhook). |
2. Globale Regeln (gelten in jedem Task jedes Plans)
- Speicher: Jeder Node-Lauf (Tests, Skripte) mit
timeout <s> node --max-old-space-size=1024 …. Vite/esbuild-Bündel nur untersystemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0. Playwright/Chromium nur untersystemd-run --scope -q -p MemoryMax=700M -p MemorySwapMax=0. Vor schweren Läufenfree -m. - Sicherheit beim Ausführen: kein
rm -rfaußerhalb eigener Scratch-Ordner unter$TMPDIR; nie/dev,/etc,/anfassen; keindocker run, keine globalen Installs; Prozesse nur per PID beenden (pkill -ftrifft die eigene Shell);grepist ein Wrapper →command grep -aoderrg. - Git: nur
git add <dateien>mit Namen, nie-A/.. Agenten pushen nie und führen keindocker compose upaus; das macht Kenso nach einer Betriebsanleitung im Plan..envunddocker-compose.ymlunter/home/webklar/appssind nicht in Git → vor jeder Änderung Backup*.bak-YYYY-MM-DD-workers. - WOMS-Worktree:
node_modulesfehlt → einmalln -s ../build/node_modules node_modules(zeigt als?? node_modules, nie committen). Tests:timeout 600 node --max-old-space-size=1024 --test tests/*.test.mjs tests/unit/*.test.mjs; reine Libs untersrc/lib/ohne React/Appwrite-Import, geladen mitloadModule()austests/unit/load.mjs. - Portal: Tests
timeout 900 node --max-old-space-size=1024 --test test/<datei>.test.jsmitstartAppwriteMock/startFakeStripe/mailSink(Mustertest/aboRoutes.test.js). - Dienst: Node 22 (Container
node:22-alpine), ESM, keine Semikolons, deutsche Kommentare, Teststimeout 300 node --max-old-space-size=1024 --test test/*.test.js. Jede äußere Abhängigkeit (Appwrite, Anthropic, SMTP, IMAP, Uhr) wird per Parameter injiziert, damit Tests ohne Netz laufen. In Tests nie echte Anthropic-, SMTP- oder Appwrite-Aufrufe. - Abhängigkeiten des Dienstes (exakt):
@anthropic-ai/sdk@0.135.0,node-appwrite@14.1.0(wie Portal),nodemailer@10.1.2,imapflow@2.5.2,mailparser@3.9.40,playwright-core@1.62.1(passt zu Chromium im Image). Keine weiteren ohne Eintrag hier. - Modelle:
claude-opus-5-5(Otto, Bob, Bianca, Ben),claude-sonnet-5-5(Bea, Max, Clara, Theo, Paul),claude-haiku-5-5(Rita, Kim, Pia, Finn, Ali, Hanna, Sven, Lea, Nora, Arne). Keintemperature/top_p. Strukturierte Antworten überoutput_config.format(json_schema). Suche und Strukturierung in zwei Aufrufen (Suche mit Werkzeugen → freier Text; danach werkzeugloser Aufruf mit Schema), weil Such-Zitate undoutput_config.formatkollidieren können. - Budget (Kenso): 10 $/Tag, 250 $/Monat in den ersten 4 Wochen, Warnung bei 80 %. Workspace-Ausgabenlimit in der Console zusätzlich. Antwort „HTTP 400 … reached your specified … API usage limits“ oder 429 mit
enforced_spend_limit_reached⇒ sofortbudget_stopp. - Mail (Kenso + Spec §8): Kaltmails nie über webklar.com/Hetzner. Höchstens 3 unverlangte Mails/Tag (Erst + Nachfass zusammen), Antworten an Schreibende + Vorschau-Links eigene Grenze 15/Tag, nur werktags 09:00–16:00 Europe/Berlin. Nur Max sendet. Sperrliste vor jedem Versand. Ein SMTP-Login-Fehler ⇒ Versand für den Tag aus (kein Wiederholen; Hetzner-IP-Sperre-Erfahrung gilt sinngemäß).
- Keine Umgehung (Kenso): kein Captcha/Cloudflare-Umgehen, kein Login-Scraping,
robots.txtbeachten (url_not_allowed= auslassen). Plattformen mit Bot-Verbot (Kleinanzeigen, Facebook, Upwork, MyHammer, freelancermap-Login-Bereich, freelance.de-Login-Bereich): nie direkt abrufen; Lesen nur über deren Alarm-Mails im Alarm-Postfach oder Suchmaschinen-Treffer; Senden nur durch Menschen über das Pult. - Texte nach außen: Deutsch, „Sie“ in Kaltmails an Betriebe, „du“ nur, wenn der Anfragende duzt. Kein „Sehr geehrte Damen und Herren“, kein Ausrufezeichen im Betreff. Pflichtbausteine (KI-Hinweis, Quelle, Datenschutz-Link, Widerspruch, Abmelde-Link, Angebotssatz) werden per Code angehängt, nie dem Modell überlassen.
- Keine Bot-Leads in WOMS-Kunden: Vor einem bezahlten Deal schreibt kein Teil von Workers in
customers,workorders,websiteProjects,customerPortalAccessoder legt Appwrite-Benutzer an. - Zeit: Dienst läuft mit
TZ=Europe/Berlin; gespeichert wird ISO-8601 UTC.
3. Besetzung (Rollen-Register) – verbindlich
workerId ist die feste Kennung in Daten, Ereignissen, API und Spiel.
| workerId | Name | Etage (floor) |
Raum (room) |
Modell | Einzige Rolle | Erlaubte Fähigkeiten (caps) |
|---|---|---|---|---|---|---|
otto |
Otto | bruecke |
bruecke |
opus | plant die Nacht, verteilt Aufträge, Budget, Morgenbericht | plan, report |
rita |
Rita | anfragen |
netz |
haiku | sucht frische „Suche Webdesigner“-Posts über Websuche | web_search, web_fetch |
kim |
Kim | anfragen |
kleinanzeigen |
haiku | liest Kleinanzeigen-Suchauftrags-Mails im Alarm-Postfach | alert_inbox |
pia |
Pia | anfragen |
portale |
haiku | liest Projekt-Alerts/RSS der Portale | alert_inbox, rss |
finn |
Finn | anfragen |
foren |
haiku | sucht in Foren/Frage-Seiten (nur erlaubte) | web_search, web_fetch |
ali |
Ali | ohne_website |
aerzte |
haiku | Branchen-Scout Ärzte & Praxen | web_search, web_fetch, places, osm |
hanna |
Hanna | ohne_website |
handwerk |
haiku | Branchen-Scout Handwerk | wie ali |
sven |
Sven | ohne_website |
selbst |
haiku | Branchen-Scout Selbstständige | wie ali |
lea |
Lea | ohne_website |
laden |
haiku | Branchen-Scout Läden & Gastro | wie ali |
bea |
Bea | bewertung |
waage |
sonnet | bewertet, verteilt Top 3 / Rest | score |
max |
Max | gespraeche |
mail |
sonnet | schreibt Mails und ist einziger Absender | draft, send_mail |
clara |
Clara | gespraeche |
forum |
sonnet | schreibt Antworten auf Anfragen | draft, pult |
nora |
Nora | gespraeche |
posteingang |
haiku | liest + sortiert Eingänge | read_inbox, classify |
theo |
Theo | gespraeche |
abschluss |
sonnet | Nachrichten rund um den Kauf (Entwürfe) | draft, pult, offer |
bob |
Bob | werkstatt |
tisch1 |
opus | baut Vorschauen (nur nach Ja) | build |
bianca |
Bianca | werkstatt |
tisch2 |
opus | baut Vorschauen | build |
ben |
Ben | werkstatt |
tisch3 |
opus | baut Vorschauen | build |
paul |
Paul | pruefung |
pruefung |
sonnet | prüft Vorschauen (Screenshots) | review, screenshot |
arne |
Arne | keller |
archiv |
haiku | Nie-doppelt-Register + Bot-Lead-Liste | register, archive |
Etagen von oben nach unten (FLOORS): bruecke, anfragen, ohne_website, bewertung, gespraeche, werkstatt, pruefung, freigabe (Pult, kein Worker), tresor (kein Worker), keller.
Die Tabelle liegt als Daten im Dienst in src/roles.js (ROLES, FLOORS) und als gespiegelte Kopie in WOMS src/lib/workersRoster.js. Ein Test in jedem Repo prüft die Kopie gegen eine gemeinsame Fixture workers-roster.json (gleicher Inhalt in beiden Repos: test/fixtures/workers-roster.json bzw. tests/fixtures/workers-roster.json).
Durchsetzung (P1): assertCap(workerId, cap) wirft CapError, wenn die Rolle die Fähigkeit nicht hat. Jede Fähigkeit hat genau einen Code-Pfad, der sie vor der Ausführung prüft. send_mail existiert nur im Sende-Tor (src/mail/gate.js, P4).
4. Datenmodell (Appwrite, Datenbank woms-database)
Alle Workers-Sammlungen: Rechte [] (nur Server-Schlüssel), documentSecurity=false. WOMS liest nur über die Dienst-API. Angelegt durch scripts/setup-collections.mjs im Dienst (P1; idempotent, --check = nur prüfen, Exit 1 bei Fehlendem). Schema als reine Daten in src/schema.js. Kein Feld heißt data. Dokument-IDs ≤ 36 Zeichen. JSON-Felder sind Strings (…Json). Zeiten: datetime.
Typ-Kürzel: s(n) String max. n, i Integer, f Float, b Boolean, dt Datetime, e[…] Enum, [] Array. * = Pflicht.
4.1 workersSettings (genau ein Dokument, $id = "main")
paused b* · dailyBudgetUsd f* (10) · monthlyBudgetUsd f* (250) · warnPct i* (80) · mailColdPerDay i* (3) · mailReplyCapPerDay i* (15) · sendWindowStart s(5)* ("09:00") · sendWindowEnd s(5)* ("16:00") · sendWeekdaysOnly b* (true) · runsPerNight i* (1) · candidatesPerRun i* (10) · activeRooms s(32)[] (["netz","kleinanzeigen","portale","foren","aerzte","handwerk","selbst","laden"]) · connectedRooms s(32)[] (Räume mit echter Quelle; leer bis eingerichtet) · regions s(64)[] (["Rheinland-Pfalz"]) · previewGate e[erste_n,immer,nie]* ("erste_n") · previewGateN i* (10) · previewsSent i* (0) · offerText s(1000)* (Kensos Satz aus Spec §11.2) · offerProductKey s(32)* ("hosting") · pultStaleDays i* (3) · retentionDays i* (90) · followupDays i* (7) · previewReminderDays i* (5) · previewDeleteDays i* (60) · reportEmail s(256)* (Kensos Adresse) · updatedAt dt · updatedBy s(64)
4.2 workersProspects – Interessenten (Anfragen und Betriebe)
entrance e[anfrage,ohne_website]* · room s(32)* · foundBy s(16)* (workerId) · source s(64)* (z. B. gutefrage, kleinanzeigen-alarm, places) · sourceUrl s(1024) · foundAt dt* · name s(256)* · contactName s(256) · email s(256) · emailSource s(1024) (URL, aus der die Adresse stammt) · phone s(64) · phoneSource s(1024) · website s(512) · street s(256) · postalCode s(10) · city s(128) · placeId s(128) · requestText s(4000) (Wortlaut der Anfrage) · requestedAt dt · score i (0–100) · scoreReason s(2000) · status e[gefunden,bewertet,botlead,kontaktiert,gespraech,ja,vorschau_bau,vorschau_gesendet,angebot,deal,verloren,gesperrt]* · botLeadReason s(500) · threadId s(36) · previewId s(36) · offerId s(36) · customerId s(36) · isHealth b (Heilberuf → keine Telefonnummer speichern) · purgeAfter dt · purgedAt dt · updatedAt dt*
Indizes: status, entrance+status, room, foundAt, purgeAfter.
4.3 workersKeys – Nie-doppelt-Register
$id = sha256(kind + ":" + value).hex.slice(0, 32).
kind e[dom,mail,tel,pid,ad,namplz]* · prospectId s(36) (leer bei Startbestand) · origin e[workers,seed_alt,seed_kunde]* · blocked b* (true: nie anschreiben) · hint b* (true nur für namplz = kein harter Treffer) · createdAt dt*.
value wird nicht gespeichert (nur der Hash), damit die Kürzung nach 90 Tagen nichts übrig lässt.
4.4 workersSuppression – Sperrliste (für immer)
$id = sha256(kind + ":" + value).hex.slice(0, 32). kind e[mail,dom,tel]* · reason e[abmeldung,widerspruch,nein,beschwerde,bounce,kunde,manuell]* · createdAt dt* · source s(64) (workerId oder mensch:<userId>).
4.5 workersSearchLog
workerId s(16)* · room s(32)* · region s(64) · query s(500)* · at dt* · found i* · fresh i* (nach Register-Prüfung neu). Index: room+at.
4.6 workersJobs – Aufträge
kind e[suche,alarm_lesen,bewerten,erstantwort,erstmail,nachfass,einordnen,antwort,bau,pruefung,vorschau_link,angebot,erinnerung,bericht,kuerzen]* · workerId s(16)* · status e[offen,laeuft,warten_batch,fertig,fehler,abgebrochen]* · prospectId s(36) · threadId s(36) · previewId s(36) · attempts i* · batchId s(64) · customId s(64) · payloadJson s(16000) · resultJson s(16000) · error s(2000) · createdAt dt* · startedAt dt · finishedAt dt. Indizes: status, workerId+status.
4.7 workersEvents – Ereignisstrom (Quelle für Spiel, Spur, Kennzahlen)
ts dt* · workerId s(16)* (oder system) · kind s(32)* (Liste §5) · jobId s(36) · prospectId s(36) · text s(300)* (deutscher Satz für Spur/Blase) · link s(1024) · to s(16) (Ziel-workerId bei Übergaben) · tokensIn i · tokensOut i · costUsd f · metaJson s(4000). Index: ts, workerId+ts.
4.8 workersDaily – Tageszähler ($id = "YYYY-MM-DD" Berlin-Datum)
costUsd f* · mailsCold i* · mailsReply i* · searches i* · found i* · contacted i* · deals i* · countersJson s(4000) (pro workerId: {gelesen, gefunden, entwuerfe, gesendet, …}) · warned b* · stopped b*.
4.9 workersThreads – Gesprächsfäden
prospectId s(36)* · channel e[mail,plattform]* · platform s(64) · platformUrl s(1024) · toEmail s(256) · subject s(300) · status e[offen,wartet_auf_kunde,wartet_auf_uns,ja,nein,abgeschlossen,gesperrt]* · lastInboundAt dt · lastOutboundAt dt · followupDueAt dt · followupSent b* · reminderDueAt dt · reminderSent b* · createdAt dt*.
4.10 workersMessages
threadId s(36)* · direction e[in,out]* · author s(64)* (workerId oder mensch:<userId> oder kunde) · subject s(300) · body s(16000)* · messageId s(512) · inReplyTo s(512) · referencesJson s(4000) · sentAt dt · channel e[mail,plattform]* · contactSource s(1024) · classification e[ja,frage,nein,abmelden,abwesend,unzustellbar,beschwerde,unklar] · classificationConfidence f · countsAsCold b* · createdAt dt*.
4.11 workersPult – Freigabe-Pult
kind e[plattform_antwort,vorschau_freigabe,unsicher,beschwerde,preisfrage,mensch_gewuenscht,bau_fehlgeschlagen,mail_antwort]* · prospectId s(36) · threadId s(36) · previewId s(36) · title s(200)* · originalText s(8000) · draft s(8000) · sourceUrl s(1024) · status e[offen,in_arbeit,gesendet,verworfen,veraltet,erledigt]* · claimedBy s(64) · claimedAt dt · doneBy s(64) · doneAt dt · createdAt dt* · staleAt dt. Index: status+createdAt.
4.12 workersOffers – Kauf-Links (Schnittstelle zum Portal, P4 schreibt, P5 liest/aktualisiert)
tokenHash s(64)* (sha256 hex des 43-Zeichen-base64url-Tokens; Token selbst nie speichern) · prospectId s(36)* · previewId s(36)* · productKey s(32)* ("hosting") · businessName s(256)* · email s(256)* · previewUrl s(1024)* · status e[offen,checkout,bezahlt,abgelaufen,widerrufen]* · expiresAt dt* · stripeSessionId s(128) · customerId s(36) · paidAt dt · createdAt dt*. Index: tokenHash (unique), status.
Kauf-URL: https://project.webklar.com/uebernahme.html#t=<token>.
4.13 workersPreviews – Vorschauen
token s(43)* (öffentlicher Pfadteil, base64url, 32 Byte) · prospectId s(36)* · builder s(16)* · status e[bau,pruefung,zurueck,fertig,freigabe,gesendet,verworfen,gescheitert]* · round i* · reviewNotes s(8000) · filesJson s(4000) (Dateiliste) · createdAt dt* · deleteAfter dt* · convertedProjectId s(36) (nach Deal).
Öffentliche Adresse: https://entwurf.project.webklar.com/<token>/.
5. Ereignisarten (workersEvents.kind, geschlossene Liste)
| kind | wer | Bedeutung (Spiel-Animation) |
|---|---|---|
auftrag |
otto | Otto vergibt einen Auftrag (to = workerId; Blase) |
leerlauf |
alle | nichts zu tun (Kaffee/Schlafen) |
gelesen |
rita, kim, pia, finn, nora | Quelle/Postfach gelesen (metaJson.count) |
fund |
Such-Worker | neuer Kandidat (Zettel entsteht) |
doppelt |
arne | Register-Treffer, Kandidat verworfen (Stempel „schon bekannt“) |
uebergabe |
alle | Dokument geht an to (Lauf + Lift) |
bewertet |
bea | Punktzahl vergeben |
top3 |
bea | Kandidat bekommt Mail-/Antwort-Platz |
botlead |
arne | in die Bot-Lead-Liste abgelegt (Keller) |
entwurf |
max, clara, theo | Nachricht entworfen |
pult |
clara, theo, nora, paul, system | Eintrag ins Freigabe-Pult (Briefkasten-Badge) |
gesendet |
max, system | Nachricht verschickt (Max) bzw. Mensch hat gesendet (system, metaJson.by) |
eingang |
nora | Antwort eingegangen |
sortiert |
nora | eingeordnet (metaJson.classification) |
bau_start / bau_fertig |
bob, bianca, ben | Vorschau wird gebaut / ist fertig |
pruefung_zurueck / pruefung_ok |
paul | Runde zurück (metaJson.round) / bestanden |
vorschau_link |
max | Vorschau-Link verschickt |
angebot |
theo | Kauf-Link erzeugt |
deal |
system | bezahlt (Pokal + Münzen in den Tresor) |
gesperrt |
system, nora | Kontakt auf Sperrliste |
budget_warnung / budget_stopp |
otto | 80 % erreicht / Grenze erreicht |
pause |
system | Pausenschalter umgelegt (metaJson.paused) |
fehler |
alle | Fehler (rotes Ausrufezeichen) |
6. Dienst – feste Signaturen (P1 liefert, spätere Pläne nutzen)
Pfade relativ zum Dienst-Repo. Alle Funktionen async, sofern sie I/O machen. deps = injizierte Abhängigkeiten { db, clock, claude, mailer, imap, log }.
| Datei | Export | Signatur / Vertrag |
|---|---|---|
src/config.js |
loadConfig(env) |
→ { appwrite:{endpoint,projectId,apiKey}, anthropicKey, api:{port:8092}, vorschau:{port:8093, root:'/data/vorschau', baseUrl:'https://entwurf.project.webklar.com'}, smtp:{…}, imap:{…}, alertImap:{…}, reportSmtp:{…}, places:{apiKey}, tz }; wirft mit Liste fehlender Namen (nie Werte). |
src/db.js |
createDb({endpoint,projectId,apiKey}) |
→ { get(col,id), list(col,queries), listAll(col,queries), create(col,data,id?), update(col,id,data), remove(col,id) }; create mit vorhandener $id wirft ConflictError (409). |
src/schema.js |
SCHEMA |
Array { id, name, attributes:[{key,type,size,required,array,elements,default}], indexes:[…] } exakt nach §4. |
src/register/normalize.js |
normalizeName, normalizePhone, normalizeEmail, companyDomain (Kopie aus WOMS src/lib/leadDuplicates.js, plus Fix „(0)“), normalizeDomain(input), normalizePhoneE164(input), emailKey(input), placeIdKey(input), namePlzKey(name, plz) |
reine Funktionen; leerer String = kein Schlüssel. |
src/register/keys.js |
keysFor(p) |
p = Prospect-ähnliches Objekt → [{kind,value}] (ohne leere). |
keyId(kind, value) |
→ 32-Zeichen-Hash (§4.3). | |
claimKeys(deps, keys, {prospectId, origin, blocked=false}) |
legt alle harten Schlüssel an; → { fresh: boolean, hits: [{kind, prospectId, blocked, origin}], hints: [...] }. fresh=false, sobald ein harter Schlüssel schon existiert (erste Schreiberin gewinnt, 409 = Treffer). namplz erzeugt nur hints. |
|
isSuppressed(deps, {email, domain, phone}) |
→ { suppressed: boolean, reason }. |
|
suppress(deps, {email, domain, phone}, reason, source) |
idempotent. | |
src/events.js |
emit(deps, {workerId, kind, text, jobId?, prospectId?, link?, to?, tokensIn?, tokensOut?, costUsd?, meta?}) |
schreibt workersEvents; prüft kind gegen §5 und workerId gegen ROLES; kürzt text auf 300. |
src/roles.js |
ROLES, FLOORS, role(workerId), assertCap(workerId, cap), CapError |
§3. |
src/budget.js |
costOf(model, usage, {batch}) |
→ USD nach Preisliste (Opus 4/20, Sonnet 2/10, Haiku 0,10/0,50 pro MTok; Cache-Lesen ×0,05 Opus/Sonnet, ×0,1 Haiku; Cache-Schreiben 5 min ×1,25, 1 h ×2; Batch Token ×0,5; Websuche 0,01 je Suche, nicht rabattiert). |
checkBudget(deps, estUsd) |
→ `{ ok, reason: 'tag' | |
book(deps, {workerId, usd, counters}) |
addiert in workersDaily; löst einmal budget_warnung bzw. budget_stopp aus. |
|
src/claude.js |
createClaude({apiKey, sdk}) |
→ { ask(opts), search(opts), batch }. |
ask(deps, {workerId, model?, system, input, schema?, images?, maxTokens, effort?}) |
prüft assertCap nur für werkzeuglose Aufrufe nicht; prüft checkBudget; ruft Messages API; bucht Kosten; → `{ data |
|
search(deps, {workerId, system, task, maxUses, allowedDomains?, blockedDomains?}) |
assertCap(workerId,'web_search'); web_search_20260318 + web_fetch_20260318 mit allowed_callers:['direct']; folgt pause_turn max. 5×; → { text, sources:[{url,title}], usage, costUsd }. |
|
src/settings.js |
getSettings(deps), updateSettings(deps, patch, by) |
Standardwerte nach §4.1; updateSettings validiert Grenzen (Budget > 0, mailColdPerDay ≤ 3 ohne Ausnahme). |
src/schedule.js |
berlinNow(clock), isDue(kind, now, lastRunDay), inSendWindow(now, settings) |
Muster Serverpuls (src/schedule.js dort). |
src/jobs.js |
enqueue(deps, {kind, workerId, prospectId?, threadId?, previewId?, payload}), next(deps, {kinds}), finish(deps, jobId, result), fail(deps, jobId, error) |
|
src/pult.js (P3) |
addPult(deps, {kind, title, prospectId?, threadId?, previewId?, originalText?, draft?, sourceUrl?}), claimPult, markSent, discard, expireStale |
|
src/mail/gate.js (P4) |
sendMail(deps, {workerId:'max', prospectId, threadId, to, subject, text, cold: boolean, inReplyTo?, references?}) |
einziger Versandweg: assertCap('max','send_mail') → Sperrliste → Tageszähler/Grenzen → Sendefenster → Pflichtbausteine → SMTP → workersMessages + Ereignis gesendet. → { sent:boolean, reason?, messageId? }. |
src/mail/footer.js (P4/P6) |
appendCompliance(text, {prospect, cold, unsubscribeUrl}) |
hängt Pflichtbausteine an (Spec §8.4). |
src/vorschau/server.js (P4) |
startVorschauServer(deps, {port, root}) |
statisch, nur GET /<token>/…, Header X-Robots-Tag: noindex, nofollow, kein Verzeichnis-Listing, 404 bei unbekanntem Token/abgelaufen. Zusätzlich Abmelden: GET /abmelden/<u> (Seite mit Knopf) und POST /abmelden/<u> (RFC 8058 One-Click, Body List-Unsubscribe=One-Click) → suppress(...,'abmeldung') + Ereignis gesperrt. <u> = unsubToken(prospectId) aus src/mail/unsub.js (HMAC-SHA256 mit WORKERS_UNSUB_SECRET, base64url, 22 Zeichen + . + prospectId). |
6.1 Prozess und Tick (P1)
src/main.js: Konfiguration laden → db → API-Server (8092) → (P4) Vorschau-Server (8093) → Tick alle 60 s. Der Tick fragt isDue für: nachtplan (22:00), nacht2 (01:00), bericht (06:30), kuerzen (03:00, P6), pultAlt (07:00, P3), posteingang (alle 2 min, P4), alarme (alle 10 min, P3), senden (alle 5 min im Sendefenster, P4), batches (alle 5 min). Tagesmarker in workersDaily.countersJson.__runs (neustartfest). Bei paused arbeitet nur pause-sichere Arbeit (Posteingang lesen + Sperrliste), nichts wird gesendet oder gebaut.
6.2 HTTP-API des Dienstes (P1 Gerüst, spätere Pläne ergänzen)
Basis intern: http://webklar-workers:8092. Von WOMS über nginx https://ticket.webklar.com/api/workers/… (neuer location-Block nach Muster /api/serverpuls/). Auth: Header X-Appwrite-JWT → Appwrite GET /account (60 s Cache) → Label mitarbeiter oder admin nötig; admin für Einstellungen. Antworten JSON, Fehler { error: '<deutscher Satz>' }.
| Methode + Pfad | Plan | Recht | Antwort |
|---|---|---|---|
GET /api/workers/health |
P1 | keins | { ok:true, version } |
GET /api/workers/state |
P1 | Team | `{ paused, workers:[{workerId, status:'arbeitet' |
GET /api/workers/events?after=<ISO>&limit=<≤200> |
P1 | Team | { events:[{id, ts, workerId, kind, text, link, to, prospectId, costUsd, meta}] } aufsteigend nach ts |
GET /api/workers/worker/:workerId/trace?limit=<≤50> |
P1 | Team | { worker:{…ROLES-Eintrag, model}, today:{counters, tokens, costUsd}, events:[…] } |
POST /api/workers/pause {paused:boolean} |
P1 | Team | { paused } (+ Ereignis pause) |
GET /api/workers/settings / PUT /api/workers/settings |
P1 | Admin | workersSettings ohne interne Zähler |
GET /api/workers/pult?status=offen |
P3 | Team | { items:[…§4.11] } |
POST /api/workers/pult/:id/claim · /sent · /discard · /done |
P3 | Team | Eintrag |
POST /api/workers/pult/:id/reply {text} |
P4 | Team | sendet über sendMail als mensch:<userId> (Mail-Fäden) |
POST /api/workers/pult/:id/approve-preview |
P4 | Team | gibt Vorschau-Link frei |
GET /api/workers/botleads?room=&q=&page= |
P6 | Team | { items:[…], total } (Telefon nur wenn Quelle vorhanden und nicht isHealth) |
GET /api/workers/prospect/:id |
P3 | Team | Interessent + Faden + Nachrichten + Vorschau |
7. WOMS – feste Namen (P2 liefert)
| Datei | Export / Zweck |
|---|---|
src/lib/navLinks.js |
Eintrag { key:'workers', label:'Workers', href:'/workers' } ersetzt leads (nicht mehr paused). |
src/App.jsx |
Route /workers → React.lazy(() => import('./pages/WorkersPage.jsx')); /leads leitet auf /workers um. |
src/lib/workersApi.js |
createWorkersApi({ getJwt, fetchImpl }) → { state(), events(after), trace(workerId), pause(paused), settings(), saveSettings(patch), pult(status), pultAction(id, action, body?), botleads(params), prospect(id) }; Header X-Appwrite-JWT; Basis /api/workers. |
src/lib/workersRoster.js |
ROLES, FLOORS (Spiegel von §3), hatForModel(model). |
src/lib/workersView.js |
reine Ableitungen: formatUsd, formatCount, workerStatusLabel, kpiTiles(kpis). |
src/lib/workersEvents.js |
reduceEvents(stateByWorker, events) → { [workerId]: { status, task, lastTs } }; eventToSteps(event) → Animationsschritte für die Regie. |
src/hooks/useWorkersFeed.js |
Polling: state alle 15 s, events?after= alle 5 s, Pause bei document.visibilityState==='hidden'. |
src/components/workers/engine/* |
scene.js: mountTower(canvas, { onSelect }) → { destroy(), setState(state), pushEvents(events), select(workerId), setPaused(bool) } + sprites.js, props.js, layout.js, building.js, lift.js, director.js, fx.js (Plain Canvas 2D, Vorlage scene-turm.js). |
src/components/workers/{WorkersStage,WorkerCard,WorkersKpis,WorkersSettingsDialog}.jsx |
UI-Teile. P3 ergänzt PultList.jsx, P6 BotLeadList.jsx. |
src/pages/WorkersPage.jsx |
Reiter Hochhaus · Freigabe (P3) · Bot-Leads (P6). |
src/styles/workers.css |
Klassen mit Präfix wk-. Schrift „Pixelify Sans“ selbst gehostet (public/fonts/PixelifySans-*.woff2). |
tests/fixtures/workers-roster.json |
gemeinsame Fixture (§3). |
8. Portal – feste Namen (P5 liefert)
| Datei | Zweck |
|---|---|
public/uebernahme.html + public/js/uebernahme.js |
Seite „Website übernehmen“: zeigt Betrieb, Vorschau-Link, Angebotssatz, Abo-Bedingungen v5 (Wortlaut wie normaler Checkout), Widerruf/Leistungsbeginn, Kundenart, Rechnungsangaben; Token aus location.hash. |
server/routes/uebernahme.js |
GET /api/uebernahme/:token → Angebotsansicht oder 404; POST /api/uebernahme/:token/checkout → Stripe-Checkout-URL. Öffentlich, X-Robots-Tag: noindex, createWriteLimiter, 404 bei jedem ungültigen/abgelaufenen Token (Muster routes/auftrag.js). |
server/services/workersKauf.js |
findOfferByToken(token), createWorkersCheckout(offer, consent, buyer), handleWorkersCheckoutEvent(event), isWorkersCheckoutEvent(event) (Session-metadata.workersOfferId vorhanden). |
server/routes/abo.js |
im Webhook vor dem normalen Zweig: if (isWorkersCheckoutEvent(event)) return handleWorkersCheckoutEvent(event). |
server/mail-templates/K44 Website übernommen.html |
Zugangsdaten + Begrüßung (nächste freie Nummer laut Code-Landkarte; vor dem Anlegen erneut alle Branches prüfen). |
server/config.js |
collections.workersOffers, collections.workersProspects, collections.workersPreviews, collections.workersEvents (Env APPWRITE_COLLECTION_WORKERS_*, Standard = Sammlungs-ID). |
Ablauf nach Zahlung (handleWorkersCheckoutEvent, idempotent über workersOffers.status): Kunde mit createCustomerWithPortalAccess({ name, email, password:<zufällig>, kundenart }) anlegen (Status danach customer) → Stripe-Customer + Subscription-Metadaten webklarCustomerId setzen → syncSubscription → recordAbschluss → Nachweis (featureConsents) → K44 senden → forgetStoredPortalPassword → T02 an Team → Vorschau als Kundenprojekt übernehmen (Repo aus Vorlage in Org WEBklar mit Systembenutzer, Dateien hochladen, websiteProjects mit customerId, Typ preview) → workersOffers.status='bezahlt', workersProspects.status='deal' + customerId → Ereignis deal (workerId system).
9. Betrieb – feste Namen
| Was | Wert |
|---|---|
| Container | webklar-workers (Compose-Dienst workers, build: ./workers, Image webklar-workers:latest, Muster Serverpuls-Dockerfile + aircard-api-Härtung) |
| Ressourcen | mem_limit: 768m, memswap_limit: 768m, cpus: 1.0, NODE_OPTIONS=--max-old-space-size=384, init: true, restart: unless-stopped, user: node, read_only: true, tmpfs: /tmp, cap_drop: [ALL], security_opt: [no-new-privileges:true], logging json-file max-size 5m max-file 2, TZ=Europe/Berlin |
| Volume | workers_data:/data (Vorschauen unter /data/vorschau/<token>/) |
| Netz | webklar-network; intern http://webklar-appwrite/v1 für Appwrite |
| Chromium | im Image per apk add --no-cache chromium (für Paul), Screenshots seriell, 60 s Zeitlimit |
| nginx (WOMS) | neuer Block location /api/workers/ → webklar-workers:8092 in /home/webklar/apps/ticket.webklar.com/nginx/ticket.webklar.com.conf (Backup + nginx -s reload im Container webklar-ticket) |
| Traefik | Datei /home/webklar/apps/traefik/dynamic/workers-entwurf.yml: Router Host(\entwurf.project.webklar.com`), Priorität 200, certResolver: letsencrypt, Ziel http://webklar-workers:8093` (P4) |
| Reservierte Subdomain | entwurf im Portal-Subdomain-Schutz sperren (P5) |
Env (.env → Compose) |
WORKERS_APPWRITE_API_KEY→APPWRITE_API_KEY, WORKERS_ANTHROPIC_API_KEY→ANTHROPIC_API_KEY, APPWRITE_ENDPOINT=http://webklar-appwrite/v1, APPWRITE_PROJECT_ID=6a1058610003c5a13a05, WORKERS_SMTP_HOST/PORT/USER/PASS/FROM (Absender-Postfach der neuen Domain), WORKERS_IMAP_HOST/PORT/USER/PASS (gleiches Postfach), WORKERS_ALERT_IMAP_HOST/PORT/USER/PASS (Alarm-Postfach), WORKERS_REPORT_SMTP_* (interner Morgenbericht über das bestehende webklar.com-Konto erlaubt, da kein Kaltkontakt), WORKERS_PLACES_API_KEY (P6), WORKERS_UNSUB_BASE=https://entwurf.project.webklar.com/abmelden, WORKERS_UNSUB_SECRET (32 Byte zufällig) |
| Serverpuls | Gruppe {"app":"Workers","match":["webklar-workers*"],"folders":["workers"]} in apps/serverpuls/config/apps.json |
| Anthropic | eigener Console-Workspace „Workers“, Ausgabenlimit 250 $/Monat (Kenso) |
10. Was Kenso vorab einrichtet (Spec §12)
Console-Workspace + Schlüssel; Domain webklar-team.de (INWX) + Google-Workspace-Postfächer (Absender + Alarme) oder Alternative; Konten + Suchaufträge auf Kleinanzeigen/freelancermap/freelance.de an das Alarm-Postfach; Freigabe des Datenschutz-Abschnitts „Interessenten“. Jeder Plan nennt am Ende seine Betriebsanleitung (Schritte, die Kenso ausführt: Skripte, .env, Compose, nginx, Push).