Files
dogfather-universe/server/pruef-rueckmeldung.mjs
T
DogFatherGitandClaude Opus 5 39ca642a52 Acht Pruefungen standen Nacht fuer Nacht als rot da -- sie waren gruen
Der naechtliche Lauf meldete heute frueh "37 Pruefungen sind rot".
Acht davon waren gar nicht rot: fehlerfrei durchgelaufen, Exitcode 0,
keine einzige FEHL-Zeile. Der Laeufer zaehlte bei ihnen aber NULL
Einzelpruefungen, und "null Pruefungen ist ein Fehler" -- eine Regel,
die richtig ist und hier das Falsche traf.

DER GRUND WAR EIN GROSSBUCHSTABE. Der Zaehler suchte `(ok|FEHL)`;
pruef-kachelfarben, -livepunkt, -tippziele, -turn-wege, -leerzustand,
-nachfrage, -tagesruf und -anruf-klingelt schreiben `  OK `.

Zwei Schaeden auf einmal:
  1. Ihre Punkte fehlten in der Gesamtzahl -- allein in fuenf der acht
     sind das 114 Stueck. Ausgerechnet die Zahl, die vor stillen
     Aussetzern schuetzen soll, war selbst eine Luecke.
  2. Acht dauerhaft rote Eintraege in einer Notiz, die morgens gelesen
     wird. Eine Warnung, die immer kommt, ist keine mehr.

Der Zaehler liest jetzt beide Schreibweisen. Die Dateien werden NICHT
vereinheitlicht: Acht umzuschreiben waeren acht Gelegenheiten, etwas
kaputtzumachen -- und die neunte, die morgen jemand anlegt, schreibt
ohnehin wieder, was sie will.

DAZU EINE PRUEFUNG, DIE DAS FESTHAELT (server/pruef-pruefzaehler.mjs).
Ein Kommentar haette den naechsten Fall nicht verhindert. Sie holt
sich den Ausdruck AUS alles-pruefen.mjs (zwei Fassungen desselben
Musters laufen auseinander), startet vier der schnellsten Pruefungen
wirklich -- je zwei in beiden Schreibweisen -- und hat drei
Gegenproben: Sie darf nicht wahllos zaehlen, sie muss zwei als zwei
zaehlen, und sie muss null als null sehen koennen, sonst waere "null
ist ein Fehler" blind.

UND DIE NOTIZ SAGT JETZT, WAS SIE MEINT. Statt "(keine Fehlerzeile
gefunden)" steht bei diesen Faellen: "Kein Befund -- es wurde keine
einzige Pruefung GEZAEHLT", mit der Erklaerung dazu. Fuer alles andere
ohne Fehlerzeile gibt es den dritten Ausgang ("Konnte nicht sagen,
woran es lag") samt den letzten Ausgabezeilen.

NEBENBEFUND, GEFUNDEN VON pruef-rueckmeldung: Die Tuer ins andere Haus
trug Ton 40 -- dieselbe Nummer wie "Entwicklung". Auf DogFathers Wand
standen zwei Kacheln in derselben Farbe, und er ist der Einzige, der
beide sieht; deshalb ist es nie jemandem aufgefallen.

  Die naheliegende Antwort waere eine 46. Farbe gewesen. Gerechnet kam
  ein Abstand von 0,0862 heraus -- unter der Hausgrenze von 0,09. Der
  Farbraum ist bei 45 Toenen voll, und eine zweite benannte Ausnahme
  am selben Tag waere der Anfang vom Ende der Regel.

  Richtig ist die andere Antwort: Die Tuer ist gar keine
  Bereichskachel. Sie fuehrt hinaus und gehoert keinem Bereich an --
  also bekommt sie keine Bereichsfarbe, sondern faellt auf --akzent
  zurueck. Damit unterscheidet sie sich von allen 45 anderen.

  Gefunden hat das die Pruefung erst, nachdem sie SAGEN konnte, welche
  Nummer doppelt ist. Vorher stand dort nur "kein Farbton doppelt (34
  Kacheln vom Server)" -- damit sucht man in vier Listen ueber zwei
  Dateien.

  Dazu: start.js schreibt kein data-ton="undefined" mehr (String()
  macht aus einem fehlenden Wert sonst ein Wort, und jede Pruefung,
  die Toene zaehlt, liest dann Text statt Zahl).

WAS MICH VOR DER FALSCHEN REPARATUR BEWAHRT HAT: Meine erste Vermutung
war, die FEHL-Zeilen staenden zu weit hinten im Ausgabefenster. Die
Gegenprobe dazu wurde rot -- null Dateien. Genau dafuer ist sie da.

