managarten/docs/plans/workbench-seeding-cleanup.md
Till JS 507532c367 docs(workbench-seeding-cleanup): record polish-pass commits
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:34:25 +02:00

10 KiB

Workbench-Seeding — Cleanup & Architektur-Hardening

Status (2026-04-25)

Rückwirkende Aufräumarbeit für workbenchScenes. Adressiert einen Race-Condition-Bug, der seit dem Space-Migration-Sweep (2026-04-22) bei jedem Login zusätzliche "Home"-Scenes anlegt — und nimmt die Gelegenheit, die ganze Bug-Klasse strukturell zu eliminieren.

Symptom

User-Reports: "viele Home-Scenes mit den immer gleichen Apps offen". Im IndexedDB der Personal-Space-Workbench kumulieren sich name='Home'-Scenes über die Sessions hinweg.

Bug-Analyse — Warum es passiert

Drei Ursachen-Schichten greifen ineinander:

1. Footgun im Creating-Hook (database.ts:1330)

Wenn ein neuer Record auf einer space-scoped Tabelle ohne spaceId geschrieben wird, stempelt der Hook automatisch spaceId = '_personal:<userId>' (Sentinel). Ursprünglich als Migrations-Brücke für v28 gedacht, verschluckt das heute jeden Code-Pfad, der vergisst die spaceId explizit zu setzen.

2. Drei verteilte Seeding-Pfade (workbench-scenes.svelte.ts)

  • Z. 293-296: count === 0ensureSeedScene() in initialize()
  • Z. 305-326: onActiveSpaceChanged Replay-on-Register feuert sofort → check r.spaceId === space.idensureSeedScene()
  • Z. 305-326 erneut: Bei jedem späteren Space-Wechsel

ensureSeedScene() setzt kein spaceId → der Hook stempelt Sentinel → der Dedup-Filter sucht aber nach echter Space-UUID → schlägt immer fehl → seedet immer wieder.

3. Idempotenz auf zufälligen UUIDs

Jeder Seed bekommt crypto.randomUUID(). Das macht "ist schon da?"-Checks von Inhalts-Vergleichen abhängig statt von Primary-Key-Constraints. Jeder Race produziert eine neue Row, weil die DB nichts zu blocken hat.

Race-Mechanik

+layout.svelte:611 startet loadActiveSpace().then(reconcileSentinels) parallel zu +page.svelte:69 workbenchScenesStore.initialize(). reconcileSentinels rewrited Sentinel-Rows pro Boot einmal zur echten Personal-Space-UUID. Mid-Session-Seeds (nach Reconcile geschrieben) bleiben bei Sentinel und werden erst beim NÄCHSTEN Boot reconciled. Resultat: jede Session fügt Duplikate hinzu.

Brand/Family/Team-Spaces verstärken den Effekt: jeder Wechsel dorthin findet keine Scene unter der Brand-UUID → seedet — landet aber per Sentinel-Stamping unter Personal-Space. Personal-Workbench füllt sich bei jedem Wechsel in einen anderen Space.

Best-Practice-Lösung in vier Schichten

Statt nur den Symptom-Patch (spaceId durchreichen) werden die unterliegenden Footguns alle adressiert, sodass die ganze Bug-Klasse strukturell unmöglich wird.

Schicht D-soft — Bestehende Duplikate aufräumen

Ziel: alle bereits angesammelten Home-Duplikate auf eine Survivor-Row pro Space reduzieren, ohne user-customisierte Scenes anzutasten.

  • Neue Datei data/scope/dedup-workbench-scenes.ts exportiert dedupHomeScenes(): Promise<number>.
  • Logik:
    1. Alle nicht-tombstoned Rows lesen, gruppieren nach (spaceId, name).
    2. Nur Gruppen mit name === 'Home' UND length > 1 UND keiner Row mit description / wallpaper / viewingAsAgentId / scopeTagIds (User-Customisierungen).
    3. Survivor-Pick: meiste openApps, dann jüngstes updatedAt.
    4. Merge: alle openApps der Verlierer (per appId deduped) in den Survivor übernehmen — nichts geht verloren.
    5. Verlierer soft-deleten (deletedAt = now) damit mana-sync den Cleanup an andere Geräte propagiert.
  • Aufruf-Stellen (idempotent — doppelter Lauf ist no-op):
    • Dexie v48 upgrade in database.ts. Läuft genau einmal pro Device beim Schema-Bump.
    • +layout.svelte handleAuthReady direkt nach reconcileSentinels(). Fängt den Edge-Case wo Sentinel-Rows nach dem Reconcile in die UUID-Gruppe wandern und dort neue Duplikate bilden.
  • Tests: data/scope/dedup-workbench-scenes.test.ts deckt: identische Duplikate → 1 Survivor; openApps-Merge dedupt nach appId; verschiedene Spaces bleiben getrennt; user-customisierte Scenes (custom description, wallpaper, viewingAsAgentId) bleiben unangetastet; non-Home-Namen bleiben unangetastet.

Schicht B + C — Zentrale Per-Space-Seeder-Registry mit deterministischen IDs

Ziel: alle Race-Pfade durch einen idempotenten Seeding-Eintrittspunkt ersetzen.

  • Neues Modul data/scope/per-space-seeds.ts:
    type Seeder = (spaceId: string) => Promise<void>;
    const seeders = new Map<string, Seeder>();
    export function registerSpaceSeed(name: string, fn: Seeder): void;
    export async function runSpaceSeeds(spaceId: string): Promise<void>;
    
  • Aufrufer-Hook: setActiveSpace() in active-space.svelte.ts ruft nach notifyHandlers(space) ein einziges void runSpaceSeeds(space.id).
  • Workbench-Modul registriert sich per Side-Effect-Import (bestehendes Muster wie bei seed-registry.ts):
    registerSpaceSeed('workbench-home', async (spaceId) => {
        const id = `seed-home-${spaceId}`;
        await db.workbenchScenes.put({ id, spaceId, name: 'Home', openApps: DEFAULT_HOME_APPS, ... });
    });
    
  • Deterministische ID seed-home-${spaceId} macht den Seed nativ idempotent: zweite Ausführung überschreibt Bit-für-Bit identisch, kein Duplikat möglich. Race-Conditions strukturell ausgeschlossen.
  • Aus workbench-scenes.svelte.ts entfernen:
    • Z. 293-296 count === 0 Block in initialize().
    • Z. 305-326 onActiveSpaceChanged-Handler (nur den Seed-Block, der LS-Read bleibt).
    • ensureSeedScene() Funktion (nicht mehr nötig).

Schicht A — Hook wirft statt Sentinel zu stempeln