GEMESSEN:
- pruef-pruefzaehler (neu): 7 Pruefungen, 0 Fehler.
- pruef-rueckmeldung: 30 geprueft, 0 Fehler (war rot).
- pruef-kachelfarben 26/0, pruef-haus-trennung 100/0,
  pruef-crew-adresse 153/0.
- tools/.nachtlauf-* ist jetzt ignoriert: Laufzeitzustand, der sich
  jede Nacht aendert. Das Ergebnis steht in der Vault-Notiz.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-25 06:46:49 +02:00

368 lines
17 KiB
JavaScript

/* RÜCKMELDUNG IN BEIDE RICHTUNGEN (10.09.2026)
Kapitel 5 und 6 des Pflichtenhefts:
"Nicht nur ich soll meine Modis bewerten oder ihnen Feedback geben
können. Auch die Modis sollen mir Feedback geben können. Sie sollen
mir beispielsweise sagen können: Was könnte ich verbessern?"
"Es soll nicht nur dazu dienen, Leistungen zu kontrollieren. Es soll
vor allem dabei helfen, als Team besser zu werden."
---------------------------------------------------------------------
DIE PRÜFUNG, DERENTWEGEN ES DIESE DATEI GIBT
Wer beim Schreiben „nur an DogFather" wählt, verlässt sich darauf.
Diese Zusage hat genau eine Stelle, an der sie gilt --
`sichtbarEintrag()` --, und genau zwei Arten, still zu brechen:
Sie könnte NICHT WEIT GENUG gehen: Die Liste ist gefiltert, aber
über die Nummer der Zeile (Ändern, Löschen) kommt man trotzdem
heran. Deshalb wird hier auch von hinten geklopft.
Sie könnte ZU WEIT gehen: `nur_leitung` ist eine neue Spalte, und
jede Zeile, die es vorher gab, hat dort NULL. In SQL ist
`NULL = 0` nicht falsch, sondern UNBEKANNT -- ohne COALESCE wäre
der gesamte alte Bestand von einer Minute auf die andere
unsichtbar gewesen. Ein leeres Brett sieht nicht nach Fehler aus;
das hätte niemand gemeldet.
Beide Fälle stehen unten, jeder mit seiner Gegenprobe.
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-rueck-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
const { eigenerPort } = await import("./helfer-port.mjs");
const PORT = await eigenerPort(import.meta, "die Rueckmeldungspruefung");
process.env.PORT = `${PORT}`;
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
await import("./index.js");
await new Promise((r) => setTimeout(r, 1000));
const BASIS = `http://127.0.0.1:${PORT}`;
/* Netz und Notbremse -- index.js faengt sonst jeden Absturz ab, und der
Lauf sieht von aussen aus wie "laeuft noch". */
for (const art of ["uncaughtException", "unhandledRejection"]) {
process.on(art, (f) => { console.error(""); console.error("ABBRUCH:", f?.stack || f); process.exit(1); });
}
setTimeout(() => { console.error(""); console.error("ABBRUCH: 120 s vorbei."); process.exit(1); },
120000).unref?.();
let fehler = 0, geprueft = 0;
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
for (let i = 0; i < 60; i++) {
try {
const p = new DatabaseSync(process.env.WORKSPACE_DB);
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
} catch { await new Promise((r) => setTimeout(r, 250)); }
}
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
function anlegen(name, rolle, code) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768, jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
anlegen("Vanessa", "hand", "CODE-HAND-0001");
const idAyla = anlegen("Ayla", "modi", "CODE-TEAM-0001");
anlegen("Ben", "modi", "CODE-TEAM-0002");
anlegen("Cigdem", "manager", "CODE-MANA-0001");
/* Team-Rollen kommen nur ueber die Crew-Wand herein, Agenturrollen nur
ueber die Agenturwand -- die Regel vom 10.09.2026. `fetch` kann den
Host-Kopf nicht setzen (undici setzt ihn aus der Adresse), deshalb
node:http. */
const { request: httpAnfrage } = await import("node:http");
function anmelden(rolle, code, wand = "crew.dogfather-universe.com") {
return new Promise((fertig) => {
const rumpf = JSON.stringify({ rolle, code });
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: "/workspace/api/anmelden", method: "POST",
headers: { "Content-Type": "application/json",
"Content-Length": Buffer.byteLength(rumpf), Host: wand },
}, (antwort) => {
antwort.resume();
antwort.on("end", () => fertig({
ok: antwort.statusCode === 200,
keks: (antwort.headers["set-cookie"] || []).map((k) => k.split(";")[0]).join("; "),
}));
});
a.on("error", () => fertig({ ok: false, keks: "" }));
a.end(rumpf);
});
}
const rufe = (keks, weg, koerper, methode = "GET") =>
fetch(BASIS + weg, {
method: methode,
headers: { "Content-Type": "application/json", Cookie: keks },
body: koerper === undefined ? undefined : JSON.stringify(koerper),
});
const holen = async (keks, weg) => (await rufe(keks, weg)).json();
/* MIT EINER BESTIMMTEN ADRESSE FRAGEN -- UND WARUM NICHT MIT `fetch`
(21.09.2026).
Welches Haus jemand sieht, entscheidet der Host der EINZELNEN
Anfrage. Eine Anmeldung ueber die Crew-Wand genuegt nicht, wenn die
naechste Abfrage an 127.0.0.1 geht.
`fetch` kann das nicht: `Host` ist ein verbotener Kopfzeilenname und
wird STILLSCHWEIGEND verworfen -- kein Fehler, keine Warnung, die
Anfrage geht einfach an die falsche Adresse. Genau deshalb benutzt
auch `anmelden()` weiter unten schon `http.request` statt `fetch`.
Eine halbe Stunde Suche waere das gewesen, haette die Pruefung nicht
Kachel fuer Kachel gemeldet, was sie findet. */
const holenVon = (keks, weg, wand) => new Promise((fertig, schief) => {
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: weg, method: "GET",
headers: { Cookie: keks, Host: wand },
}, (antwort) => {
let text = "";
antwort.on("data", (s2) => { text += s2; });
antwort.on("end", () => {
try { fertig(JSON.parse(text || "{}")); } catch { fertig({}); }
});
});
a.on("error", schief);
a.end();
});
const dogi = await anmelden("admin", "CODE-DOGI-0001");
const hand = await anmelden("hand", "CODE-HAND-0001");
const ayla = await anmelden("modi", "CODE-TEAM-0001");
const ben = await anmelden("modi", "CODE-TEAM-0002");
const cigdem = await anmelden("manager", "CODE-MANA-0001", "workspace.dogfather-universe.com");
ok([dogi, hand, ayla, ben, cigdem].every((x) => x.ok && x.keks), "alle fuenf sind angemeldet");
const WEG = "/workspace/api/bereich/rueckmeldung";
const titel = async (keks) =>
((await holen(keks, WEG)).eintraege || []).map((e) => e.titel);
/* =======================================================================
1. Es gibt den Bereich, und er hat die Fragen aus dem Pflichtenheft
======================================================================= */
console.log("");
console.log("=== Der Bereich ===");
{
const a = await holen(dogi.keks, WEG);
ok(a.einstellung?.name === "Rückmeldung", `DogFather kann ihn oeffnen (${a.einstellung?.name})`);
const arten = Object.keys(a.einstellung?.arten || {});
ok(arten.length === 9, `neun Fragen (${arten.join(", ")})`);
ok(a.einstellung?.vertraulich === true, "und er kennt die vertrauliche Zeile");
const b = await holen(ayla.keks, WEG);
ok(b.einstellung?.name === "Rückmeldung", "das Team kann ihn auch oeffnen");
/* GEGENPROBE: Die Agentur hat hier nichts zu suchen -- 404 und nicht
403, sonst waere allein die Antwort schon eine Auskunft. */
const c = await rufe(cigdem.keks, WEG);
ok(c.status === 404, `ein Manager kommt nicht hinein (${c.status})`);
}
/* =======================================================================
2. Beide Richtungen
======================================================================= */
console.log("");
console.log("=== Wer schreibt wem ===");
let offenId = 0, geheimId = 0;
{
const a = await rufe(ayla.keks, WEG, {
art: "gut", titel: "Die Absprachen vor dem Live helfen sehr",
datum: "2026-09-10", dringlichkeit: "niedrig",
}, "POST");
offenId = (await a.json())?.id;
ok(a.status === 201 && offenId > 0, `ein Teammitglied schreibt fuers Team (${a.status})`);
const b = await rufe(ayla.keks, WEG, {
art: "dogfather", titel: "Du unterbrichst uns oft mitten im Satz",
datum: "2026-09-10", dringlichkeit: "mittel", nur_leitung: 1,
}, "POST");
geheimId = (await b.json())?.id;
ok(b.status === 201 && geheimId > 0, `und eine nur an DogFather (${b.status})`);
const c = await rufe(dogi.keks, WEG, {
art: "behalten", titel: "Die Schichtübergabe so lassen",
datum: "2026-09-10", dringlichkeit: "niedrig",
}, "POST");
ok(c.status === 201, `DogFather schreibt in dieselbe Sammlung (${c.status})`);
/* Die Spalte muss wirklich gesetzt sein -- nicht nur die Antwort
stimmen. Ohne diese Zeile waere die Pruefung gruen, wenn das Feld
stillschweigend verworfen wuerde. */
const zeile = d.prepare("SELECT nur_leitung FROM eintraege WHERE id = ?").get(geheimId);
ok(zeile?.nur_leitung === 1, `die Zeile steht als vertraulich in der Datenbank (${zeile?.nur_leitung})`);
const offen = d.prepare("SELECT nur_leitung FROM eintraege WHERE id = ?").get(offenId);
ok(offen?.nur_leitung === 0, `und die andere als offen (${offen?.nur_leitung})`);
}
/* =======================================================================
3. DIE ZUSAGE: „nur DogFather" heisst nur DogFather
======================================================================= */
console.log("");
console.log("=== Wer sieht was ===");
{
const beiDogi = await titel(dogi.keks);
ok(beiDogi.some((t) => t.startsWith("Du unterbrichst")),
`DogFather sieht die vertrauliche Zeile (${beiDogi.length} Eintraege)`);
const beiAyla = await titel(ayla.keks);
ok(beiAyla.some((t) => t.startsWith("Du unterbrichst")),
"die Schreiberin sieht ihre eigene");
/* DIE EIGENTLICHE PRUEFUNG. Die rechte Hand sieht sonst ueberall
dasselbe wie DogFather -- hier nicht. */
const beiHand = await titel(hand.keks);
ok(!beiHand.some((t) => t.startsWith("Du unterbrichst")),
`die rechte Hand sieht sie NICHT (${beiHand.join(" | ")})`);
const beiBen = await titel(ben.keks);
ok(!beiBen.some((t) => t.startsWith("Du unterbrichst")),
"ein anderes Teammitglied auch nicht");
/* GEGENPROBE: Sonst koennte "sieht sie nicht" auch heissen "sieht
ueberhaupt nichts". Die OFFENE Zeile muss bei beiden ankommen. */
ok(beiHand.some((t) => t.startsWith("Die Absprachen")),
"die offene Zeile sehen beide sehr wohl");
ok(beiBen.some((t) => t.startsWith("Die Absprachen")),
"auch das andere Teammitglied");
}
/* =======================================================================
4. Und von hinten? Die Nummer ist kein Schluessel
======================================================================= */
console.log("");
console.log("=== Ueber die Nummer ===");
{
const a = await rufe(hand.keks, `${WEG}/${geheimId}`, { titel: "umgeschrieben" }, "PATCH");
ok(a.status === 404, `die rechte Hand kann sie nicht aendern (${a.status})`);
const b = await rufe(hand.keks, `${WEG}/${geheimId}`, undefined, "DELETE");
ok(b.status === 404, `und nicht loeschen (${b.status})`);
const heil = d.prepare("SELECT titel FROM eintraege WHERE id = ?").get(geheimId);
ok(heil?.titel?.startsWith("Du unterbrichst"), "die Zeile steht unveraendert da");
/* GEGENPROBE: An der OFFENEN Zeile darf sie arbeiten -- sonst hiesse
die 404 oben nur "diese Route geht fuer sie nie". */
const c = await rufe(hand.keks, `${WEG}/${offenId}`, { status: "erledigt" }, "PATCH");
ok(c.status === 200, `an der offenen Zeile darf sie arbeiten (${c.status})`);
}
/* =======================================================================
5. Der alte Bestand (NULL) bleibt sichtbar
======================================================================= */
console.log("");
console.log("=== Was vor der Spalte da war ===");
{
d.prepare(`INSERT INTO eintraege (bereich, art, titel, datum, erstellt, erstellt_von, nur_leitung)
VALUES ('rueckmeldung','idee','Aus der Zeit vor der Spalte',?,?,?,NULL)`)
.run("2026-09-01", jetzt, idAyla);
const beiHand = await titel(hand.keks);
ok(beiHand.some((t) => t === "Aus der Zeit vor der Spalte"),
"eine Zeile mit NULL gilt als offen und bleibt sichtbar");
/* Die Gegenprobe zur Gegenprobe: Genau diese Zeile MIT 1 waere weg. */
d.prepare("UPDATE eintraege SET nur_leitung = 1 WHERE titel = 'Aus der Zeit vor der Spalte'").run();
const nachher = await titel(hand.keks);
ok(!nachher.some((t) => t === "Aus der Zeit vor der Spalte"),
"dieselbe Zeile auf 1 gesetzt ist fuer sie weg -- die Regel greift wirklich");
d.prepare("UPDATE eintraege SET nur_leitung = NULL WHERE titel = 'Aus der Zeit vor der Spalte'").run();
}
/* =======================================================================
6. In anderen Bereichen gibt es das Feld nicht
======================================================================= */
console.log("");
console.log("=== Kein Versteck in anderen Bereichen ===");
{
const a = await rufe(ayla.keks, "/workspace/api/bereich/ideen", {
art: "content", titel: "Eine ganz normale Idee", datum: "2026-09-10",
nur_leitung: 1,
}, "POST");
const id = (await a.json())?.id;
ok(a.status === 201, `eine Idee laesst sich anlegen (${a.status})`);
const zeile = d.prepare("SELECT nur_leitung FROM eintraege WHERE id = ?").get(id);
ok(!zeile?.nur_leitung,
`das Feld wird dort stillschweigend verworfen (${zeile?.nur_leitung})`);
const beiHand = await holen(hand.keks, "/workspace/api/bereich/ideen");
ok((beiHand.eintraege || []).some((e) => e.id === id),
"und die Idee steht fuer alle da -- niemand kann sich dort verstecken");
}
/* =======================================================================
7. Die Kachel steht bei allen dreien -- mit demselben Namen
======================================================================= */
console.log("");
console.log("=== Die Kachel ===");
{
/* GEFRAGT WIRD IM EIGENEN HAUS (21.09.2026).
Bis heute fragte dieser Abschnitt ueber 127.0.0.1 -- also ueber
eine Pruefadresse, auf der die Agentursicht gilt. Solange
DogFather dort auch die Kacheln von Team Dogi bekam, fiel das
nicht auf.
Seit dem 21.09. bekommt er sie dort nicht mehr: Filipe wollte
"keine mischung mehr von kacheln", Team Dogi steht nur noch auf
seiner eigenen Adresse. Die Pruefung war damit rot, ohne dass die
Kachel fehlt -- sie hat nur am falschen Ort gesucht.
Gefragt wird deshalb mit dem Host der Crew-Wand, also genau dort,
wo diese Kachel zuhause ist. */
const CREW_WAND = "crew.dogfather-universe.com";
const kacheln = async (keks) => {
const i = await holenVon(keks, "/workspace/api/ich", CREW_WAND);
return [...(i.bereiche || []), ...(i.bereiche_zusatz || [])];
};
for (const [wer, keks] of [["DogFather", dogi.keks], ["die rechte Hand", hand.keks],
["das Team", ayla.keks]]) {
const k = (await kacheln(keks)).filter((x) => x.ziel === "bereich.html?b=rueckmeldung");
ok(k.length === 1 && k[0].name === "Rückmeldung",
`${wer}: genau eine Kachel, und sie heisst gleich (${k.map((x) => x.name).join("/") || "keine"})`);
}
/* Der Farbton muss neu sein -- zwei Kacheln in derselben Farbe sind
ein Fehler, den pruef-start-ansicht sonst erst am Bildschirm
findet. */
const alle = await kacheln(dogi.keks);
const toene = alle.map((x) => x.ton).filter((t) => t !== undefined);
/* UND WENN DOCH, DANN WELCHER -- die Meldung nennt die Nummer und
beide Kacheln. Vorher stand da nur "kein Farbton doppelt (34
Kacheln vom Server)"; daraus laesst sich nicht ablesen, WO man
suchen muss, und die Suche in vier Listen ueber zwei Dateien
dauert dann laenger als die ganze Reparatur. Dritter Ausgang im
Kleinen: Eine Pruefung, die nur "nein" sagt, ist eine halbe. */
const wo = new Map();
for (const k of alle) {
if (k.ton === undefined) continue;
if (!wo.has(k.ton)) wo.set(k.ton, []);
wo.get(k.ton).push(k.name || k.ziel || "?");
}
const doppelt = [...wo].filter(([, n]) => n.length > 1)
.map(([t, n]) => `Ton ${t}: ${n.join(" + ")}`);
ok(doppelt.length === 0,
`kein Farbton doppelt (${toene.length} Kacheln vom Server`
+ `${doppelt.length ? " -- " + doppelt.join("; ") : ""})`);
}
d.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
console.log("");
console.log(`${fehler === 0 ? "BESTANDEN" : "FEHLGESCHLAGEN"} -- ${geprueft} geprueft, ${fehler} Fehler`);
process.exit(fehler ? 1 : 0);