Ziel: vergessene spaceId-Sets als hard-fail statt silent-corruption.

  • database.ts:1330 umstellen: wenn spaceId undefined/null AND Tabelle nicht in USER_LEVEL_TABLES:
    throw new Error(
        `[scope] write to space-scoped table '${tableName}' without spaceId. ` +
        `Set spaceId explicitly or move the table to USER_LEVEL_TABLES.`
    );
    
  • reconcileSentinels darf bleiben (rewriten historischer Sentinel-Daten weiter, falls vorhanden) — neue Writes sehen den Sentinel-Pfad nie mehr.
  • Erwartet: deckt 2-3 stille Bugs in anderen Modulen auf, die seit der v28-Migration unbemerkt durchgelaufen sind. Genau deshalb ist die Schicht wertvoll.
  • Risk-Mitigation: vor Schicht A einen Audit-Lauf (grep + Code-Review) der bestehenden .add(-Stellen über alle Module — wer setzt spaceId nicht? Diese Stellen vorab fixen.

Schicht D-hard — Cleanup als Schema-Invariante festschreiben

Ziel: den deterministischen Seed-ID-Vertrag im Code als fest erwarteten Zustand verankern.

  • 1-2 Tage nach Schicht B+C+A. Soak-Zeit, damit alle Devices via Sync den dedup'ten State sehen.
  • Dexie v49 (oder höher) Migration: alle workbenchScenes mit name === 'Home' und ohne ID-Prefix seed-home- umbenennen auf seed-home-${spaceId}. Falls Konflikt mit existierendem deterministischen Survivor: alte Row löschen.
  • Code-Annahme: queries dürfen ab hier db.workbenchScenes.get(\seed-home-${spaceId}`)` direkt benutzen, ohne By-Name-Filter-Fallback.

Reihenfolge — final state (alles SHIPPED)

Die ursprüngliche Vier-Schichten-Sequenz wurde während der Umsetzung zu einer einfacheren Architektur kondensiert: weil der Hook smart sein kann (statt nur Sentinel zu stempeln), entfällt die "throw on missing"-Variante und damit auch der Etappe-2-Soak.

  1. Schicht D-softd62ae8f1e (Dexie v48 dedup + +layout post-reconcile dedup)
  2. Schicht B + Cc73f93ff1 (per-space-seeds Registry, deterministic Home id, store-stripped) + 568d79dc1 (transitional legacy-Home check + wiring integration test)
  3. Schicht A Etappe 143bef2b24 (Helper + 16 explicit stamps, soft phase)
  4. Schicht A — finala6c5397d1 (smart hook stempelt aktive Space-UUID statt Sentinel; 16 explicit stamps reverted weil redundant)
  5. Schicht D-hard + Cleanupfa71269fc (v50 löscht legacy non-deterministic Home rows, transitional check aus Seeder raus, post-reconcile dedup aus +layout raus)
  6. Polish-Pass 2026-04-26e930a66ff (v48 dedup-Logik inline, dedup-Helper-Modul + 220 Test-Zeilen gelöscht; seedWorkbenchHomeOn → Promise<void>; comment-vocab bereinigt) + 8c5f064b0 (direkter Smart-Hook-Test in data/space-stamping.test.ts + Per-Space-Seeds-Pattern in apps/mana/CLAUDE.md dokumentiert)

Endzustand: eine Stelle entscheidet über Tenancy-Stamping (der Hook), deterministische IDs garantieren Idempotenz, kein transitional code mehr, kein post-reconcile fallback mehr, kein Dead-Code-Helper mehr, direkter Test für den Hook — exakt das was "saubere Lösung ohne legacy reste" verlangt.

Erfolgskriterien

  • Nach D-soft: User sieht in jedem Space genau eine Home-Scene mit allen openApps gemerged. Andere Custom-Scenes unverändert. Sync propagiert Cleanup an alle Devices.
  • Nach B+C: Login → keine neuen Duplikate, egal welche Race-Reihenfolge. Space-Wechsel zu fremdem Space erstellt Home-Scene dort, nicht im Personal-Space.
  • Nach A: jeder unbeabsichtigte add() ohne spaceId schlägt mit klarer Error-Message fehl.
  • Nach D-hard: deterministische Seed-IDs sind der einzige Weg "Home" in DB zu finden.

Risiken & Mitigations

  • D-soft soft-deletes durch sync gepullt → andere Devices sehen plötzlich weniger Scenes. Erwünscht — dedup ist genau der Zweck. Sync handelt soft-deletes via deletedAt korrekt.
  • D-soft falsch-positiv: User hat zwei legitime "Home"-Scenes manuell angelegt → die Heuristik ("kein description/wallpaper/agent/scope") schließt customisierte Rows aus. Reine Default-Duplikate werden gemerged. Edge-Case: User hat zwei identische Scenes "Home" beide mit Default-Apps absichtlich angelegt — sehr unwahrscheinlich, und Konsequenz (Merge auf eine Row mit Union der Apps) ist benign.
  • B+C: Schicht-A Wirkung vorgezogen — wenn B+C zuerst kommt, fixed der Workbench-Path den Bug; aber andere Module könnten weiterhin still falsch stempeln. Akzeptabel, weil B+C den User-sichtbaren Bug schließt.
  • A: Bestehende Tests, die ohne spaceId schreiben → Audit-Schritt vor A. vitest run deckt's auf.

Out-of-Scope

  • Server-truthed Scene-Creation (mana-sync seedet auf Space-Create direkt in PG). Bricht local-first für nur einen Use-Case — nicht der richtige Tradeoff fürs Daten-Modell.
  • Vereinheitlichung mit Workbench-Templates-Apply-Pattern (bereits ähnliche Seed-Handler-Registry in apply-template.ts). Spannend, aber nicht Teil dieses Plans.