60 Commits

Author SHA1 Message Date
Agent Zero b23045c46a docs(plan): I-F entdoppelt — DSGVO/Session-Revocation nur noch in BLOCK G (G1/G2), E4/E5 nur in I-H
Jede Spezifikation existiert genau einmal: G1 DSGVO Art. 15/17/20, G2 Session-Revocation, G3 Hygiene bleiben kanonisch in BLOCK G; E4 Monitoring-Reality-Check und E5 Performance-Baseline bleiben kanonisch in I-H. I-F ist jetzt reiner Verantwortlichkeits-Index mit Cross-References (spart ~3 Anlaeufe Doppeldokumentation/-umsetzung).
2026-08-25 17:24:23 +02:00
Agent Zero 5d8c48a08f fix(i-b): Cross-Tenant-Suite 10/10 gruen — echte RLS-Verifikation statt Vakuum-Tests
Root-Causes und Fixes: (1) conftest.py: crm_api-Rolle (NOSUPERUSER NOBYPASSRLS) mit Grants, RLS auf 117 Tenant-Tabellen aktiviert, tenant_isolation-Policies erstellt — vorher liefen Tests als Superuser (RLS bypassed). (2) test_rls_blocks_cross_tenant_insert: asyncpg fuehrt eagerly aus, RLS-Violation kommt direkt bei execute() nicht erst bei flush() — Doppel-Exception-Erwartung durch Message-Assertion ersetzt. (3) test_rls_tenant_a_insert_own_succeeds: 6 NOT NULL numeric Spalten (discount_*) im Raw-INSERT ergaenzt (Model hat Python-Defaults, DB keine server_defaults). (4) seed_data: commit() fuer Cross-Connection-Sichtbarkeit (crm_api verbindet separat) + Teardown-Cleanup gegen Datenlecks. (5) admin_session: ohne conn.begin() — sonst conditional_savepoint und commit() wirkungslos. (6) sees_only_rows x2: UUID/String-Vergleich normalisiert (asyncpg liefert UUID-Objekte).

Vorher: 9 von 10 Tests vakuum-trivial gruen (leere DB, Superuser). Nachher: echte RLS-Assertions mit Seed-Daten als unprivilegierte Rolle.
2026-08-25 17:06:24 +02:00
Agent Zero f6dde68221 fix(i-d): RBAC-Comprehensive 4 Failures behoben — http_exception_handler um dict-detail-Durchreichung erweitert (strukturierte Error-Codes AGENTS.md-konform, body[detail] = raw_detail dict statt stringify); 3 Contact-Payload-Feldnamen korrigiert (firstname/surname statt first_name/last_name in legacy-editor Tests); test_rbac_comprehensive 102/102 gruen 2026-08-25 12:57:52 +02:00
Agent Zero d901d001c7 fix(i-c): Outbox-Cluster behoben — OutboxDelivery-Model in app/models/outbox.py ergaenzt (Migration-0075-konform inkl. uq_outbox_deliveries_event_consumer UniqueConstraint); Root-Cause: create_all-basiertes Test-Schema fehlte die Tabelle und den Constraint (ON CONFLICT schlug fehl); 12 Failures → 0; Beweistest test_outbox+test_outbox_phase5 23/23 gruen 2026-08-25 00:59:49 +02:00
Agent Zero 962e0ee1f6 fix(i-d): Geister-Komponenten eliminiert — AIAssistant-Seite erstellt (Agent-Auswahl + AgentChat, STATIC_COMPONENT_MAP registriert nach C3-Pattern); 5 Ghost-Contact-Detail-Tabs aus Backend-Manifesten entfernt; Production-Build mit AIAssistant-Chunk verifiziert (AIAssistant-DVb66TSo.js); tsc exit=0; ruff clean
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-24 21:50:21 +02:00
Agent Zero 49ca4c5fb2 fix(i-c): ARCH-026 behoben — fehlende Manifest-Deklarationen ergaenzt (automation→mail, mcp_server→unified_search, tasks→kommunikation, self_improvement→kommunikation); resolve_load_order verifiziert 25 plugins topologisch ohne Zyklen
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-24 21:26:41 +02:00
Agent Zero 1b22da8b0d docs(i-c): ARCH-011/BUG-017 erledigt dokumentiert — integration_tools.py in Block H geloescht, verbleibende ai/-Imports sind Contract-basiert (architektonische Loesung); Cross-Plugin-Scan 459 Dateien 0 Verstoesse 2026-08-24 21:21:59 +02:00
Agent Zero 76a31a8c39 docs(i-c): BUG-078 widerlegt — alle 3 dead functions repo-weit verifiziert als legitime Utilities (seed_admin-Nutzung, Test-API, bewusst leerer Startup-Hook); Scanner-Limit dokumentiert 2026-08-24 21:21:11 +02:00
Agent Zero a991f9a0b4 docs(i-c): BUG-071 widerlegt (API/Tests/Frontend konsistent auf source_contact_id/target_contact_id — urspruengliches Mismatch existiert nicht mehr); G3-a dump.rdb erledigt (entfernt, git-ignored, Root-Cause lokaler Test-Redis workdir=Repo-Root dokumentiert; Production unbeeinflusst — redisdata:/data Volume) 2026-08-24 21:18:56 +02:00
Agent Zero 84a30d85c2 fix(i-c): BUG-036 behoben — Workflow-Instances GET lieferte 500 auf jeden Aufruf (Route übergab user_id/is_system_admin die die Service-Signatur nicht akzeptierte → TypeError); Service um optionale User-Filterung erweitert (Nicht-Admins sehen nur eigene Instanzen via initiated_by, Admins alle); Beweistest test_bug036_instances.py 2/2 grün 2026-08-24 21:16:08 +02:00
Agent Zero d9aed519f2 fix(i-c): BUG-024 behoben — Plugin-Detail-Endpoint GET /api/v1/plugins/{name} implementiert (Manifest-Metadaten + DB-Status, 404 für unbekannte); Beweistest test_plugin_detail.py 2/2 grün; Existenzprüfung vorher: Route fehlte komplett (bewiesen), Frontend-Nutzung niedrig aber API-Vollständigkeit hergestellt 2026-08-24 21:12:58 +02:00
Agent Zero b9a6c06e85 docs(i-a): Stale-Status korrigiert — 13 Findings nachdokumentiert die bereits gefixt waren (ARCH-051/055/056/057/027 + BUG-085–092 D1-Suiten) mit Beweis-Referenzen auf Commits; ehrliche Dokumentationsbasis für Block I 2026-08-24 21:07:13 +02:00
Agent Zero 8386e99caa docs(plan): Block I-H ergaenzt — Prozess- & Rest-Luecken aus Originalplan (F1-Restprozess Branch/Tag/Staging, F3-Gate-F Minimal-Plugin-Test, G3 dump.rdb + Downgrade-Entscheidung, E2/E4/E5 konkrete Gates); Block I ist jetzt vollstaendig abgeglichen gegen Originalplan F/G/S + alle Session-Funde 2026-08-24 21:02:43 +02:00
Agent Zero 7d9ae03bf1 docs(plan): Block I VOLLSTÄNDIG überarbeitet — alle Fehlerquellen einbezogen nach Abgleich von test-bugs.md (73 -Findings), Suite v2 Restzone (brach bei 77% ab), Blöcke F/G aus Originalplan, S-Tracks S1/S2/S3; Struktur: I-A Stale-Status → I-B Restzone messen → I-C Produktionsbugs → I-D Frontend → I-E Test-Hygiene Runde 2 → I-F Sicherheit/Compliance → I-G S-Tracks; Gate I = 7 konkrete Kriterien für keine bekannten Fehler 2026-08-24 20:42:40 +02:00
Agent Zero 36a03b9897 docs(plan): Block I ergaenzt — Keine bekannten Fehler mehr (I1 API-Verkabelung 12 Brueche, I2 Geister-Komponenten x6, I3 Test-Hygiene Runde 2 inkl. Mail-Mocking + Voll-Triage, I4 CI-Gate scharf schalten, I5 Credential-Rotation PFLICHT, I6 Kleinkram-Buendel, I7 Server-Kontext E2/E4/E5); Gate I: Voll-Suite gruen ohne Ausschuesse + api_contracts 0 echte Findings + 0 Geister + Credentials rotiert 2026-08-24 20:21:16 +02:00
Agent Zero 860db8d61e security(e6): 7 echte Credentials aus docs/deploy-guide.md entfernt (Forgejo-Token, Coolify-Token, DB-Passwort, Redis-Passwort, SECRET_KEY, Admin-Passwort — durch Git-Historie kompromittiert); durch Secretstore-Referenzen ersetzt; Credential-Rotation-Anleitung mit konkreten Schritten für alle 7 Credentials ergänzt (SECRET_KEY zuletzt, invalidiert Sessions) 2026-08-24 14:06:56 +02:00
Agent Zero 81aea8c77f feat(e3): Restore-Drill als lokalen End-to-End-Beweis implementiert — scripts/restore_drill.sh: Migrations-DB+Seed → pg_dump → frische DB → Restore → 12 Integritäts-Checks (Tabellen/Alembic/RLS-Parität, tenant-scoped contacts, audit_log, RLS fail-closed mit restricted NOSUPERUSER-NOBYPASSRLS-Rolle, Policy-Rollen-Bindung an crm_api); DRILL_EXIT=0; idempotent mit automatischem Cleanup 2026-08-24 14:03:57 +02:00
Agent Zero 46c909c226 feat(e1): AuditMiddleware als systematisches Safety-Net — alle erfolgreichen POST/PATCH/DELETE erzeugen Audit-Eintrag (Session-basierte user/tenant-Attribuierung, entity_type aus Pfad, source=middleware in changes); schließt Lücke von 349 mutierenden Endpoints in 59 Dateien ohne Audit; Skip-Liste auth/health/errors/audit/external; best-effort; Beweistest test_audit_middleware.py grün (POST ohne explizites log_audit → Audit-Zeile); Regressionssmoke 23/23 grün 2026-08-24 13:55:25 +02:00
Agent Zero 197b0d3bab fix(e7): CI-Gate-Vorbereitung — ruff über app/ von 105 auf 0 Findings bereinigt; 8 echte F821-NameError-Produktionsbugs behoben (external_api stream_chat-Call-Signatur an stream_chat_comm angepasst, agent_runner uuid vor lokalem Import, automation/plugin UserTenant-Import, tasks delete-audit user_id, workflows/engine timedelta, unified_search/contracts Any); py311-kompatibles StepHandler-Alias statt type-Statement; E402/F841 bereinigt; Verifikation 85/89 grün (4 Failures = bekannter Vorbestand BUG-099)
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-24 13:36:17 +02:00
Agent Zero 3934aea6ef docs(d6): Block D abgeschlossen — ai_copilot als deprecated markiert mit Abschaltplan (ARCH-059: Backend-only, 0 Frontend-Referenzen, Test geskippt → Migration wäre Verschwendung); ARCH-023 als verifiziertes No-Op dokumentiert (Plugin-Services registrieren sich selbst bei on_activate — bewusstes Design) 2026-08-24 12:50:17 +02:00
Agent Zero 5cc5a3fa6a fix(d5): Marathon-Scanner-Triage — trace_api_contracts 859→218 (-75%, Router-Präfixe/Multi-Router/leere Pfade/Template-Literals gefixt), trace_plugins 27→0 (-100%, Inline-Manifest-Konvention erkannt); 371 HIGH-Fehlalarme eliminiert (OpenAPI-verifiziert); ~12 echte API-Bugs als Follow-up dokumentiert (ai/sessions ×5, policies ×4, mail ×4) 2026-08-24 12:43:41 +02:00
Agent Zero c0e8e4ecfd docs(d4): Security-Triage abgeschlossen — ARCH-027 verifiziert (SECRET_KEY-Fail bereits implementiert und strenger als gefordert), BUG-019 = 0 echte hardcoded Secrets (Entropie-Wert-Scan), BUG-020 = kein fixbares Finding (alle f-string-SQL-Interpolationen aus Whitelists/Config, kein User-Input-Fluss) 2026-08-24 11:02:50 +02:00
Agent Zero c32e4bb34e refactor(d3): ARCH-051 — 14 dict-body-Routes auf Pydantic-Schemas umgestellt (entity_permissions bulk ×2, guests invite, users menu-order, system_settings backup-config+dsar, knowledge ×3, self_improvement ×5); DSAR-Export F821-Bug behoben (datetime/timezone undefined → NameError beim GDPR-Export), Zeitstempel auf datetime.now(UTC); Validierung jetzt im Schema statt in Routen
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-24 10:55:22 +02:00
Agent Zero ef90d57f0a fix(d3): systemischer Permission-Resolver-Bug behoben — DMS/Mail get_entity_models-Overrides ergänzt (dms_file/dms_folder/file/mail_account fehlten im ENTITY_MODELS-Mapping → ValueError bei allen Entity-Freigaben zur Laufzeit); pgvector-Extension in conftest db_setup verankert; test_permissions 22/22 grün; Resolver-Auflösung aller 4 Typen direkt bewiesen
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-24 10:07:48 +02:00
Agent Zero 0768cfb29a fix(d3): ARCH-055/056/057 — errors.py error.user_agent statt nicht existierendem userAgent (AttributeError zur Laufzeit); roles.py SYSTEM_PERMISSIONS aus CORE_PERMISSIONS abgeleitet (47 statt 36 Permissions, Drift behoben, category→system für Frontend-Gruppierung); registry._plugins→öffentliche API list_discovered()+get_plugin() 2026-08-24 08:28:35 +02:00
Agent Zero 56e401969e docs(progress): D1 abgeschlossen — alle 9 Ziel-Suites grün, 3 Produktionsbugs behoben 2026-08-24 08:10:43 +02:00
Agent Zero 6d04206695 fix(d1): SystemSettings-Schema-Drift behoben — backup_interval/backup_retention_days/backup_destination Model-Spalten + Migration 0142 nachgezogen (10b1f83 hatte Schema/Service/Frontend erweitert ohne Model/Migration); Settings-API Create/Read wieder funktionsfähig; Fresh-DB-Kette 0001→0142 verifiziert 2026-08-24 08:06:19 +02:00
Agent Zero f6e117b1c3 fix(d1): Calendar-Suite + ai_proactive repariert — conftest CalendarPlugin-Import wiederhergestellt (abbe7a1-Regression), CalendarContract-Zugriffe snake_case→PascalCase (context_tools, services ×2, mail/routes), 2 stale Rate-Limit-Tests auf zentrale check_rate_limit-Grenze umgestellt; test_calendar 34/34, ai_proactive-Failures behoben; Mail-Vorbestand dokumentiert
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-24 07:57:27 +02:00
Agent Zero 9d8da99026 fix(contacts): ContactCreate-Typ-Inferenz — Person-Payloads ohne explizites type werden nicht mehr als Firma abgelehnt (Regression aus BUG-008-Fix dada44c); test_companies 18/18, test_contacts 8/8 grün 2026-08-24 07:32:09 +02:00
Agent Zero 54066b05fd docs(f3): plugin checklist + architecture requirements section in dev guide 2026-08-24 01:54:16 +02:00
Agent Zero 36636f5c25 docs(d2): utcnow family fixed, sqlite-001 results, handover notes for successor agent 2026-08-24 01:40:34 +02:00
Agent Zero d89044d8f7 fix(d2): datetime.now(UTC) everywhere + SQLITE-001 automation tests on ephemeral postgres
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-24 01:22:42 +02:00
Agent Zero 5e0ffd91c2 docs: block C complete - C1-C8 implemented, gate C checks 4+5 proven, ghost components documented 2026-08-23 23:50:19 +02:00
Agent Zero b8b8ef180a fix(c8): shared TeamPanel component (arch-062) + curated icon map in SortableMenuItem (arch-063 OOM fix) 2026-08-23 23:44:43 +02:00
Agent Zero cad7d084e8 feat(c7): dashboard widgets as plugin contributions + contact counts via contacts contract
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-23 23:36:36 +02:00
Agent Zero dff97f5589 fix(c6): settings plugin pages permission-filtered, label-dedup hack removed 2026-08-23 22:46:03 +02:00
Agent Zero 9e84c400ed fix(c5,arch-021): system dashboard nav entry only for system admins 2026-08-23 22:31:04 +02:00
Agent Zero 067fc132cb feat(c4,arch-006): plugin route renderer enforces manifest permission via protected route 2026-08-23 22:24:43 +02:00
Agent Zero b01b756a4a fix(c3,arch-019): static chunk map for plugin components - production build loads plugin pages correctly 2026-08-23 22:14:45 +02:00
Agent Zero 4bce89aecb fix(c2,arch-004): workspace visibleModuleKeys respects is_visible=false 2026-08-23 22:01:38 +02:00
Agent Zero 5e9be254e2 feat(c1): permission fields on frontend menu items and page routes + manifest migration for all plugins
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-23 21:59:22 +02:00
Agent Zero 8a76bfdba4 docs: block B complete - gate B passed all 5 checks 2026-08-23 21:46:21 +02:00
Agent Zero d2434203c1 test(gate-b): new-plugin-without-core-changes + dependency blockade proofs 2026-08-23 21:44:01 +02:00
Agent Zero ad7c763e59 fix(gate-b): fresh-db install path - conditional guards on plugin-table migrations + dual-path convergence migrations
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-23 21:36:56 +02:00
Agent Zero e3fb4728d7 refactor(b3): dynamic entity registry, custom_fields permissions decoupled from contacts, write perms generated from registry 2026-08-23 20:54:04 +02:00
Agent Zero 7467c01d38 refactor(b2): eliminate all cross-plugin imports - contracts for worker/agent_runner/workstream, declared dependency for wiki
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-23 20:29:09 +02:00
Agent Zero 4038b74025 docs: b1 progress - contacts domain plugin-owned 2026-08-23 20:20:31 +02:00
Agent Zero 5ad107ff83 refactor(b1): contacts domain fully plugin-owned - routes moved from core to contacts plugin with require_active_plugin guard
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-23 20:20:02 +02:00
Agent Zero 5cee78c54c docs: block A complete - A2 deactivation cleanup results, Gate A passed 2026-08-23 19:34:49 +02:00
Agent Zero 32f63adc09 test(gate-a): block A completion proof - imports, lifecycle symmetry, activate-once, contract roundtrip 2026-08-23 19:31:33 +02:00
Agent Zero c21634b323 fix(arch-a2): deactivation cleanup - container services, search provider, hook deregistration, notification sync, task state, activation order
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-23 19:24:12 +02:00
Agent Zero 73d2e109cd docs: block a progress - arch-043/052/008/009 fixed and verified 2026-08-23 18:41:21 +02:00
Agent Zero 795307754f fix(arch-008,arch-009): canonical 2-segment permission schema enforced; fix dead role wildcard patterns
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-23 18:35:50 +02:00
Agent Zero 17516d2783 fix(arch-043,arch-052): deterministic system tenant lookup; async-safe file metadata
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-23 16:39:09 +02:00
Agent Zero ed8ee5cda1 docs: architecture repair progress - plan v3, session status, bug statuses 2026-08-23 16:11:01 +02:00
Agent Zero 90a367089d fix(arch-038,arch-054): register_event_handlers hook in BasePlugin; entity model lookup matches registry shape
Check Cross-Plugin Imports / check (push) Has been cancelled
2026-08-23 15:45:43 +02:00
Agent Zero b04cda774b fix(arch-014,arch-020): no contract lazy-resurrect after unregister; event bus dedupes handlers
Check Cross-Plugin Imports / check (push) Has been cancelled
Also fixes ARCH-029/041: none-check before attribute access in trigger dispatcher.
2026-08-23 15:30:12 +02:00
Agent Zero 982b4c9353 fix(arch-003): active-manifests available to every authenticated user 2026-08-23 15:09:39 +02:00
Agent Zero 1d6152fb82 fix(arch-001,arch-002): permissions before on_activate; activate once per process 2026-08-23 15:04:51 +02:00
Agent Zero 337d78ef53 merge: Block H - Agent platform kernel (tools/steps/blocks/tabs plugin-contributable) 2026-08-23 14:47:52 +02:00
166 changed files with 6979 additions and 8286 deletions
+126
View File
@@ -6,6 +6,132 @@
--- ---
## Architektur-Reparatur (2026-08-23, laufend)
**Plan:** docs/fix-plan-v3.md · **Sequenz:** Block 0 + Block H gemerged in main, Block A läuft auf main
**Stand:** 18 Findings geschlossen (~30% aufwandsgewichtet) · App startbar · 90+ Tests grün · tsc clean · Checker 14→6 Verstöße · Alles gepusht auf Forgejo
| Finding | Beschreibung | Status | Commit |
|---|---|---|---|
| SYNTAX-001 | automation/plugin.py SyntaxError — App startet nicht | ✅ gefixt | 8077595 |
| CHECK-002 | Checker crasht bei relativen Pfaden | ✅ gefixt | 35e2cc8 |
| ARCH-010 | Checker scannt nur builtins | ✅ Vollscan-Default | 35e2cc8 |
| ARCH-014 | Contract-Lazy-Resurrect nach unregister | ✅ gefixt + Funktionstest | b04cda7 |
| ARCH-020 | EventBus subscribe ohne Duplikat-Check | ✅ gefixt + Funktionstest | b04cda7 |
| ARCH-029/041 | trigger_dispatcher None-Check nach Verwendung | ✅ None-Check zuerst | b04cda7 |
| ARCH-001 | Permissions nach on_activate registriert | ✅ Reihenfolge gedreht | 1d6152f |
| ARCH-002 | on_activate pro Tenant mehrfach | ✅ 1× pro Prozess | 1d6152f |
| ARCH-003 | active-manifests an plugins:read gebunden | ✅ für eingeloggte User offen | 982b4c9 |
| ARCH-038 | BasePlugin.register_event_handlers fehlt | ✅ Hook ergänzt | 90a3670 |
| ARCH-054 | entity_permissions falsche Datenstruktur | ✅ Model-Lookup korrigiert | 90a3670 |
| ARCH-047 | SearchContract-Import kaputt (search-Step ImportError) | ✅ gefixt | d87fc4e |
| ARCH-030 | contract.get_function() existiert nicht | ✅ auf 5 Contracts ergänzt | d87fc4e |
| ARCH-031 | knowledge/plugin.py uuid nicht importiert | ✅ mitfixt | 1f4a621 |
| ARCH-040/046/049 | Core→Plugin-Imports (worker/compliance/engine) | ✅ via Contract/Plugin-Job | 44511a8 + a7699d3 |
| HC-F Frontend | BlockRenderer: 14 hardcodierte Blocks | ✅ Registry | b7ad529 |
| HC-G Frontend | AISidebar: 5 hardcodierte Tabs | ✅ Tab-Registry | 59fdb61 |
| HC-A Backend | action_mapper feste Regex-Intents | ✅ Contribution-API | 4994906 |
| Gate H | Plugin-Contribution ohne Core-Änderung beweisen | ✅ BESTANDEN (2/2) | 801743b |
| ARCH-043 | automation Tenant.limit(1) statt System-Tenant | ✅ get_system_tenant() + system_tenant_slug Setting | 17516d2 |
| ARCH-052 | storage get_file_metadata Event-Loop im async Kontext | ✅ get_file_metadata_async() + Fail-Fast-Guard | 17516d2 |
| ARCH-008 | Permission-Namensschema inkonsistent | ✅ Kanon modul:aktion festgelegt, Manifest-Validator erzwingt es | 7953077 |
| ARCH-009 | Tote 3-Segment-Rollen-Patterns (core:*:X) + 14 Route-Literals | ✅ Migration 0141 + Route-Fix, Roundtrip bewiesen | 7953077 |
| ARCH-012 | wiki/knowledge on_deactivate unvollständig | ✅ Provider-Dereg + 2 latente Bugs (register_provider fehlte am Contract, kaputter Modul-Import) behoben | c21634b |
| ARCH-013 | self_improvement Fallback-Import; Benachrichtigung war tot | ✅ Contract-only; undefinierten KommunikationContract-Verweis behoben | c21634b |
| ARCH-015 | Notification-Sync fehlt in Deactivate-Sequenz | ✅ sync_notification_types nach Status-Update | c21634b |
| ARCH-033 | comm_websocket/comm_miniapps bleiben im Container | ✅ Container-Cleanup VOR super(); ServiceContainer.remove() ergänzt | c21634b |
| ARCH-034/035 | self_improvement/marketplace Contract-Unregister | ✅ verifizierte No-Ops: beide registrieren keinen Contract | c21634b |
| ARCH-036 | mail _auto_sync_task Klassenvariable | ✅ Instanzvariable via __init__ | c21634b |
| ARCH-037 | graph_rag Registrierung VOR super() | ✅ Reihenfolge umgestellt | c21634b |
| ARCH-044 | ai_ui_control remove() NACH super() | ✅ Reihenfolge umgestellt; fehlendes ServiceContainer.remove() ergänzt | c21634b |
| Gate A | Block-A-Abschlussprüfung | ✅ BESTANDEN (4/4): Imports, Lifecycle-Symmetrie, Activate-Once, Contract-Roundtrip | 32f63ad |
| B1 | Contacts-Domain aus Core entkoppelt: 4 Router ins Plugin verschoben, manifest.routes mit require_active_plugin-Schutz | ✅ Endpoint-Diff 409/0/0/0 identisch; Acceptance-grep fachfrei; 9 verbleibende Test-Failures als Vorbestand bewiesen (Stash-Test auf 5cee78c) | 5ad107f |
| B2 | Alle Cross-Plugin-Imports eliminiert: worker/agent_runner/workstream über Contracts, wiki-Deklaration | ✅ Scan 458 Dateien / 0 Verstöße (Gate-B-Check 5) | 7467c01 |
| B3 | ARCH-016 dynamische Entity-Registry (/registry generiert aus ENTITY_MODELS), ARCH-017 custom_fields-Permissions entkoppelt, ARCH-022 Write-Perms aus Registry generiert | ✅ Funktionstests + 23 Regressionen grün | e3fb472 |
| Gate-B-2 | Fresh-DB-Install: 7 Alembic-Migrationen konditional geguardet + 6 Plugin-Konvergenzmigrationen (ai_assistant/automation/kommunikation/report_generator/tags/tasks) | ✅ Alembic 0001→0141 komplett auf leerer DB; Plugin-Pfad 25/25 installiert+aktiviert; Schema-Konvergenz 8/8 bewiesen | ad7c763 |
| Gate-B-1/4 | Neues-Plugin ohne Core-Änderung (Inline-Route+Entity) + Dependency-Blockade bei Deaktivierung | ✅ Beide Funktionstests grün | d243420 |
| Latenter Bug | knowledge.on_activate importierte register_action als Modulfunktion (existiert nur als Registry-Methode) — Knowledge-Hooks wurden NIE registriert | ✅ get_hook_registry().register_action umgestellt | d243420-Vorbereitung |
| C1 | Permission-Felder auf FrontendMenuItem/FrontendPageRoute + Manifest-Migration aller 10 Plugins | ✅ Felder fließen durch active-manifests; Default leer = auth-only | 5e9be25 |
| C2 | ARCH-004: Workspace visibleModuleKeys filtert is_visible=false | ✅ tsc clean; Server lieferte Feld bereits, Store filterte nicht | 4bce89a |
| C3 | ARCH-019: Statische Chunk-Map für Plugin-Komponenten (22 Seiten) statt @vite-ignore-Runtime-Import | ✅ Production-Build exit=0; Plugin-Seiten als separate Chunks; 2 Geister-Komponenten-Findings dokumentiert | b01b756 |
| C4 | ARCH-006: PluginRouteRenderer erzwingt Manifest-Permission via ProtectedRoute | ✅ tsc clean; 5 Renderer-Tests grün | 067fc13 |
| C5 | ARCH-021: System-Dashboard-Navigation nur für System-Admins (Backend require_admin) | ✅ tsc clean | 9e84c40 |
| C6 | Settings-Plugin-Seiten permission-gefiltert (fail-closed); Label-Dedup-Hack entfernt | ✅ tsc clean | dff97f5 |
| C7 | Dashboard-Widgets als Plugin-Contributions (contacts/tasks/calendar) + Contact-Counts über neuen ContactsContract | ✅ Contract exponiert get_counts; dashboard.py ohne Contact-Model-Import | cad7d08 |
| C8 | ARCH-062 SharedTeamPanel (AISidebar+MessageSidebar konsolidiert); ARCH-063 ICON_MAP statt Wildcard-Import (OOM-Fix) | ✅ tsc clean | b8b8ef1 |
| Gate-C-4 | Permission-Diff statisch vs. Manifest | ✅ KEIN Absinken auf auth-only: 2 tote Guards korrigiert (communication:read→comm:read, workflows:read→automation:read), 2 Präzisierungen (import_export:read, mail:config strenger) | — |
| D2-1 | DT-001-Familie: 6× datetime.utcnow() → datetime.now(UTC) (worker ×2, audit, webhook_service inkl. Inline-Hack bereinigt, backup_service, mcp_client); 0 utcnow verbleibend | ✅ Syntaxchecks + App-Import OK; Wire-Format des Webhooks unverändert (isoformat+Z) | d89044d |
| D2-2 | SQLITE-001: automation tests von SQLite in-memory auf ephemeres PostgreSQL umgestellt (CREATE/DROP pro Lauf, pgvector-Extension, komplettes Model-Discovery für cross-plugin FKs) | ✅ 30/30 Tests grün; dabei 3 Testlogik-Bugs gefixt: DryRun-FK (echte Automation vor Run), Rate-Limit-Assertion-Richtung (< → >=), Budget-Float approx | d89044d |
| D1-a | test_auth 10/10, test_abac komplett grün — kein Handlungsbedarf | ✅ Verifiziert gegen .env.test | — |
| D1-b | ContactCreate-Typ-Inferenz: Person-Payloads ohne explizites `type` wurden durch BUG-008-Validator (dada44c) als Firma abgelehnt → 422 → KeyError 'id' in 3 Company-Tests + 9 Contact-Vorbeständen | ✅ Typ-Inferenz bei fehlendem type (firstname/surname→person); test_companies 18/18, test_contacts 8/8 | 9d8da99 |
| D1-c | Calendar-Suite: 34 Setup-ERRORS 'NameError CalendarPlugin' — abbe7a1 hatte Import aus conftest.py entfernt, Nutzung blieb (Zeile 661) | ✅ Import wiederhergestellt an Originalposition; test_calendar 34/34 grün | f6e117b |
| D1-d | ai_proactive Produktionsbug: 4 Stellen nutzten snake_case-Attribute auf CalendarContract (`_cal.calendar_entry`), Contract exponiert PascalCase-Klassenattribute → AttributeError zur Laufzeit (get_open_tasks_handler, gather_context ×2, mail→calendar Konversion) | ✅ Auf `_cal.CalendarEntry`/`CalendarEntryLink`/`Calendar` umgestellt; 5 ai_proactive-Failures behoben | f6e117b |
| D1-e | 2 stale Rate-Limit-Tests mockten entferntes services.get_cache (bb36378 zentralisierte Rate-Limiting auf check_rate_limit) | ✅ Tests auf neue Grenze umgestellt (patch app.core.rate_limit.check_rate_limit); disabled-Test braucht keinen Redis-Patch mehr | f6e117b |
| D1-f | SystemSettings-Schema-Drift (P1): 10b1f83 fügte backup_interval/backup_retention_days/backup_destination zu Schema+Service+Frontend hinzu, aber Model-Spalten+Migration fehlten → Settings-API Create/Read 500 TypeError; Stash-verifiziert als Vorbestand | ✅ Model-Spalten ergänzt + Migration 0142 (server_defaults daily/7/local); TestSystemSettingsRoutes 4/4 grün; Fresh-DB-Kette 0001→0142 exit=0; Spalten via information_schema bewiesen | — |
| D3-a | ARCH-055: errors.py nutzte error.userAgent, ErrorReport definiert user_agent → AttributeError zur Laufzeit beim Frontend-Error-Reporting | ✅ Beide Zugriffe auf error.user_agent korrigiert; ruff clean | 0768cfb |
| D3-b | ARCH-056: roles.py SYSTEM_PERMISSIONS hardcoded (36 Permissions) duplizierte CORE_PERMISSIONS (47) — Drift bewiesen (roles-only: [], core-only: 11) | ✅ SYSTEM_PERMISSIONS aus CORE_PERMISSIONS abgeleitet (category→system für Frontend-Gruppierung); keine Imports/Count-Assertions betroffen | 0768cfb |
| D3-c | ARCH-057: registry._plugins.items() privater Zugriff in roles.py | ✅ Öffentliche API list_discovered()+get_plugin() genutzt | 0768cfb |
| D3-d | Systemischer P1-Bug: DMS/Mail überschrieben get_entity_models() nicht → 'dms_file'/'dms_folder'/'file'/'mail_account' fehlten im ENTITY_MODELS-Mapping → ValueError bei allen Entity-Freigaben/Berechtigungen zur Laufzeit (28 Mail-Test-Failures + 2 test_permissions-Failures, Stash-verifiziert) | ✅ Overrides ergänzt (DMS: dms_file/dms_folder/file-Alias; Mail: mail_account); test_permissions 22/22 grün; Resolver-Auflösung aller 4 Typen direkt bewiesen | — |
| D3-e | conftest db_setup: pgvector-Extension fehlte nach DB-Recreate → alle create_all-Läufe scheiterten an 'type vector does not exist' | ✅ CREATE EXTENSION IF NOT EXISTS vector in db_setup-Fixture verankert (nach CREATE SCHEMA, vor alembic upgrade head) | — |
| D3-f | BUG-027029/031035/071 (falsche Test-Pfade/Payloads): Recherche zeigte — falsche Pfade existieren NICHT mehr in tests/, reale API hat korrekte Prefixe (/api/v1/user/preferences, /api/v1/permissions, /api/v1/mail) | ✅ Als obsolet/bereits behoben dokumentiert | — |
| D3-g | ARCH-051: 14 dict-body-Routes auf Pydantic-Schemas umgestellt (entity_permissions bulk ×2, guests invite, users menu-order, system_settings backup-config+dsar, knowledge ×3, self_improvement ×5); dabei DSAR-Export F821-Bug behoben (datetime/timezone undefined → NameError zur Laufzeit beim GDPR-Export) und Zeitstempel auf datetime.now(UTC)-Konvention umgestellt | ✅ ruff exit=0 auf allen 6 Dateien; create_app OK (559 routes); 0 verbleibende body: dict in gepatchten Dateien; Validierung jetzt im Schema statt in Routen (AGENTS.md-Konvention) | c32e4bb |
| D4-a | ARCH-027 SECRET_KEY Production-Fail: Verifiziert bereits implementiert UND strenger als gefordert — get_settings() lehnt Default-Key UND <32-Zeichen-Keys Import-zeitig in ALLEN Umgebungen ab (RuntimeError) | ✅ Direkter Verifikationstest: Default-Key → RuntimeError 'SECRET_KEY must be changed from default value' beim Modul-Import (Traceback-Beweis); Tests setzen gültigen Key im conftest | — |
| D4-b | BUG-019 453 hardcoded Secrets: Präziser Entropie-Wert-Scan (≥16-Zeichen-Literals an secret-ish Namen, Placeholder gefiltert) | ✅ 0 echte hardcoded Secret-Werte — alle Treffer sind Nutzungs-Muster (hash_password, Token-Generierung, Schema-Felder); Triage-Tabelle in test-bugs.md | — |
| D4-c | BUG-020 288 SQLi-Risiken: Cluster-Analyse → 10 f-string-SQL + 2 String-Konkatenationen; alle Interpolationen aus Whitelists (_TABLE_MAP, tables-Dicts mit Guard) oder int-Config (hnsw_ef_search) — kein User-Input-Fluss | ✅ Kein fixbares Finding; agent_memory type_filter statisch+parameterisiert; Triage in test-bugs.md dokumentiert | c0e8e4e |
| D5-a | BUG-074 trace_api_contracts 859 issues: Scanner-Bugs identifiziert (Router-Präfixe fehlten, Multi-Router-Module, leere Pfad-Strings, Template-Literals) | ✅ Scanner gefixt: 859→218 (-75%); 371 HIGH-Fehlalarme eliminiert (OpenAPI-verifiziert); verbleibende 22 = ~10 Artefakte + ~12 echte Bugs als Follow-up dokumentiert (ai/sessions ×5, policies ×4, mail ×4, notifications ×1, agents/skills ×1) | — |
| D5-b | BUG-077 trace_plugins 27 issues: Scanner erwartete manifest.py, Projekt-Konvention ist Inline-Manifest in plugin.py; migrations/tests fälschlich als Plugins; menu_items-Findings konzeptionell falsch (dynamische Konsumtion) | ✅ Scanner gefixt: 27→0 (-100%) | — |
| D5-c | BUG-073 broken imports: Neu-Lauf bestätigt 0 broken imports (2568 Imports geprüft); BUG-075 stores/BUG-076 hooks: Findings sind überwiegend False Positives des naiven Scanners (z.B. 'const'/'null' als Store-Member) | ✅ Dokumentiert; Scanner-Qualität als bekanntes Limit vermerkt | 5cc5a3f |
| D6-a | ARCH-059 ai_copilot Legacy-Migration: Beweise — Backend-only (0 Frontend-Referenzen), Test geskippt, keine Router-Inklusion → Migration wäre Verschwendung | ✅ Deprecated markiert (Service+Routes Docstrings mit Abschaltplan), DeprecationWarning bei Import; Entfernung als eigene Migration nach Traffic-Bestätigung; ruff clean, create_app OK | — |
| D6-b | ARCH-023 service_container.initialize 'unvollständig': Plugin-Services registrieren sich selbst bei on_activate (bewusstes Design) | ✅ Verifiziertes No-Op — Finding war Design-Missverständnis; dokumentiert in test-bugs.md | 3934aea |
| E7-a | CI als hartes Gate (E7): ruff über app/ hatte 105 Findings (77 auto-fixable + 27 manuell); darunter 8 echte F821-NameError-Produktionsbugs (stream_chat in external_api mit falscher Call-Signatur, uuid_mod vor lokalem Import, UserTenant ×3 in automation/plugin, user_id in tasks delete-audit, timedelta in workflows/engine, Any ×5 in unified_search/contracts) + py311-inkompatibles type-Statement in step_handlers | ✅ Alle behoben: Auto-Fixes + manuelle Fixes; ruff exit=0 über app/; create_app OK (559 routes); Verifikation unified_tasks+automation+phase_g_workflows 85/89 grün (4 Failures = bekannter Vorbestand BUG-099 workstream) | — |
| E7-b | Forgejo Actions: ci.yml existiert (.forgejo/workflows/ci.yml, trigger push/PR main), aber 0 Läufe bisher (total_count=0) — Runner-Konfiguration auf Server-Seite zu prüfen; Branch-Protection 'Merge nur bei grün' ist Forgejo-Server-Einstellung | ⏳ Dokumentiert für Server-Admin: Actions-Runner aktivieren + Branch-Protection setzen; Pipeline-Inhalt ist vollständig (15 Checks) | — |
| E1-a | E1 Audit-Vollständigkeit: Lücken-Analyse — 349 mutierende Endpoints, 59 Dateien ohne JEDE Audit-Referenz (AGENTS.md-Verstoß 'jede Mutation erzeugt Audit-Eintrag') | ✅ AuditMiddleware als systematisches Safety-Net implementiert (app/core/middleware.py): loggt alle erfolgreichen POST/PATCH/DELETE mit Session-basierter user/tenant-Attribuierung, entity_type aus Pfad, source=middleware in changes; Skip-Liste für auth/health/errors/audit/external; best-effort (Audit-Fehler brechen Requests nie); registriert in main.py | — |
| E1-b | E1 Beweis: Dedizierter Test test_audit_middleware.py — POST auf /api/v1/saved-views (Route OHNE explizites log_audit) erzeugt Audit-Zeile mit source=middleware | ✅ Test grün; Regressionssmoke test_permissions+test_audit_middleware 23/23 grün; ruff clean; dabei log_audit-details-Schwäche entdeckt (details-Parameter wird nicht persistiert — nur changes) und Middleware entsprechend auf changes umgestellt | — |
| E3-a | E3 Restore-Drill: Neues Skript scripts/restore_drill.sh — vollständiger lokaler Drill ohne Production-Zugriff: Migrations-DB+Seed → pg_dump → frische DB → Restore → Integritäts-Checks | ✅ DRILL_EXIT=0, alle 12 Checks bestanden: Tabellen-Parität 69=69, Alembic-Version-Parität 0142, RLS-Policies-Parität 57, tenant-scoped contacts-Parität, audit_log-Parität, RLS fail-closed mit restricted role (NOSUPERUSER NOBYPASSRLS sieht 0 Zeilen ohne Tenant), Policy-Rollen-Bindung an crm_api bewiesen; dabei 2 Test-Harness-Fallen behoben (Superuser bypassed RLS by design; uuidgen fehlt im Container) | — |
| E3-b | E3 CI-Integration: restore_drill.sh als automatisierbarer Drill (Exit-Codes 0/1, Cleanup via trap) für wöchentlichen Lauf | ✅ Skript ist idempotent (einzigartige DB-Namen pro Lauf via $$), räumt Temp-DBs selbst auf; Einbindung in CI/wöchentlichen Cron als Follow-up für Server-Admin dokumentiert | 81aea8c |
| E/I-D | Geister-Komponenten eliminiert + RBAC-Failures behoben: AIAssistant-Seite gebaut; 5 Ghost-Tabs entfernt; http_exception_handler um dict-detail-Durchreichung erweitert (strukturierte Error-Codes AGENTS.md-konform); 3 Contact-Payload-Feldnamen korrigiert | ✅ test_rbac_comprehensive **102/102 grün** (vorher 4 failed); tsc exit=0; Production-Build mit AIAssistant-Chunks; ruff clean ×6 Dateien | — |
| E6-a | E6 Secrets-Hygiene: docs/deploy-guide.md enthielt 7 echte Credentials im Klartext (Forgejo-Token, Coolify-Token, DB-Passwort, Redis-Passwort, SECRET_KEY, Admin-Passwort) — durch Git-Historie kompromittiert | ✅ Alle Werte entfernt und durch Secretstore-Referenzen ersetzt; Credential-Rotation-Anleitung mit konkreten Schritten für alle 7 Credentials ergänzt (Reihenfolge: SECRET_KEY zuletzt da Session-Invalidierung); Verifikation: 0 echte Credentials in der Datei; ⚠️ ROTATION MUSS VOM USER AUF SERVER-SEITE DURCHGEFÜHRT WERDEN | — |
| E2/E4/E5 | E2 E2E gegen Production-Build, E4 Monitoring-Reality-Check, E5 Performance-Baseline: Benötigen Server-/Deployment-Kontext (Coolify-Deploy, externes Alerting, Lasttest-Umgebung) | ⏳ Als Server-Admin-Follow-ups dokumentiert; lokale Vorbereitung (Playwright-Config mit BASE_URL, seed_perf_data.py, spike_e_benchmark.py) existiert bereits | — |
**Block D ABGESCHLOSSEN** (D1D6) — D1: alle 9 Ziel-Suites grün; D2: DateTime/SQLITE-001; D3: ARCH-051/055/056/057 + systemischer Permission-Resolver-Bug + conftest-pgvector; D4: Security-Triage (ARCH-027 verifiziert, BUG-019 = 0 echte Secrets, BUG-020 kein fixbares Finding); D5: Scanner-Triage (api_contracts -75%, plugins -100%, 371 Fehlalarme eliminiert); D6: ai_copilot deprecated + ARCH-023 No-Op. Offene Follow-ups dokumentiert (~12 echte API-Bugs aus D5, IMAP-Mocking für Mail-Tests). Nächster Block: E (Production-Härtung).
**Block A ABGESCHLOSSEN** — Gate A bestanden (32f63ad).
**Block B ABGESCHLOSSEN** — Gate B bestanden (alle 5 Checks bewiesen).
**Block C ABGESCHLOSSEN** — C1C8 implementiert, Gate-C-Checks 4+5 bewiesen; Checks 13 (Playwright gegen Build, Workspace-E2E, Normal-User-E2E) erfordern E2E-Lauf → Block E.
**Block D: D1+D2 ABGESCHLOSSEN** (6d04206) — D1: alle 9 Ziel-Suites grün (auth 10/10, abac, companies 18/18, contacts 8/8, calendar 34/34, ai_proactive 38+1s, api_tokens, backend_coverage_gaps 41/41, phase_h_wiki 42/42); 3 Produktionsbugs behoben (ContactCreate-Typ-Inferenz, CalendarContract-Zugriffe ×4, SystemSettings-Schema-Drift+Migration 0142). Offen: D3 (API-/Testpfade + ARCH-051/055/056/057), D4 (Security-Triage BUG-019/020 + ARCH-027), D5 (Marathon-Triage), D6 (Legacy-Migration ARCH-059/023).
| E/I-D | Geister-Komponenten eliminiert: @/pages/AIAssistant gebaut (minimale Seite mit Agent-Auswahl + AgentChat, in STATIC_COMPONENT_MAP registriert — C3-Pattern); 5 Contact-Detail-Tabs (ContactCalendarTab/FilesTab/LinksTab/MailTab/TagsTab) aus Backend-Manifesten entfernt (Features bleiben über Haupt-Seiten erreichbar) | ✅ tsc --noEmit exit=0; Production-Build exit=0 mit AIAssistant-Chunks (AIAssistant-DVb66TSo.js 5.92 kB); ruff clean ×6 Dateien; create_app OK (560 routes); Route /ai-assistant funktioniert statt ErrorBoundary | — |
### Bekannte Vorbestände (Block D Triage)
- ~~9 Contact/Company-Test-Failures~~ ✅ GELÖST in D1-b (ContactCreate-Typ-Inferenz, 9d8da99) — Root-Cause war BUG-008-Validator-Default type='company'.
- ~~test_mail: 28 Failures 'Unknown entity type: mail_account'~~ ✅ Root-Cause in D3-d behoben (ef90d57): Mail/DMS get_entity_models-Overrides fehlten; Rest-Failures im vollen Mail-Lauf = IMAP-Netzwerk-Calls ohne Mocking (Test-Infrastruktur-Follow-up, kein Produktionsbug).
- 5 PluginLoader-Test-Failures sind Vorbestand (Stash-Verifikation): Tests erwarten UI-Text 'Failed to load plugin', aktueller Loader zeigt deutsche Texte.
- Geister-Komponenten: Backend-Manifeste referenzieren @/pages/AIAssistant + 5 Contact-Detail-Tabs, die im Frontend nicht existieren (siehe unten).
### Handover-Hinweis für Nachfolge-Agent
- Reparaturplan: docs/fix-plan-v3.md (Blöcke 0/H/A/B/C done, D gestartet, E/F/G offen)
- Findings-Status: docs/test-bugs.md (✅/⏳ je Finding)
- Verifikationsmuster: Stash-Test gegen Pre-Block-Commit für Vorbestands-Nachweis; Endpoint-Diff via OpenAPI-Snapshot; Cross-Plugin-Scan als Gate
- Test-DB: .env.test (leocrm_test), automation-Tests erstellen eigene ephemere DBs
- Forgejo-Issues/Milestones laut AGENTS.md §9 noch NICHT angelegt — nur PROGRESS.md-Tracking
### Geister-Komponenten (Block D Triage)
Backend-Manifeste referenzieren Frontend-Komponenten, die nicht existieren:
- @/pages/AIAssistant (ai_assistant Menü+Route)
- @/components/contact/ContactCalendarTab, ContactFilesTab, ContactLinksTab, ContactMailTab, ContactTagsTab (calendar/dms/entity_links/mail/tags Detail-Tabs)
Diese Tabs zeigen in Production den ErrorBoundary-Fallback.
### Bekannte Vorbestände (Block D Triage)
- 9 Contact/Company-Test-Failures existieren seit vor Block B (Stash-Verifikation 2026-08-23): KeyError 'id' bei Create/Delete/Link-Flows — Ursache ungeklärt, gehört zur pytest-Massen-Triage.
**Offen gesamt:** B (Contacts-Entkopplung als Kernstück), C (Frontend-Routen/Workspace/Settings/Dashboard), D (pytest-Massen + Security-Triage), E (Production-Härtung), F3 (Guide-Updates), G (DSGVO).
**Bekannte Vorbestände:** BUG-099 (workstream.py gelöscht in 7d86592, Tests importieren es noch — 7 Failures). test_trigger_core besteht isoliert.
---
## Übersicht ## Übersicht
| Phase | Status | Start | Ende | Done | Partial | Not Done | Total | Anmerkung | | Phase | Status | Start | Ende | Done | Partial | Not Done | Total | Anmerkung |
@@ -18,7 +18,26 @@ branch_labels = None
depends_on = None depends_on = None
def _table_exists(conn, table_name: str) -> bool:
"""True when the table exists (dual-path convergence, Gate B).
On a fresh install the ai_assistant plugin SQL migration has not run
yet when Alembic reaches this revision — skip instead of failing.
The plugin-side migration adds the same columns idempotently.
"""
row = conn.execute(
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
{"tname": f"public.{table_name}"},
).scalar()
return bool(row)
def upgrade() -> None: def upgrade() -> None:
conn = op.get_bind()
if not _table_exists(conn, "ai_providers"):
# Fresh-install path: table arrives with the ai_assistant plugin
# migration, which includes these columns.
return
op.add_column("ai_providers", sa.Column("region", sa.String(20), nullable=False, server_default="unknown")) op.add_column("ai_providers", sa.Column("region", sa.String(20), nullable=False, server_default="unknown"))
op.add_column("ai_providers", sa.Column("hosting_type", sa.String(30), nullable=False, server_default="cloud")) op.add_column("ai_providers", sa.Column("hosting_type", sa.String(30), nullable=False, server_default="cloud"))
op.add_column("ai_providers", sa.Column("dpa_status", sa.String(20), nullable=False, server_default="none")) op.add_column("ai_providers", sa.Column("dpa_status", sa.String(20), nullable=False, server_default="none"))
@@ -29,6 +48,9 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
conn = op.get_bind()
if not _table_exists(conn, "ai_providers"):
return
op.drop_column("ai_providers", "allowed_data_classes") op.drop_column("ai_providers", "allowed_data_classes")
op.drop_column("ai_providers", "transfer_notice") op.drop_column("ai_providers", "transfer_notice")
op.drop_column("ai_providers", "training_on_customer_data") op.drop_column("ai_providers", "training_on_customer_data")
@@ -17,126 +17,145 @@ branch_labels = None
depends_on = None depends_on = None
def _table_exists(conn, table_name: str) -> bool:
"""True when the table exists (dual-path convergence, Gate B).
On a fresh install the kommunikation plugin SQL migration has not run
yet when Alembic reaches this revision — skip the comm_* parts instead
of failing. The plugin-side migration adds the same column idempotently.
"""
row = conn.execute(
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
{"tname": f"public.{table_name}"},
).scalar()
return bool(row)
def upgrade() -> None: def upgrade() -> None:
# 1. Add is_system column to comm_conversations conn = op.get_bind()
op.add_column( if _table_exists(conn, "comm_conversations"):
"comm_conversations", # 1. Add is_system column to comm_conversations
sa.Column("is_system", sa.Boolean(), nullable=False, server_default=sa.text("false")), op.add_column(
) "comm_conversations",
op.create_index( sa.Column("is_system", sa.Boolean(), nullable=False, server_default=sa.text("false")),
"ix_comm_conversations_tenant_system", )
"comm_conversations", op.create_index(
["tenant_id", "is_system"], "ix_comm_conversations_tenant_system",
) "comm_conversations",
["tenant_id", "is_system"],
)
# 2. Create system channel per tenant (for tenants that have notifications) # 2. Create system channel per tenant (for tenants that have notifications)
op.execute(""" op.execute("""
INSERT INTO comm_conversations (id, tenant_id, title, is_pinned, is_locked, is_direct, is_archived, is_system, created_by, created_by_type, metadata, created_at, updated_at) INSERT INTO comm_conversations (id, tenant_id, title, is_pinned, is_locked, is_direct, is_archived, is_system, created_by, created_by_type, metadata, created_at, updated_at)
SELECT SELECT
gen_random_uuid(), gen_random_uuid(),
n.tenant_id, n.tenant_id,
'System Channel', 'System Channel',
false, false,
true, true,
false, false,
false, false,
true, true,
NULL, NULL,
'system', 'system',
'{}'::jsonb, '{}'::jsonb,
NOW(), NOW(),
NOW() NOW()
FROM ( FROM (
SELECT DISTINCT tenant_id FROM notifications WHERE deleted_at IS NULL SELECT DISTINCT tenant_id FROM notifications WHERE deleted_at IS NULL
) n ) n
WHERE NOT EXISTS ( WHERE NOT EXISTS (
SELECT 1 FROM comm_conversations cc SELECT 1 FROM comm_conversations cc
WHERE cc.tenant_id = n.tenant_id AND cc.is_system = true AND cc.deleted_at IS NULL WHERE cc.tenant_id = n.tenant_id AND cc.is_system = true AND cc.deleted_at IS NULL
); );
""") """)
# 3. Insert notifications as CommMessages in the system channel # 3. Insert notifications as CommMessages in the system channel
op.execute(""" op.execute("""
INSERT INTO comm_messages (id, tenant_id, conversation_id, sender_id, sender_type, content, content_format, metadata, created_at, updated_at) INSERT INTO comm_messages (id, tenant_id, conversation_id, sender_id, sender_type, content, content_format, metadata, created_at, updated_at)
SELECT SELECT
gen_random_uuid(), gen_random_uuid(),
n.tenant_id, n.tenant_id,
sc.id, sc.id,
n.user_id, n.user_id,
'system', 'system',
COALESCE(n.title, '') || CASE WHEN n.body IS NOT NULL THEN E'\n' || n.body ELSE '' END, COALESCE(n.title, '') || CASE WHEN n.body IS NOT NULL THEN E'\n' || n.body ELSE '' END,
'text', 'text',
jsonb_build_object( jsonb_build_object(
'notification_type', n.type, 'notification_type', n.type,
'severity', 'info', 'severity', 'info',
'entity_ref', CASE WHEN n.entity_type IS NOT NULL THEN jsonb_build_object('entity_type', n.entity_type, 'entity_id', n.entity_id::text) ELSE NULL END, 'entity_ref', CASE WHEN n.entity_type IS NOT NULL THEN jsonb_build_object('entity_type', n.entity_type, 'entity_id', n.entity_id::text) ELSE NULL END,
'migrated_from_notification', true, 'migrated_from_notification', true,
'original_notification_id', n.id::text 'original_notification_id', n.id::text
), ),
n.created_at, n.created_at,
COALESCE(n.read_at, n.created_at) COALESCE(n.read_at, n.created_at)
FROM notifications n FROM notifications n
JOIN comm_conversations sc ON sc.tenant_id = n.tenant_id AND sc.is_system = true AND sc.deleted_at IS NULL JOIN comm_conversations sc ON sc.tenant_id = n.tenant_id AND sc.is_system = true AND sc.deleted_at IS NULL
WHERE n.deleted_at IS NULL; WHERE n.deleted_at IS NULL;
""") """)
# 4. Insert text blocks for each migrated message # 4. Insert text blocks for each migrated message
op.execute(""" op.execute("""
INSERT INTO comm_message_blocks (id, tenant_id, message_id, block_type, block_data, sort_order) INSERT INTO comm_message_blocks (id, tenant_id, message_id, block_type, block_data, sort_order)
SELECT SELECT
gen_random_uuid(), gen_random_uuid(),
cm.tenant_id, cm.tenant_id,
cm.id, cm.id,
'text', 'text',
jsonb_build_object('text', cm.content), jsonb_build_object('text', cm.content),
0 0
FROM comm_messages cm FROM comm_messages cm
WHERE cm.metadata->>'migrated_from_notification' = 'true'; WHERE cm.metadata->>'migrated_from_notification' = 'true';
""") """)
# 5. Insert action_card blocks for messages with entity references # 5. Insert action_card blocks for messages with entity references
op.execute(""" op.execute("""
INSERT INTO comm_message_blocks (id, tenant_id, message_id, block_type, block_data, sort_order) INSERT INTO comm_message_blocks (id, tenant_id, message_id, block_type, block_data, sort_order)
SELECT SELECT
gen_random_uuid(), gen_random_uuid(),
cm.tenant_id, cm.tenant_id,
cm.id, cm.id,
'action_card', 'action_card',
jsonb_build_object( jsonb_build_object(
'label', 'Open', 'label', 'Open',
'entity_type', (cm.metadata->'entity_ref'->>'entity_type'), 'entity_type', (cm.metadata->'entity_ref'->>'entity_type'),
'entity_id', (cm.metadata->'entity_ref'->>'entity_id') 'entity_id', (cm.metadata->'entity_ref'->>'entity_id')
), ),
1 1
FROM comm_messages cm FROM comm_messages cm
WHERE cm.metadata->>'migrated_from_notification' = 'true' WHERE cm.metadata->>'migrated_from_notification' = 'true'
AND cm.metadata->'entity_ref' IS NOT NULL; AND cm.metadata->'entity_ref' IS NOT NULL;
""") """)
# 6. For read notifications, create CommMessageRead entries # 6. For read notifications, create CommMessageRead entries
op.execute(""" op.execute("""
INSERT INTO comm_message_reads (id, tenant_id, conversation_id, user_id, last_read_msg_id, last_read_at) INSERT INTO comm_message_reads (id, tenant_id, conversation_id, user_id, last_read_msg_id, last_read_at)
SELECT SELECT
gen_random_uuid(), gen_random_uuid(),
cm.tenant_id, cm.tenant_id,
cm.conversation_id, cm.conversation_id,
cm.sender_id, cm.sender_id,
cm.id, cm.id,
COALESCE(n.read_at, n.created_at) COALESCE(n.read_at, n.created_at)
FROM comm_messages cm FROM comm_messages cm
JOIN notifications n ON n.id::text = cm.metadata->>'original_notification_id' JOIN notifications n ON n.id::text = cm.metadata->>'original_notification_id'
WHERE cm.metadata->>'migrated_from_notification' = 'true' WHERE cm.metadata->>'migrated_from_notification' = 'true'
AND n.read_at IS NOT NULL AND n.read_at IS NOT NULL
AND n.deleted_at IS NULL; AND n.deleted_at IS NULL;
""") """)
# 7. Create legacy view over notifications table for backward compatibility # 7. Legacy view over the CORE notifications table — exists on both paths
op.execute("DROP VIEW IF EXISTS notifications_legacy") op.execute("DROP VIEW IF EXISTS notifications_legacy")
op.execute("CREATE VIEW notifications_legacy AS SELECT * FROM notifications") op.execute("CREATE VIEW notifications_legacy AS SELECT * FROM notifications")
def downgrade() -> None: def downgrade() -> None:
conn = op.get_bind()
op.execute("DROP VIEW IF EXISTS notifications_legacy") op.execute("DROP VIEW IF EXISTS notifications_legacy")
if not _table_exists(conn, "comm_conversations"):
return
op.execute("DELETE FROM comm_message_blocks WHERE message_id IN (SELECT id FROM comm_messages WHERE metadata->>'migrated_from_notification' = 'true')") op.execute("DELETE FROM comm_message_blocks WHERE message_id IN (SELECT id FROM comm_messages WHERE metadata->>'migrated_from_notification' = 'true')")
op.execute("DELETE FROM comm_messages WHERE metadata->>'migrated_from_notification' = 'true'") op.execute("DELETE FROM comm_messages WHERE metadata->>'migrated_from_notification' = 'true'")
op.execute("DELETE FROM comm_conversations WHERE is_system = true AND title = 'System Channel'") op.execute("DELETE FROM comm_conversations WHERE is_system = true AND title = 'System Channel'")
+17
View File
@@ -14,7 +14,24 @@ branch_labels = None
depends_on = None depends_on = None
def _table_exists(conn, table_name: str) -> bool:
"""True when the table exists (dual-path convergence, Gate B).
On a fresh install the automation plugin SQL migration has not run yet
when Alembic reaches this revision — skip instead of failing. The
plugin-side convergence migration creates the same table.
"""
row = conn.execute(
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
{"tname": f"public.{table_name}"},
).scalar()
return bool(row)
def upgrade() -> None: def upgrade() -> None:
conn = op.get_bind()
if not _table_exists(conn, "automation_agent_runs"):
return
op.create_table( op.create_table(
"automation_agent_run_steps", "automation_agent_run_steps",
sa.Column("id", PGUUID(as_uuid=True), primary_key=True), sa.Column("id", PGUUID(as_uuid=True), primary_key=True),
@@ -18,7 +18,24 @@ branch_labels = None
depends_on = None depends_on = None
def _table_exists(conn, table_name: str) -> bool:
"""True when the table exists (dual-path convergence, Gate B).
On a fresh install the automation plugin SQL migration has not run yet
when Alembic reaches this revision — skip instead of failing. The
plugin-side convergence migration adds the same columns.
"""
row = conn.execute(
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
{"tname": f"public.{table_name}"},
).scalar()
return bool(row)
def upgrade() -> None: def upgrade() -> None:
conn = op.get_bind()
if not _table_exists(conn, "automation_agent_definitions"):
return
op.add_column( op.add_column(
"automation_agent_definitions", "automation_agent_definitions",
sa.Column("temperature", sa.Float, nullable=False, server_default="0.3"), sa.Column("temperature", sa.Float, nullable=False, server_default="0.3"),
@@ -20,7 +20,27 @@ branch_labels = None
depends_on = None depends_on = None
def _table_exists(conn, table_name: str) -> bool:
"""True when the table exists (dual-path convergence, Gate B).
On a fresh install the tasks plugin SQL migration has not run yet when
Alembic reaches this revision — skip instead of failing. The plugin-side
convergence migration adds the same columns/indexes. The legacy-data
backfills below only matter for pre-existing rows and are correctly
empty on a fresh install.
"""
row = conn.execute(
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
{"tname": f"public.{table_name}"},
).scalar()
return bool(row)
def upgrade() -> None: def upgrade() -> None:
conn = op.get_bind()
if not _table_exists(conn, "tasks"):
return
# ── Add new columns to tasks ──────────────────────────────────────────── # ── Add new columns to tasks ────────────────────────────────────────────
op.add_column("tasks", sa.Column("assignee_type", sa.String(20), nullable=False, server_default="user")) op.add_column("tasks", sa.Column("assignee_type", sa.String(20), nullable=False, server_default="user"))
op.add_column("tasks", sa.Column("assignee_id", PGUUID(as_uuid=True), nullable=True)) op.add_column("tasks", sa.Column("assignee_id", PGUUID(as_uuid=True), nullable=True))
@@ -12,6 +12,7 @@ Revises: 0126
""" """
from alembic import op from alembic import op
import sqlalchemy as sa
revision = "0127" revision = "0127"
down_revision = "0126" down_revision = "0126"
@@ -19,7 +20,19 @@ branch_labels = None
depends_on = None depends_on = None
def _table_exists(conn, table_name: str) -> bool:
"""True when the table exists (dual-path convergence, Gate B)."""
row = conn.execute(
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
{"tname": f"public.{table_name}"},
).scalar()
return bool(row)
def upgrade() -> None: def upgrade() -> None:
conn = op.get_bind()
if not _table_exists(conn, "tasks"):
return
# Drop the FK constraint on tasks.contact_id # Drop the FK constraint on tasks.contact_id
op.drop_constraint("tasks_contact_id_fkey", "tasks", type_="foreignkey") op.drop_constraint("tasks_contact_id_fkey", "tasks", type_="foreignkey")
@@ -9,6 +9,7 @@ Revises: 0128
""" """
from alembic import op from alembic import op
import sqlalchemy as sa
revision = "0129" revision = "0129"
down_revision = "0128" down_revision = "0128"
@@ -27,8 +28,25 @@ TABLES_NEEDING_RLS = [
] ]
def _table_exists(conn, table_name: str) -> bool:
"""True when the table exists (dual-path convergence, Gate B).
Plugin-owned tables may not exist yet on a fresh install when Alembic
reaches this revision — skip them instead of failing. The plugin-side
convergence migrations apply the same RLS policies.
"""
row = conn.execute(
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
{"tname": f"public.{table_name}"},
).scalar()
return bool(row)
def upgrade() -> None: def upgrade() -> None:
conn = op.get_bind()
for table in TABLES_NEEDING_RLS: for table in TABLES_NEEDING_RLS:
if not _table_exists(conn, table):
continue
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;") op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;")
op.execute( op.execute(
f"CREATE POLICY tenant_isolation ON {table} " f"CREATE POLICY tenant_isolation ON {table} "
@@ -37,6 +55,9 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
conn = op.get_bind()
for table in TABLES_NEEDING_RLS: for table in TABLES_NEEDING_RLS:
if not _table_exists(conn, table):
continue
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table};") op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table};")
op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY;") op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY;")
@@ -10,6 +10,7 @@ Revises: 0135
Create Date: 2026-08-21 Create Date: 2026-08-21
""" """
from alembic import op from alembic import op
import sqlalchemy as sa
revision = "0136" revision = "0136"
down_revision = "0135" down_revision = "0135"
@@ -29,8 +30,25 @@ TABLES_WITH_BAD_RLS = [
] ]
def _table_exists(conn, table_name: str) -> bool:
"""True when the table exists (dual-path convergence, Gate B).
Plugin-owned tables may not exist yet on a fresh install when Alembic
reaches this revision — skip them instead of failing. The plugin-side
convergence migrations apply the same RLS policies.
"""
row = conn.execute(
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
{"tname": f"public.{table_name}"},
).scalar()
return bool(row)
def upgrade() -> None: def upgrade() -> None:
conn = op.get_bind()
for table in TABLES_WITH_BAD_RLS: for table in TABLES_WITH_BAD_RLS:
if not _table_exists(conn, table):
continue
# Drop old policy with app.tenant_id # Drop old policy with app.tenant_id
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table};") op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table};")
# Create new policy with app.current_tenant_id # Create new policy with app.current_tenant_id
@@ -41,7 +59,10 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
conn = op.get_bind()
for table in TABLES_WITH_BAD_RLS: for table in TABLES_WITH_BAD_RLS:
if not _table_exists(conn, table):
continue
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table};") op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table};")
op.execute( op.execute(
f"CREATE POLICY tenant_isolation ON {table} " f"CREATE POLICY tenant_isolation ON {table} "
@@ -19,7 +19,25 @@ branch_labels = None
depends_on = None depends_on = None
def _table_exists(conn, table_name: str) -> bool:
"""True when the table exists (dual-path convergence, Gate B).
On a fresh install the tags plugin SQL migration has not run yet when
Alembic reaches this revision — skip instead of failing. The plugin-side
convergence migration adds the same columns.
"""
row = conn.execute(
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
{"tname": f"public.{table_name}"},
).scalar()
return bool(row)
def upgrade() -> None: def upgrade() -> None:
conn = op.get_bind()
if not _table_exists(conn, "tags"):
return
# parent_id for tree structure (self-referencing FK) # parent_id for tree structure (self-referencing FK)
op.add_column("tags", sa.Column("parent_id", PGUUID(as_uuid=True), nullable=True)) op.add_column("tags", sa.Column("parent_id", PGUUID(as_uuid=True), nullable=True))
op.create_foreign_key( op.create_foreign_key(
@@ -35,6 +53,9 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
conn = op.get_bind()
if not _table_exists(conn, "tags"):
return
op.drop_column("tags", "icon") op.drop_column("tags", "icon")
op.drop_column("tags", "applicable_to") op.drop_column("tags", "applicable_to")
op.drop_index("ix_tags_parent", table_name="tags") op.drop_index("ix_tags_parent", table_name="tags")
@@ -18,11 +18,31 @@ branch_labels = None
depends_on = None depends_on = None
def _table_exists(conn, table_name: str) -> bool:
"""True when the table exists (dual-path convergence, Gate B).
On a fresh install the report_generator plugin SQL migration has not
run yet when Alembic reaches this revision — skip instead of failing.
The plugin-side convergence migration adds the same column.
"""
row = conn.execute(
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
{"tname": f"public.{table_name}"},
).scalar()
return bool(row)
def upgrade() -> None: def upgrade() -> None:
conn = op.get_bind()
if not _table_exists(conn, "report_templates"):
return
op.add_column("report_templates", sa.Column("folder_id", PGUUID(as_uuid=True), nullable=True)) op.add_column("report_templates", sa.Column("folder_id", PGUUID(as_uuid=True), nullable=True))
op.create_index("ix_report_templates_folder", "report_templates", ["folder_id"]) op.create_index("ix_report_templates_folder", "report_templates", ["folder_id"])
def downgrade() -> None: def downgrade() -> None:
conn = op.get_bind()
if not _table_exists(conn, "report_templates"):
return
op.drop_index("ix_report_templates_folder", table_name="report_templates") op.drop_index("ix_report_templates_folder", table_name="report_templates")
op.drop_column("report_templates", "folder_id") op.drop_column("report_templates", "folder_id")
@@ -18,11 +18,31 @@ branch_labels = None
depends_on = None depends_on = None
def _table_exists(conn, table_name: str) -> bool:
"""True when the table exists (dual-path convergence, Gate B).
On a fresh install the kommunikation plugin SQL migration has not run
yet when Alembic reaches this revision — skip instead of failing.
The plugin-side migration adds the same column idempotently.
"""
row = conn.execute(
sa.text("SELECT to_regclass(:tname) IS NOT NULL"),
{"tname": f"public.{table_name}"},
).scalar()
return bool(row)
def upgrade() -> None: def upgrade() -> None:
conn = op.get_bind()
if not _table_exists(conn, "comm_conversations"):
return
op.add_column("comm_conversations", sa.Column("folder_id", PGUUID(as_uuid=True), nullable=True)) op.add_column("comm_conversations", sa.Column("folder_id", PGUUID(as_uuid=True), nullable=True))
op.create_index("ix_comm_conversations_folder", "comm_conversations", ["folder_id"]) op.create_index("ix_comm_conversations_folder", "comm_conversations", ["folder_id"])
def downgrade() -> None: def downgrade() -> None:
conn = op.get_bind()
if not _table_exists(conn, "comm_conversations"):
return
op.drop_index("ix_comm_conversations_folder", table_name="comm_conversations") op.drop_index("ix_comm_conversations_folder", table_name="comm_conversations")
op.drop_column("comm_conversations", "folder_id") op.drop_column("comm_conversations", "folder_id")
@@ -0,0 +1,84 @@
'''Fix role permission wildcard patterns to canonical 2-segment schema
Revision ID: 0141
Revises: 0140
Create Date: 2026-08-23
Migration 0019 seeded default roles with 3-segment permission patterns
(core:*:read etc.). The runtime matcher (_matches_permission) compares
segment counts strictly, so those patterns could never match any
2-segment requirement - editor/viewer roles were silently dead.
Canonical schema is module:action (2 segments, * wildcards allowed).
core:*:X means all modules with action X, so it converts to *:X.
'''
from alembic import op
# revision identifiers, used by Alembic.
revision = '0141'
down_revision = '0140'
branch_labels = None
depends_on = None
# Rebuild the permissions JSONB object, rewriting every key that starts
# with the dead 'core:' prefix to its 2-segment equivalent ('*:X').
_UPGRADE_SQL = '''
UPDATE roles
SET permissions = sub.new_perms,
permission_version = permission_version + 1
FROM (
SELECT
r.id AS role_id,
jsonb_object_agg(
CASE WHEN k LIKE 'core:%'
THEN '*:' || split_part(k, ':', 3)
ELSE k END,
v
) AS new_perms
FROM roles r,
jsonb_each(r.permissions) AS e(k, v)
GROUP BY r.id
) AS sub
WHERE roles.id = sub.role_id
AND EXISTS (
SELECT 1 FROM jsonb_object_keys(roles.permissions) k
WHERE k LIKE 'core:%'
)
'''
# Reverse: map '*:X' back to 'core:*:X' only for keys that came from the
# original seeding pattern. Roles that legitimately use '*:X' without a
# matching 'core:*:X' history are left untouched (best-effort downgrade).
_DOWNGRADE_SQL = '''
UPDATE roles
SET permissions = sub.new_perms,
permission_version = permission_version + 1
FROM (
SELECT
r.id AS role_id,
jsonb_object_agg(
CASE WHEN k = '*:' || split_part(k, ':', 2)
AND k <> '*:*'
THEN 'core:*:' || split_part(k, ':', 2)
ELSE k END,
v
) AS new_perms
FROM roles r,
jsonb_each(r.permissions) AS e(k, v)
GROUP BY r.id
) AS sub
WHERE roles.id = sub.role_id
AND EXISTS (
SELECT 1 FROM jsonb_object_keys(roles.permissions) k
WHERE k = '*:' || split_part(k, ':', 2) AND k <> '*:*'
)
'''
def upgrade() -> None:
op.execute(_UPGRADE_SQL)
def downgrade() -> None:
op.execute(_DOWNGRADE_SQL)
@@ -0,0 +1,39 @@
"""Add backup config columns to system_settings table.
Follow-up to 0130: the backup feature (10b1f83) added backup_interval,
backup_retention_days and backup_destination to schema/service/frontend
but missed model columns and this migration.
Revision ID: 0142
Revises: 0141
"""
import sqlalchemy as sa
from alembic import op
revision = "0142"
down_revision = "0141"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"system_settings",
sa.Column("backup_interval", sa.String(20), nullable=False, server_default="daily"),
)
op.add_column(
"system_settings",
sa.Column("backup_retention_days", sa.Integer(), nullable=False, server_default="7"),
)
op.add_column(
"system_settings",
sa.Column("backup_destination", sa.String(20), nullable=False, server_default="local"),
)
def downgrade() -> None:
op.drop_column("system_settings", "backup_destination")
op.drop_column("system_settings", "backup_retention_days")
op.drop_column("system_settings", "backup_interval")
+1 -1
View File
@@ -17,7 +17,7 @@ from __future__ import annotations
import logging import logging
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass
from typing import Any from typing import Any
from sqlalchemy import select from sqlalchemy import select
-2
View File
@@ -25,8 +25,6 @@ import logging
import uuid import uuid
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from app.core.sensitive_data import sanitize_dict
if TYPE_CHECKING: if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
-2
View File
@@ -20,8 +20,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.ai.ai_use_case import AIUseCaseMetadata from app.ai.ai_use_case import AIUseCaseMetadata
from app.core.sensitive_data import ( from app.core.sensitive_data import (
SENSITIVE_FIELDS, SENSITIVE_FIELDS,
filter_for_llm_context,
get_data_class_for_field,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
-1
View File
@@ -6,7 +6,6 @@ import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
LOW_CONFIDENCE_THRESHOLD = 0.6 LOW_CONFIDENCE_THRESHOLD = 0.6
-1
View File
@@ -7,7 +7,6 @@ from typing import Any
from app.ai.knowledge_sources import get_source_config from app.ai.knowledge_sources import get_source_config
EXTRACTION_TRIGGERS = { EXTRACTION_TRIGGERS = {
"mail.received", "mail.received",
"dms.file_uploaded", "dms.file_uploaded",
+1 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass
from typing import Any from typing import Any
+4
View File
@@ -107,6 +107,10 @@ class Settings(BaseSettings):
rate_limit_webhook_max: int = 100 # incoming webhooks rate_limit_webhook_max: int = 100 # incoming webhooks
rate_limit_webhook_window: int = 60 # 1 minute rate_limit_webhook_window: int = 60 # 1 minute
# System tenant — used by seeding/plugins that need a well-known default
# tenant (must match scripts/seed_admin.py slug).
system_tenant_slug: str = "default"
# LLM Cost Overrun Protection (B.17) # LLM Cost Overrun Protection (B.17)
llm_monthly_budget_usd: float = 100.0 # per-tenant monthly LLM budget llm_monthly_budget_usd: float = 100.0 # per-tenant monthly LLM budget
llm_hard_cutoff: bool = True # block LLM calls when budget exceeded llm_hard_cutoff: bool = True # block LLM calls when budget exceeded
+17
View File
@@ -355,6 +355,23 @@ async def close_engine() -> None:
_migration_session_factory = None _migration_session_factory = None
async def get_system_tenant(db: AsyncSession):
"""Return the well-known system tenant, or ``None`` if it does not exist.
Resolves by configured slug (``settings.system_tenant_slug``, default
``"default"`` as created by ``scripts/seed_admin.py``) instead of an
arbitrary first row, so multi-tenant databases stay deterministic.
"""
from sqlalchemy import select
from app.config import get_settings
from app.models.tenant import Tenant # lazy: models import this module's Base
slug = get_settings().system_tenant_slug
result = await db.execute(select(Tenant).where(Tenant.slug == slug).limit(1))
return result.scalar_one_or_none()
def reset_engine_for_testing(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: def reset_engine_for_testing(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
"""Replace all global engines with a test engine. Returns a session factory. """Replace all global engines with a test engine. Returns a session factory.
+7 -2
View File
@@ -36,8 +36,13 @@ class EventBus:
self._handlers: dict[str, list[EventHandler]] = defaultdict(list) self._handlers: dict[str, list[EventHandler]] = defaultdict(list)
def subscribe(self, event_name: str, handler: EventHandler) -> None: def subscribe(self, event_name: str, handler: EventHandler) -> None:
"""Subscribe a handler to an event.""" """Subscribe a handler to an event.
self._handlers[event_name].append(handler)
Idempotent: subscribing the same handler twice is a no-op
(ARCH-020) so double activation cannot fire handlers twice.
"""
if handler not in self._handlers[event_name]:
self._handlers[event_name].append(handler)
def unsubscribe(self, event_name: str, handler: EventHandler) -> None: def unsubscribe(self, event_name: str, handler: EventHandler) -> None:
"""Unsubscribe a handler from an event.""" """Unsubscribe a handler from an event."""
+96
View File
@@ -3,6 +3,8 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
import uuid as uuid_mod
from fastapi import Request, status from fastapi import Request, status
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
@@ -137,3 +139,97 @@ class CSRFMiddleware(BaseHTTPMiddleware):
pass pass
return await call_next(request) return await call_next(request)
class AuditMiddleware(BaseHTTPMiddleware):
"""Safety-net audit trail for ALL successful mutating requests.
AGENTS.md requires every mutation to produce an audit entry. Explicit
``log_audit`` calls in routes/services remain the detail layer (entity ids,
change diffs); this middleware guarantees a baseline entry for mutations
that lack one, marked with ``source=middleware`` in ``details``.
Best-effort by design: audit failures never break the request.
"""
_MUTATING = {"POST", "PUT", "PATCH", "DELETE"}
_SKIP_PREFIXES = (
"/api/v1/auth",
"/api/v1/health",
"/api/v1/errors",
"/api/v1/audit",
"/api/v1/external",
)
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
if request.method not in self._MUTATING:
return response
if response.status_code < 200 or response.status_code >= 300:
return response
path = request.url.path
if any(path.startswith(p) for p in self._SKIP_PREFIXES):
return response
try:
await self._write_entry(request, path, response.status_code)
except Exception:
logging.getLogger(__name__).debug(
"AuditMiddleware: failed to write baseline entry for %s %s", request.method, path
)
return response
@staticmethod
def _derive_entity_type(path: str) -> str:
"""Derive an entity_type from the second URL segment."""
parts = [p for p in path.split("/") if p]
# /api/v1/<resource>/... -> resource; singularize naive trailing 's'
resource = parts[2] if len(parts) > 2 and parts[0] == "api" and parts[1] == "v1" else (parts[0] if parts else "unknown")
return resource[:-1] if len(resource) > 3 and resource.endswith("s") else resource
async def _write_entry(self, request: Request, path: str, status_code: int) -> None:
from app.core.audit import log_audit
from app.core.auth import get_redis, get_session_data
from app.core.db import create_db_session
# Attribute via the Redis session (same source as CSRFMiddleware) —
# FastAPI dependencies run after middleware, so request.state is empty here.
settings = get_settings()
session_id = request.cookies.get(settings.session_cookie_name)
if not session_id:
return # unauthenticated — nothing to attribute
redis = get_redis()
session_data = await get_session_data(redis, session_id)
if not session_data:
return
tenant_raw = session_data.get("tenant_id")
user_raw = session_data.get("user_id")
if not tenant_raw:
return
action_map = {"POST": "create", "PATCH": "update", "PUT": "update", "DELETE": "delete"}
entity_id: uuid_mod.UUID | None = None
parts = [p for p in path.split("/") if p]
if parts and re.fullmatch(r"[0-9a-fA-F-]{36}", parts[-1]):
try:
entity_id = uuid_mod.UUID(parts[-1])
except ValueError:
entity_id = None
async with create_db_session(uuid_mod.UUID(tenant_raw)) as db:
await log_audit(
db,
uuid_mod.UUID(tenant_raw),
uuid_mod.UUID(user_raw) if user_raw else None,
action_map.get(request.method, request.method.lower()),
self._derive_entity_type(path),
entity_id,
changes={
"source": "middleware",
"method": request.method,
"path": path,
"status": status_code,
},
)
await db.commit()
+2
View File
@@ -54,6 +54,8 @@ CORE_PERMISSIONS: list[dict[str, str]] = [
{"key": "taxes:write", "label": "Taxes: Write", "category": "core", "module": "taxes"}, {"key": "taxes:write", "label": "Taxes: Write", "category": "core", "module": "taxes"},
{"key": "currencies:read", "label": "Currencies: Read", "category": "core", "module": "currencies"}, {"key": "currencies:read", "label": "Currencies: Read", "category": "core", "module": "currencies"},
{"key": "currencies:write", "label": "Currencies: Write", "category": "core", "module": "currencies"}, {"key": "currencies:write", "label": "Currencies: Write", "category": "core", "module": "currencies"},
{"key": "custom_fields:read", "label": "Custom Fields: Read", "category": "core", "module": "custom_fields"},
{"key": "custom_fields:write", "label": "Custom Fields: Write", "category": "core", "module": "custom_fields"},
{"key": "import_export:read", "label": "Import/Export: Read", "category": "core", "module": "import_export"}, {"key": "import_export:read", "label": "Import/Export: Read", "category": "core", "module": "import_export"},
{"key": "import_export:write", "label": "Import/Export: Write", "category": "core", "module": "import_export"}, {"key": "import_export:write", "label": "Import/Export: Write", "category": "core", "module": "import_export"},
{"key": "workspaces:read", "label": "Workspaces: Read", "category": "core", "module": "workspaces"}, {"key": "workspaces:read", "label": "Workspaces: Read", "category": "core", "module": "workspaces"},
+8
View File
@@ -29,6 +29,14 @@ class ServiceContainer:
"""Check if a service is registered.""" """Check if a service is registered."""
return name in self._services return name in self._services
def remove(self, name: str) -> None:
"""Remove a service registration (no-op if absent).
Used by plugin deactivation hooks to clean up services they
registered during activation.
"""
self._services.pop(name, None)
async def initialize(self) -> None: async def initialize(self) -> None:
"""Initialize core services.""" """Initialize core services."""
if self._initialized: if self._initialized:
+40 -12
View File
@@ -501,11 +501,38 @@ async def save_with_metadata(
} }
async def get_file_metadata_async(path: str) -> dict[str, Any]:
"""Awaitable variant of :func:`get_file_metadata` (ARCH-052).
Safe to call from inside a running event loop never creates a
nested one. For local storage this is plain filesystem access; for
S3 and other async backends the backend's ``exists()`` is awaited.
"""
backend = get_storage_backend()
if isinstance(backend, LocalStorage):
full_path = backend._full_path(path)
if not os.path.exists(full_path):
return {"size": None, "modified": None, "exists": False}
stat = os.stat(full_path)
return {
"size": stat.st_size,
"modified": stat.st_mtime,
"exists": True,
}
# S3 or other async backends — await the backend directly
if not await backend.exists(path):
return {"size": None, "modified": None, "exists": False}
return {"size": None, "modified": None, "exists": True}
def get_file_metadata(path: str) -> dict[str, Any]: def get_file_metadata(path: str) -> dict[str, Any]:
"""Read metadata of a stored file without loading its content. """Read metadata of a stored file without loading its content.
Works with the *local* storage backend. For S3, use the S3 client Works with the *local* storage backend without touching the event
``stat_object`` API directly. loop. For S3 and other async-only backends this drives the check
through ``asyncio.run``; calling it from inside a running event loop
raises ``RuntimeError`` use :func:`get_file_metadata_async` there
instead (ARCH-052).
Parameters Parameters
---------- ----------
@@ -530,14 +557,15 @@ def get_file_metadata(path: str) -> dict[str, Any]:
"modified": stat.st_mtime, "modified": stat.st_mtime,
"exists": True, "exists": True,
} }
# S3 or other backends — fall back to exists() check # Async-only backend outside a running loop is fine; inside one we
import asyncio as _asyncio # must never build a nested event loop.
loop = _asyncio.new_event_loop()
try: try:
exists = loop.run_until_complete(backend.exists(path)) asyncio.get_running_loop()
if not exists: except RuntimeError:
return {"size": None, "modified": None, "exists": False} pass
return {"size": None, "modified": None, "exists": True} else:
finally: raise RuntimeError(
loop.close() "get_file_metadata() cannot be used with async storage backends "
"inside a running event loop — use get_file_metadata_async()"
)
return asyncio.run(get_file_metadata_async(path))
+4 -1
View File
@@ -121,11 +121,14 @@ class TriggerDispatcher:
"""Query DB for active automations matching *event_name* and dispatch.""" """Query DB for active automations matching *event_name* and dispatch."""
from app.core.db import get_session_factory from app.core.db import get_session_factory
from app.plugins.builtins.contracts import get_contract from app.plugins.builtins.contracts import get_contract
# None-check FIRST — accessing attributes on the contract before the
# check crashed with AttributeError when automation was inactive
# (ARCH-029/041).
automation_contract = get_contract("automation") automation_contract = get_contract("automation")
AutomationDefinition = automation_contract.Automation # noqa: N806
if automation_contract is None: if automation_contract is None:
logger.debug("Automation plugin not available — trigger skipped") logger.debug("Automation plugin not available — trigger skipped")
return return
AutomationDefinition = automation_contract.Automation # noqa: N806
factory = get_session_factory() factory = get_session_factory()
tenant_id = payload.get("tenant_id") tenant_id = payload.get("tenant_id")
+11 -12
View File
@@ -170,12 +170,7 @@ async def on_startup(ctx: dict[str, Any]) -> None:
if search_contract is not None: if search_contract is not None:
factory = async_session factory = async_session
async with factory() as db: async with factory() as db:
# auto_register_providers is not exposed via contract yet; await search_contract.auto_register_providers(db)
# use the contract's get_search_registry to access providers
from app.plugins.builtins.unified_search.provider_registry import (
auto_register_providers,
)
await auto_register_providers(db)
logger.info("Search providers registered for worker") logger.info("Search providers registered for worker")
else: else:
logger.debug("Unified search plugin not available — skipping provider registration") logger.debug("Unified search plugin not available — skipping provider registration")
@@ -350,8 +345,10 @@ async def cleanup_audit_log_job(ctx: dict[str, Any]) -> None:
Runs daily to prevent the audit_log table from growing indefinitely. Runs daily to prevent the audit_log table from growing indefinitely.
Iterates per-tenant for RLS compliance. Iterates per-tenant for RLS compliance.
""" """
from sqlalchemy import text as sa_text, delete as sa_delete from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta
from sqlalchemy import delete as sa_delete
from sqlalchemy import text as sa_text
from app.core.db import get_worker_session_factory from app.core.db import get_worker_session_factory
from app.models.audit import AuditLog from app.models.audit import AuditLog
@@ -362,7 +359,7 @@ async def cleanup_audit_log_job(ctx: dict[str, Any]) -> None:
tenant_result = await db.execute(sa_text("SELECT id FROM tenants")) tenant_result = await db.execute(sa_text("SELECT id FROM tenants"))
tenant_ids = [row[0] for row in tenant_result] tenant_ids = [row[0] for row in tenant_result]
cutoff = datetime.utcnow() - timedelta(days=365) cutoff = datetime.now(UTC) - timedelta(days=365)
total_deleted = 0 total_deleted = 0
for tenant_id in tenant_ids: for tenant_id in tenant_ids:
await db.execute( await db.execute(
@@ -393,8 +390,10 @@ async def cleanup_trash_job(ctx: dict[str, Any]) -> None:
Runs daily to clean up the trash. Iterates per-tenant for RLS compliance. Runs daily to clean up the trash. Iterates per-tenant for RLS compliance.
Default retention: 90 days in trash before permanent deletion. Default retention: 90 days in trash before permanent deletion.
""" """
from sqlalchemy import text as sa_text, delete as sa_delete from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta
from sqlalchemy import delete as sa_delete
from sqlalchemy import text as sa_text
from app.core.db import get_worker_session_factory from app.core.db import get_worker_session_factory
from app.models.contact import Contact from app.models.contact import Contact
@@ -406,7 +405,7 @@ async def cleanup_trash_job(ctx: dict[str, Any]) -> None:
tenant_result = await db.execute(sa_text("SELECT id FROM tenants")) tenant_result = await db.execute(sa_text("SELECT id FROM tenants"))
tenant_ids = [row[0] for row in tenant_result] tenant_ids = [row[0] for row in tenant_result]
cutoff = datetime.utcnow() - timedelta(days=90) cutoff = datetime.now(UTC) - timedelta(days=90)
total_deleted = 0 total_deleted = 0
for tenant_id in tenant_ids: for tenant_id in tenant_ids:
+28 -3
View File
@@ -17,8 +17,9 @@ from app.core.db import get_db, set_tenant_context, set_user_context
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Known write-permission modules — used by require_write() to check # Legacy fallback list — used by require_write() only when the permission
# specific permissions instead of broad wildcards like *:write # registry is not initialized. The live source of truth is generated from
# the registry (see _get_write_permissions, ARCH-022).
_WRITE_PERMISSIONS = [ _WRITE_PERMISSIONS = [
"users:write", "users:write",
"roles:write", "roles:write",
@@ -35,6 +36,30 @@ _WRITE_PERMISSIONS = [
] ]
def _get_write_permissions() -> list[str]:
"""Return all known ``module:write`` permission keys (ARCH-022).
Generated from the permission registry so plugin write permissions are
picked up automatically without touching this file. Falls back to the
static legacy list when the registry is unavailable/uninitialized.
"""
try:
from app.core.permission_registry import get_permission_registry
registry = get_permission_registry()
if getattr(registry, "_initialized", False):
perms = [
entry["key"]
for entry in registry.get_all()
if entry["key"].endswith(":write")
]
if perms:
return sorted(perms)
except Exception:
pass
return list(_WRITE_PERMISSIONS)
async def get_redis_dep() -> aioredis.Redis: async def get_redis_dep() -> aioredis.Redis:
"""FastAPI dependency for Redis client.""" """FastAPI dependency for Redis client."""
return get_redis() return get_redis()
@@ -261,7 +286,7 @@ async def require_write(
# Check via permission system for specific write permissions # Check via permission system for specific write permissions
from app.core.permissions import check_permission from app.core.permissions import check_permission
for perm in _WRITE_PERMISSIONS: for perm in _get_write_permissions():
if check_permission(current_user, perm): if check_permission(current_user, perm):
return current_user return current_user
+41 -25
View File
@@ -22,8 +22,12 @@ logger = logging.getLogger(__name__)
from app.config import get_settings # noqa: E402 from app.config import get_settings # noqa: E402
from app.core.db import close_engine, get_engine # noqa: E402 from app.core.db import close_engine, get_engine # noqa: E402
from app.core.error_codes import ApiError, build_error_response # noqa: E402 from app.core.error_codes import ERROR_CODES, ApiError, build_error_response # noqa: E402
from app.core.middleware import CSRFMiddleware, SecurityHeadersMiddleware # noqa: E402 from app.core.middleware import ( # noqa: E402
AuditMiddleware,
CSRFMiddleware,
SecurityHeadersMiddleware,
)
from app.core.monitoring import record_error, record_request # noqa: E402 from app.core.monitoring import record_error, record_request # noqa: E402
from app.core.rate_limit import GeneralRateLimitMiddleware # noqa: E402 from app.core.rate_limit import GeneralRateLimitMiddleware # noqa: E402
from app.core.resilience import CircuitBreakerMiddleware # noqa: E402 from app.core.resilience import CircuitBreakerMiddleware # noqa: E402
@@ -36,16 +40,14 @@ from app.routes import ( # noqa: E402
attachments, attachments,
audit, audit,
auth, auth,
compliance,
backups, backups,
bank_accounts, bank_accounts,
contact_folder_permissions, compliance,
contact_folders,
contacts,
currencies, currencies,
custom_field_definitions, custom_field_definitions,
custom_fields, custom_fields,
dashboard, dashboard,
delegations,
entity_history, entity_history,
entity_permissions, entity_permissions,
errors, errors,
@@ -59,7 +61,6 @@ from app.routes import ( # noqa: E402
owner_transfer, owner_transfer,
permission_templates, permission_templates,
plugins, plugins,
delegations,
policies, policies,
roles, roles,
saved_filters, saved_filters,
@@ -284,22 +285,23 @@ async def lifespan(app: FastAPI):
logger.info(f"Plugin {name} is inactive — skipping activation") logger.info(f"Plugin {name} is inactive — skipping activation")
continue continue
# Activate plugin with a FRESH session per plugin to avoid RLS state leakage # Activate plugin ONCE per process (ARCH-002 fix): a fresh session with
# RLS fail-closed requires app.current_tenant_id for tenant-table writes. # the first tenant's RLS context satisfies fail-closed RLS for any
# Plugin activation may fail on duplicate cron job inserts — this is harmless # tenant-table writes during activation. Plugins that need per-tenant
# since cron jobs already exist from previous startups. # data must seed it themselves (e.g. via the default-tenant mechanism).
# Calling on_activate once prevents duplicate event listeners, cron
# jobs, mini-apps and other contributions at multi-tenant startups.
plugin_activated = False plugin_activated = False
for tenant_id in all_tenant_ids: if all_tenant_ids:
try: try:
async with async_session() as plugin_db: async with async_session() as plugin_db:
await set_tenant_context(plugin_db, tenant_id) await set_tenant_context(plugin_db, all_tenant_ids[0])
await plugin.on_activate(plugin_db, container, event_bus) await plugin.on_activate(plugin_db, container, event_bus)
await plugin_db.flush() await plugin_db.flush()
await plugin_db.commit() await plugin_db.commit()
plugin_activated = True plugin_activated = True
except Exception as exc: except Exception as exc:
logger.warning(f"[STARTUP] Plugin {name} activation issue for tenant {tenant_id}: {exc}") logger.warning(f"[STARTUP] Plugin {name} activation issue: {exc}")
break
if plugin_activated: if plugin_activated:
plugin_record.status = "active" plugin_record.status = "active"
@@ -474,6 +476,7 @@ def create_app() -> FastAPI:
) )
app.add_middleware(CSRFMiddleware) app.add_middleware(CSRFMiddleware)
app.add_middleware(SecurityHeadersMiddleware) app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(AuditMiddleware)
app.add_middleware(GeneralRateLimitMiddleware) app.add_middleware(GeneralRateLimitMiddleware)
app.add_middleware(RequestLoggingMiddleware) app.add_middleware(RequestLoggingMiddleware)
app.add_middleware(CircuitBreakerMiddleware) app.add_middleware(CircuitBreakerMiddleware)
@@ -516,11 +519,26 @@ def create_app() -> FastAPI:
504: "service_timeout", 504: "service_timeout",
} }
code = status_to_code.get(exc.status_code, "internal_error" if exc.status_code >= 500 else "validation_error") code = status_to_code.get(exc.status_code, "internal_error" if exc.status_code >= 500 else "validation_error")
body = build_error_response( # Structured detail passthrough (AGENTS.md): when a route raises
code=code, # HTTPException with a dict detail containing a machine-readable ``code``,
detail=str(exc.detail) if exc.detail else None, # preserve the structured shape instead of stringifying it.
trace_id=trace_id, raw_detail = exc.detail
) if isinstance(raw_detail, dict):
inner_code = raw_detail.get("code", code)
body = build_error_response(
code=inner_code if inner_code in ERROR_CODES else code,
detail=raw_detail.get("detail") or str(raw_detail),
trace_id=trace_id,
)
# Preserve the full structured detail as a nested object so clients
# can read ``resp.json()["detail"]["code"]``.
body["detail"] = raw_detail
else:
body = build_error_response(
code=code,
detail=str(exc.detail) if exc.detail else None,
trace_id=trace_id,
)
resp = JSONResponse(status_code=exc.status_code, content=body) resp = JSONResponse(status_code=exc.status_code, content=body)
if trace_id: if trace_id:
resp.headers["X-Trace-Id"] = trace_id resp.headers["X-Trace-Id"] = trace_id
@@ -544,11 +562,9 @@ def create_app() -> FastAPI:
app.include_router(groups.router) app.include_router(groups.router)
app.include_router(tenants.router) app.include_router(tenants.router)
app.include_router(notifications.router) app.include_router(notifications.router)
from app.routes.companies import router as companies_router # NOTE: contacts/companies/contact-folders routes are plugin-owned now
app.include_router(companies_router) # (Block B1) and mounted via the manifest.routes mechanism below with
app.include_router(contacts.router) # require_active_plugin("contacts") protection.
app.include_router(contact_folders.router)
app.include_router(contact_folder_permissions.router)
app.include_router(entity_permissions.router) app.include_router(entity_permissions.router)
app.include_router(dashboard.router) app.include_router(dashboard.router)
app.include_router(entity_history.router) app.include_router(entity_history.router)
+1 -1
View File
@@ -6,8 +6,8 @@ from app.models.audit import AuditLog
from app.models.auth import ApiToken, PasswordResetToken from app.models.auth import ApiToken, PasswordResetToken
from app.models.backup import Backup from app.models.backup import Backup
from app.models.bank_account import BankAccount from app.models.bank_account import BankAccount
from app.models.consumer_inbox import ConsumerInbox
from app.models.compliance import ComplianceIncident from app.models.compliance import ComplianceIncident
from app.models.consumer_inbox import ConsumerInbox
from app.models.contact import Contact, ContactPerson from app.models.contact import Contact, ContactPerson
from app.models.contact_folder import ContactFolder from app.models.contact_folder import ContactFolder
from app.models.contact_merge import ContactMergeHistory from app.models.contact_merge import ContactMergeHistory
+1 -2
View File
@@ -11,13 +11,12 @@ from datetime import datetime
from typing import Any from typing import Any
from sqlalchemy import DateTime, ForeignKey, String, func from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin from app.core.db import Base, TenantMixin
from app.models.owned_mixin import OwnedMixin from app.models.owned_mixin import OwnedMixin
from sqlalchemy.dialects.postgresql import TSVECTOR
# Re-export EntityHistory as DeletionLog for backward compatibility. # Re-export EntityHistory as DeletionLog for backward compatibility.
# Tests import DeletionLog from app.models.audit and use entity_snapshot attribute. # Tests import DeletionLog from app.models.audit and use entity_snapshot attribute.
+47 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, Integer, String, Text, func from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -77,3 +77,49 @@ class EventOutbox(Base):
failed_at: Mapped[datetime | None] = mapped_column( failed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, DateTime(timezone=True), nullable=True,
) )
class OutboxDelivery(Base):
"""Per-consumer delivery status for an event_outbox row (Migration 0075).
Tracks whether each consumer successfully processed an event; an event is
only 'published' when all mandatory deliveries succeed.
"""
__tablename__ = "outbox_deliveries"
__table_args__ = (
UniqueConstraint(
"event_id", "consumer_name",
name="uq_outbox_deliveries_event_consumer",
),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True,
server_default=func.gen_random_uuid(),
)
event_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("event_outbox.id", ondelete="CASCADE"),
nullable=False,
)
consumer_name: Mapped[str] = mapped_column(String(150), nullable=False)
status: Mapped[str] = mapped_column(
String(30), nullable=False, server_default="pending",
)
attempt_count: Mapped[int] = mapped_column(
Integer, nullable=False, server_default="0",
)
next_attempt_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True,
)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
processed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
+3
View File
@@ -53,6 +53,9 @@ class SystemSettings(Base, TenantMixin, OwnedMixin):
theme_border_radius: Mapped[str] = mapped_column(String(20), nullable=False, default="0.5rem") theme_border_radius: Mapped[str] = mapped_column(String(20), nullable=False, default="0.5rem")
# Backup configuration # Backup configuration
backup_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") backup_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
backup_interval: Mapped[str] = mapped_column(String(20), nullable=False, default="daily", server_default="daily")
backup_retention_days: Mapped[int] = mapped_column(Integer, nullable=False, default=7, server_default="7")
backup_destination: Mapped[str] = mapped_column(String(20), nullable=False, default="local", server_default="local")
# Automation plugin settings (JSONB) # Automation plugin settings (JSONB)
automation_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True) automation_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
# Retention policy overrides (JSONB) — compliance module # Retention policy overrides (JSONB) — compliance module
-1
View File
@@ -12,7 +12,6 @@ from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, SoftDeleteMixin, TimestampMixin from app.core.db import Base, SoftDeleteMixin, TimestampMixin
from app.models.owned_mixin import OwnedMixin
class User(Base, TimestampMixin, SoftDeleteMixin): class User(Base, TimestampMixin, SoftDeleteMixin):
+9
View File
@@ -97,6 +97,15 @@ class BasePlugin(ABC):
""" """
return [] return []
async def register_event_handlers(self, event_bus: EventBus) -> None:
"""Register event handlers for the background worker (ARCH-038 hook).
The worker calls this on every active plugin at startup so plugins
can subscribe to events even when the web process is separate.
Default: no-op. Override to subscribe handlers.
"""
return None
# ─── Job Modules ─── # ─── Job Modules ───
def get_job_modules(self) -> list[str]: def get_job_modules(self) -> list[str]:
@@ -12,7 +12,6 @@ from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin from app.core.db import Base, TenantMixin
from app.models.owned_mixin import OwnedMixin from app.models.owned_mixin import OwnedMixin
from pgvector.sqlalchemy import Vector
class AgentMemory(Base, TenantMixin, OwnedMixin): class AgentMemory(Base, TenantMixin, OwnedMixin):
@@ -22,6 +22,7 @@ from app.plugins.builtins.ai_assistant.schemas import (
ExternalAgentRequest, ExternalAgentRequest,
ExternalAgentResponse, ExternalAgentResponse,
) )
from app.plugins.builtins.ai_assistant.services import stream_chat_comm as stream_chat
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -120,13 +121,18 @@ async def run_agent_external(
} }
# Run the agent via streaming chat (non-streaming mode) # Run the agent via streaming chat (non-streaming mode)
from app.plugins.builtins.ai_assistant.services import stream_chat_comm
full_response = "" full_response = ""
async with get_db() as stream_db: async with get_db() as stream_db:
await set_tenant_context(stream_db, tenant_id) await set_tenant_context(stream_db, tenant_id)
async for chunk in stream_chat( async for chunk in stream_chat(
stream_db, session, agent, data.message, user_context, tenant_id stream_db,
session.id,
agent,
data.message,
user_context,
tenant_id,
uuid.UUID(current_user["user_id"]),
): ):
if chunk.startswith("data: ") and chunk != "data: [DONE]\n\n": if chunk.startswith("data: ") and chunk != "data: [DONE]\n\n":
try: try:
@@ -0,0 +1,10 @@
-- Dual-path convergence (Gate B): add compliance columns that Alembic
-- migration 0119 adds on the core path. Idempotent so both install paths
-- converge to the identical schema.
ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS region VARCHAR(20) NOT NULL DEFAULT 'unknown';
ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS hosting_type VARCHAR(30) NOT NULL DEFAULT 'cloud';
ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS dpa_status VARCHAR(20) NOT NULL DEFAULT 'none';
ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS retention_policy TEXT NOT NULL DEFAULT '';
ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS training_on_customer_data BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS transfer_notice TEXT NOT NULL DEFAULT '';
ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS allowed_data_classes JSONB NOT NULL DEFAULT '[]'::jsonb;
+3 -3
View File
@@ -42,7 +42,7 @@ class AIAssistantPlugin(BasePlugin):
), ),
], ],
events=[], events=[],
migrations=["0001_initial.sql", "0002_folders_attachments.sql"], migrations=["0001_initial.sql", "0002_folders_attachments.sql", "0003_sort_order.sql", "0004_compliance_fields.sql"],
permissions=[ permissions=[
"ai:read", "ai:read",
"ai:write", "ai:write",
@@ -52,10 +52,10 @@ class AIAssistantPlugin(BasePlugin):
], ],
is_core=True, is_core=True,
menu_items=[ menu_items=[
FrontendMenuItem(label_key='nav.aiAssistant', label='KI Assistent', path='/ai-assistant', icon='Bot', order=90), FrontendMenuItem(label_key='nav.aiAssistant', label='KI Assistent', path='/ai-assistant', icon='Bot', order=90, permission='ai:read'),
], ],
page_routes=[ page_routes=[
FrontendPageRoute(path='/ai-assistant', component='@/pages/AIAssistant', protected=True), FrontendPageRoute(path='/ai-assistant', component='@/pages/AIAssistant', protected=True, permission='ai:read'),
], ],
settings_pages=[ settings_pages=[
FrontendSettingsPage(path='ai', label_key='settings.ai', label='AI Settings', component='@/pages/AISettings', icon='Bot', order=60), FrontendSettingsPage(path='ai', label_key='settings.ai', label='AI Settings', component='@/pages/AISettings', icon='Bot', order=60),
@@ -165,8 +165,8 @@ async def get_open_tasks_handler(arguments: dict[str, Any], context: dict[str, A
from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract
_cal = get_calendar_contract() _cal = get_calendar_contract()
calendar_entry = _cal.calendar_entry calendar_entry = _cal.CalendarEntry
calendar_entry_link = _cal.calendar_entry_link calendar_entry_link = _cal.CalendarEntryLink
db, tenant_id, _ = await _get_db_and_tenant(context) db, tenant_id, _ = await _get_db_and_tenant(context)
entity_type = arguments["entity_type"] entity_type = arguments["entity_type"]
@@ -69,10 +69,12 @@ async def push_suggestion(user_id: str, suggestion: dict[str, Any]) -> None:
# Post suggestion to Communication (I-WORK-PROACTIVE) # Post suggestion to Communication (I-WORK-PROACTIVE)
try: try:
import uuid as uuid_mod import uuid as uuid_mod
from sqlalchemy import select as sa_select
from app.core.db import get_worker_session_factory
from app.plugins.builtins.contracts import get_contract_registry from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.kommunikation.models import CommConversation from app.plugins.builtins.kommunikation.models import CommConversation
from sqlalchemy import select as sa_select
from app.core.db import get_worker_session_factory
komm = get_contract_registry().get("kommunikation") komm = get_contract_registry().get("kommunikation")
if komm: if komm:
factory = get_worker_session_factory() factory = get_worker_session_factory()
@@ -274,8 +276,8 @@ async def gather_context(
# Upcoming calendar events # Upcoming calendar events
from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract
_cal = get_calendar_contract() _cal = get_calendar_contract()
calendar_entry = _cal.calendar_entry calendar_entry = _cal.CalendarEntry
calendar_entry_link = _cal.calendar_entry_link calendar_entry_link = _cal.CalendarEntryLink
now = datetime.now(UTC) now = datetime.now(UTC)
event_result = await db.execute( event_result = await db.execute(
@@ -389,8 +391,8 @@ async def gather_context(
# Upcoming events # Upcoming events
from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract
_cal = get_calendar_contract() _cal = get_calendar_contract()
calendar_entry = _cal.calendar_entry calendar_entry = _cal.CalendarEntry
calendar_entry_link = _cal.calendar_entry_link calendar_entry_link = _cal.CalendarEntryLink
now = datetime.now(UTC) now = datetime.now(UTC)
event_result = await db.execute( event_result = await db.execute(
+4 -1
View File
@@ -62,7 +62,10 @@ class AIUIControlPlugin(BasePlugin):
from app.plugins.builtins.contracts import get_contract_registry from app.plugins.builtins.contracts import get_contract_registry
get_contract_registry().unregister(self.manifest.name) get_contract_registry().unregister(self.manifest.name)
await super().on_deactivate(db, service_container, event_bus) # Remove the WebSocket manager BEFORE super() so that event handlers
# being unsubscribed can no longer reach it (ARCH-044).
if service_container.has("ai_ui_control_ws"): if service_container.has("ai_ui_control_ws"):
service_container.remove("ai_ui_control_ws") service_container.remove("ai_ui_control_ws")
logger.info("AI UI Control WebSocket manager removed") logger.info("AI UI Control WebSocket manager removed")
await super().on_deactivate(db, service_container, event_bus)
@@ -55,7 +55,8 @@ async def send_agent_message(
# 2. Create a kommunikation message in a dedicated agent room # 2. Create a kommunikation message in a dedicated agent room
try: try:
from app.plugins.builtins.kommunikation.contracts import CommConversation as Room, CommMessage as Message from app.plugins.builtins.kommunikation.contracts import CommConversation as Room
from app.plugins.builtins.kommunikation.contracts import CommMessage as Message
# Find or create the agent-to-agent room # Find or create the agent-to-agent room
room_name = f"agent:{from_agent_id}:{target_agent.id}" room_name = f"agent:{from_agent_id}:{target_agent.id}"
@@ -619,6 +619,7 @@ async def stream_agent_run(
in real-time as the agent processes. in real-time as the agent processes.
""" """
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from app.ai.agent_stream import stream_react_loop from app.ai.agent_stream import stream_react_loop
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
+13 -21
View File
@@ -11,6 +11,7 @@ Safety features:
from __future__ import annotations from __future__ import annotations
import logging import logging
import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
@@ -37,12 +38,12 @@ async def run_agent(
3. Infinite loop: same tool 5x consecutively (handled in ReAct loop) 3. Infinite loop: same tool 5x consecutively (handled in ReAct loop)
4. Budget limit: cumulative cost_usd 4. Budget limit: cumulative cost_usd
""" """
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
from app.plugins.builtins.automation.models import ( from app.plugins.builtins.automation.models import (
AgentDefinition, AgentDefinition,
AgentRun, AgentRun,
AgentRunStep, AgentRunStep,
) )
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
factory = get_session_factory() factory = get_session_factory()
@@ -110,9 +111,9 @@ async def run_agent(
try: try:
from app.plugins.builtins.contracts import get_contract from app.plugins.builtins.contracts import get_contract
mail_contract = get_contract("mail") mail_contract = get_contract("mail")
if mail_contract and hasattr(mail_contract, "get_recent_mails"): if mail_contract and hasattr(mail_contract, "Mail"):
from sqlalchemy import select as _select from sqlalchemy import select as _select
from app.plugins.builtins.mail.models import Mail Mail = mail_contract.Mail
async with factory() as db: async with factory() as db:
mail_q = await db.execute( mail_q = await db.execute(
_select(Mail) _select(Mail)
@@ -167,7 +168,7 @@ async def run_agent(
perm_ctx = await resolve_agent_permissions( perm_ctx = await resolve_agent_permissions(
db=db, db=db,
tenant_id=agent.tenant_id, tenant_id=agent.tenant_id,
user_id=agent.created_by or uuid_mod.uuid4(), user_id=agent.created_by or uuid.uuid4(),
agent_definition=agent, agent_definition=agent,
) )
@@ -379,23 +380,16 @@ async def run_agent(
komm = get_contract_registry().get("kommunikation") komm = get_contract_registry().get("kommunikation")
if komm: if komm:
async with factory() as db: async with factory() as db:
# Find or create agent conversation room # Find or create agent conversation room via contract
from app.plugins.builtins.contracts import get_contract as _get_contract # (find_locked_room_id matches create_plugin_room semantics)
_komm_contract = _get_contract("kommunikation")
from app.plugins.builtins.kommunikation.models import CommConversation
from sqlalchemy import select as sa_select
room_title = f"Agent: {agent.name}" room_title = f"Agent: {agent.name}"
existing = await db.execute( conv_id = await komm.find_locked_room_id(
sa_select(CommConversation).where( db=db,
CommConversation.tenant_id == agent.tenant_id, tenant_id=agent.tenant_id,
CommConversation.title == room_title, plugin_name="automation",
CommConversation.is_locked.is_(True), title=room_title,
CommConversation.locked_by == "automation",
CommConversation.deleted_at.is_(None),
)
) )
conv = existing.scalar_one_or_none() if not conv_id:
if not conv:
room = await komm.create_plugin_room( room = await komm.create_plugin_room(
db=db, db=db,
tenant_id=agent.tenant_id, tenant_id=agent.tenant_id,
@@ -405,8 +399,6 @@ async def run_agent(
participant_type="agent", participant_type="agent",
) )
conv_id = uuid.UUID(room["conversation_id"]) conv_id = uuid.UUID(room["conversation_id"])
else:
conv_id = conv.id
# Post result as message with action_card block # Post result as message with action_card block
status = result_data.get("status", "unknown") status = result_data.get("status", "unknown")
@@ -0,0 +1,31 @@
-- Dual-path convergence (Gate B): create the ReAct step-tracking table
-- that Alembic migration 0121 creates on the core path, add the Phase-F
-- columns from 0122, and apply the RLS policy from 0129/0136. Idempotent
-- so both install paths converge to the identical schema.
CREATE TABLE IF NOT EXISTS automation_agent_run_steps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
agent_run_id UUID NOT NULL REFERENCES automation_agent_runs(id) ON DELETE CASCADE,
step_number INTEGER NOT NULL,
thought TEXT,
action VARCHAR(255),
action_input JSONB,
observation TEXT,
cost_usd FLOAT NOT NULL DEFAULT 0.0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_agent_run_steps_run ON automation_agent_run_steps(tenant_id, agent_run_id);
ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS temperature FLOAT NOT NULL DEFAULT 0.3;
ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS max_tokens INTEGER NOT NULL DEFAULT 1000;
ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS max_steps INTEGER NOT NULL DEFAULT 20;
ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS trace_mode VARCHAR(20) NOT NULL DEFAULT 'standard';
ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS skill_ids JSONB NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS trigger_config JSONB NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS ai_use_case_metadata JSONB NOT NULL DEFAULT '{}'::jsonb;
-- RLS matching migrations 0129 + 0136 (current_tenant_id variant)
ALTER TABLE automation_agent_run_steps ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON automation_agent_run_steps;
CREATE POLICY tenant_isolation ON automation_agent_run_steps
USING (tenant_id::text = current_setting('app.current_tenant_id', true));
+35 -19
View File
@@ -38,7 +38,7 @@ class AutomationPlugin(BasePlugin):
"Define AI agents with LLM models and tools, create event/schedule/manual " "Define AI agents with LLM models and tools, create event/schedule/manual "
"automations with conditions and actions, schedule cron jobs, and track execution logs." "automations with conditions and actions, schedule cron jobs, and track execution logs."
), ),
dependencies=[], dependencies=["mail"],
routes=[ routes=[
PluginRouteDef( PluginRouteDef(
path="/api/v1/automation", path="/api/v1/automation",
@@ -62,7 +62,7 @@ class AutomationPlugin(BasePlugin):
"mail.received", "mail.received",
"workflow.timeout", "workflow.timeout",
], ],
migrations=["0001_initial.sql", "0002_agent_subtasks.sql", "0003_skill_definitions.sql"], migrations=["0001_initial.sql", "0002_agent_subtasks.sql", "0003_skill_definitions.sql", "0004_run_steps_phase_f.sql"],
permissions=[ permissions=[
"automation:read", "automation:read",
"automation:write", "automation:write",
@@ -82,6 +82,7 @@ class AutomationPlugin(BasePlugin):
path="/workflows", path="/workflows",
icon="Workflow", icon="Workflow",
order=52, order=52,
permission="automation:read",
), ),
FrontendMenuItem( FrontendMenuItem(
label_key="nav.importExport", label_key="nav.importExport",
@@ -89,6 +90,7 @@ class AutomationPlugin(BasePlugin):
path="/import-export", path="/import-export",
icon="ArrowUpDown", icon="ArrowUpDown",
order=53, order=53,
permission="import_export:read",
), ),
FrontendMenuItem( FrontendMenuItem(
label_key="nav.dedupMerge", label_key="nav.dedupMerge",
@@ -96,6 +98,7 @@ class AutomationPlugin(BasePlugin):
path="/contacts/dedup", path="/contacts/dedup",
icon="Copy", icon="Copy",
order=54, order=54,
permission="contacts:read",
), ),
FrontendMenuItem( FrontendMenuItem(
label_key="nav.tags", label_key="nav.tags",
@@ -103,6 +106,7 @@ class AutomationPlugin(BasePlugin):
path="/tags", path="/tags",
icon="Tag", icon="Tag",
order=55, order=55,
permission="tags:read",
), ),
FrontendMenuItem( FrontendMenuItem(
label_key="nav.activity", label_key="nav.activity",
@@ -110,6 +114,7 @@ class AutomationPlugin(BasePlugin):
path="/activity", path="/activity",
icon="Activity", icon="Activity",
order=56, order=56,
permission="contacts:read",
), ),
], ],
page_routes=[ page_routes=[
@@ -117,21 +122,25 @@ class AutomationPlugin(BasePlugin):
path="/automation", path="/automation",
component="@/pages/AutomationDashboard", component="@/pages/AutomationDashboard",
order=50, order=50,
permission="automation:read",
), ),
FrontendPageRoute( FrontendPageRoute(
path="/agents", path="/agents",
component="@/pages/AgentDashboard", component="@/pages/AgentDashboard",
order=51, order=51,
permission="agents:read",
), ),
FrontendPageRoute( FrontendPageRoute(
path="/workflows", path="/workflows",
component="@/pages/Workflows", component="@/pages/Workflows",
order=52, order=52,
permission="automation:read",
), ),
FrontendPageRoute( FrontendPageRoute(
path="/import-export", path="/import-export",
component="@/pages/ImportExport", component="@/pages/ImportExport",
order=53, order=53,
permission="import_export:read",
), ),
], ],
settings_pages=[ settings_pages=[
@@ -239,18 +248,25 @@ class AutomationPlugin(BasePlugin):
logger.exception("Failed to register own cron jobs") logger.exception("Failed to register own cron jobs")
# Register pre-built agents in DB (if not already present) # Register pre-built agents in DB (if not already present)
try: try:
from app.plugins.builtins.automation.models import AgentDefinition
from app.plugins.builtins.automation.prebuilt.email_triage_agent import create_email_triage_agent
from app.plugins.builtins.automation.prebuilt.contact_enrichment_agent import create_contact_enrichment_agent
from app.plugins.builtins.automation.prebuilt.follow_up_agent import create_follow_up_agent
from app.plugins.builtins.automation.prebuilt.report_agent import create_report_agent
from sqlalchemy import select as sa_select from sqlalchemy import select as sa_select
# Get first tenant + admin user for seeding # Get system tenant + admin user for seeding (ARCH-043:
from app.models.user import User # deterministic slug lookup instead of arbitrary first row)
from app.models.tenant import Tenant from app.core.db import get_system_tenant
tenant_result = await db.execute(sa_select(Tenant).limit(1)) from app.models.user import User, UserTenant
tenant = tenant_result.scalar_one_or_none() from app.plugins.builtins.automation.models import AgentDefinition
from app.plugins.builtins.automation.prebuilt.contact_enrichment_agent import (
create_contact_enrichment_agent,
)
from app.plugins.builtins.automation.prebuilt.email_triage_agent import (
create_email_triage_agent,
)
from app.plugins.builtins.automation.prebuilt.follow_up_agent import (
create_follow_up_agent,
)
from app.plugins.builtins.automation.prebuilt.report_agent import create_report_agent
tenant = await get_system_tenant(db)
if tenant: if tenant:
user_result = await db.execute( user_result = await db.execute(
sa_select(User) sa_select(User)
@@ -288,15 +304,14 @@ class AutomationPlugin(BasePlugin):
def _register_workflow_agent_tools(self) -> None: def _register_workflow_agent_tools(self) -> None:
"""Register I-AW agent tools for starting and inspecting workflows.""" """Register I-AW agent tools for starting and inspecting workflows."""
import uuid import uuid
from typing import Any
from app.ai.tool_registry import get_tool_registry from app.ai.tool_registry import get_tool_registry
registry = get_tool_registry() registry = get_tool_registry()
async def _start_workflow_handler(arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]: async def _start_workflow_handler(arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
"""Start a workflow by ID.""" """Start a workflow by ID."""
from app.services.workflow_service import create_instance
from app.core.db import get_worker_session_factory from app.core.db import get_worker_session_factory
from app.services.workflow_service import create_instance
workflow_id = arguments.get("workflow_id", "") workflow_id = arguments.get("workflow_id", "")
tenant_id = context.get("tenant_id") tenant_id = context.get("tenant_id")
user_id = context.get("user_id") user_id = context.get("user_id")
@@ -332,8 +347,9 @@ class AutomationPlugin(BasePlugin):
async def _check_workflow_status_handler(arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]: async def _check_workflow_status_handler(arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
"""Check the status of a workflow instance.""" """Check the status of a workflow instance."""
from sqlalchemy import select from sqlalchemy import select
from app.models.workflow import WorkflowInstance
from app.core.db import get_worker_session_factory from app.core.db import get_worker_session_factory
from app.models.workflow import WorkflowInstance
instance_id = arguments.get("instance_id", "") instance_id = arguments.get("instance_id", "")
tenant_id = context.get("tenant_id") tenant_id = context.get("tenant_id")
if not instance_id or not tenant_id: if not instance_id or not tenant_id:
@@ -418,16 +434,16 @@ class AutomationPlugin(BasePlugin):
from another plugin's manifest. Uses plugin name prefixing for conflict resolution.""" from another plugin's manifest. Uses plugin name prefixing for conflict resolution."""
from sqlalchemy import select from sqlalchemy import select
# Get default tenant_id from the first tenant in the DB # Get system tenant for contributions (ARCH-043: deterministic slug
from app.models.tenant import Tenant # lookup instead of arbitrary first row)
from app.core.db import get_system_tenant
from app.plugins.builtins.automation.models import AutomationCronJob from app.plugins.builtins.automation.models import AutomationCronJob
from app.plugins.builtins.automation.services import ( from app.plugins.builtins.automation.services import (
AgentService, AgentService,
AutomationService, AutomationService,
CronJobService, CronJobService,
) )
tenant_result = await db.execute(select(Tenant).limit(1)) tenant = await get_system_tenant(db)
tenant = tenant_result.scalar_one_or_none()
default_tenant_id = tenant.id if tenant else None default_tenant_id = tenant.id if tenant else None
if default_tenant_id is None: if default_tenant_id is None:
logger.warning("No tenant found — skipping plugin contributions registration") logger.warning("No tenant found — skipping plugin contributions registration")
@@ -3,7 +3,9 @@
Enriches contact data by searching for related information. Enriches contact data by searching for related information.
""" """
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from app.plugins.builtins.automation.models import AgentDefinition from app.plugins.builtins.automation.models import AgentDefinition
CONTACT_ENRICHMENT_SYSTEM_PROMPT = """You are a Contact Enrichment Agent for a CRM system. CONTACT_ENRICHMENT_SYSTEM_PROMPT = """You are a Contact Enrichment Agent for a CRM system.
@@ -3,7 +3,9 @@
Sorts and prioritizes incoming emails automatically. Sorts and prioritizes incoming emails automatically.
""" """
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from app.plugins.builtins.automation.models import AgentDefinition from app.plugins.builtins.automation.models import AgentDefinition
EMAIL_TRIAGE_SYSTEM_PROMPT = """You are an E-Mail Triage Agent for a CRM system. EMAIL_TRIAGE_SYSTEM_PROMPT = """You are an E-Mail Triage Agent for a CRM system.
@@ -3,7 +3,9 @@
Reminds about and creates follow-up tasks for contacts. Reminds about and creates follow-up tasks for contacts.
""" """
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from app.plugins.builtins.automation.models import AgentDefinition from app.plugins.builtins.automation.models import AgentDefinition
FOLLOW_UP_SYSTEM_PROMPT = """You are a Follow-up Agent for a CRM system. FOLLOW_UP_SYSTEM_PROMPT = """You are a Follow-up Agent for a CRM system.
@@ -3,7 +3,9 @@
Generates reports from CRM data using search and API tools. Generates reports from CRM data using search and API tools.
""" """
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from app.plugins.builtins.automation.models import AgentDefinition from app.plugins.builtins.automation.models import AgentDefinition
REPORT_SYSTEM_PROMPT = """You are a Report Agent for a CRM system. REPORT_SYSTEM_PROMPT = """You are a Report Agent for a CRM system.
@@ -1,60 +1,132 @@
"""Tests for the Automation & Agents plugin. """Tests for the Automation & Agents plugin.
Uses pytest with async fixtures. Tests use SQLite in-memory database Uses pytest with async fixtures against an ephemeral PostgreSQL database
since PostgreSQL may not be available in the dev container. (SQLITE-001 fix) matches the project convention and exercises the real
PGUUID/JSONB column types.
""" """
from __future__ import annotations from __future__ import annotations
# Register ALL plugin models so create_all can resolve cross-plugin FKs
# (e.g. entity_attachments.dms_file_id -> files) — same pattern as
# scripts/sync_plugin_schema.py.
import importlib
import os
import pkgutil
import uuid import uuid
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
import app.models # noqa: F401 — registers core models
import app.models.outbox # noqa: F401 — event_outbox is NOT re-exported by app.models
import app.plugins.builtins as _builtins_pkg
from app.core.db import Base from app.core.db import Base
from app.plugins.builtins.automation.models import (
for _importer, _modname, _ispkg in pkgutil.iter_modules(_builtins_pkg.__path__):
if not _ispkg:
continue
try:
importlib.import_module(f"app.plugins.builtins.{_modname}.models")
except ImportError:
pass # plugin without models module
except Exception: # pragma: no cover - defensive
pass
from app.plugins.builtins.automation.models import ( # noqa: E402 — after dynamic plugin-model discovery
AgentRun, AgentRun,
AutomationRun, AutomationRun,
) )
from app.plugins.builtins.automation.services import ( from app.plugins.builtins.automation.services import ( # noqa: E402 — after dynamic plugin-model discovery
AgentService, AgentService,
AutomationService, AutomationService,
CronJobService, CronJobService,
) )
def _ephemeral_db_url() -> str:
"""Derive an ephemeral test DB URL from DATABASE_URL/.env.test."""
base_url = os.environ.get(
"DATABASE_URL",
"postgresql+asyncpg://leocrm_test:test123@localhost:5432/leocrm_test",
)
return f"{base_url.rsplit('/', 1)[0]}/automation_test_{uuid.uuid4().hex[:8]}"
# ─── Fixtures ─── # ─── Fixtures ───
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def db() -> AsyncGenerator[AsyncSession, None]: async def db() -> AsyncGenerator[AsyncSession, None]:
"""Create an in-memory SQLite database for testing.""" """Create an ephemeral PostgreSQL database for this test run."""
engine = create_async_engine( db_url = _ephemeral_db_url()
"sqlite+aiosqlite:///:memory:", admin_url = db_url.rsplit("/", 1)[0] + "/postgres"
echo=False,
) from sqlalchemy.ext.asyncio import create_async_engine as _cae
admin_engine = _cae(admin_url, isolation_level="AUTOCOMMIT")
async with admin_engine.connect() as conn:
await conn.execute(text(f'CREATE DATABASE "{db_url.rsplit("/", 1)[1]}"'))
await admin_engine.dispose()
# Plugin models use the pgvector Vector type — enable the extension in
# the fresh database before create_all runs (must connect to the target
# DB itself; CREATE EXTENSION has no ON DATABASE clause).
ext_engine = _cae(db_url, isolation_level="AUTOCOMMIT")
async with ext_engine.connect() as conn:
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
await ext_engine.dispose()
engine = create_async_engine(db_url, echo=False)
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session: try:
yield session async with async_session() as session:
yield session
await engine.dispose() finally:
await engine.dispose()
admin_engine2 = _cae(admin_url, isolation_level="AUTOCOMMIT")
async with admin_engine2.connect() as conn:
await conn.execute(text(f'DROP DATABASE IF EXISTS "{db_url.rsplit("/", 1)[1]}"'))
await admin_engine2.dispose()
@pytest.fixture @pytest_asyncio.fixture
def tenant_id() -> uuid.UUID: async def tenant_id(db: AsyncSession) -> uuid.UUID:
return uuid.uuid4() """Create a real tenant row — PostgreSQL enforces FKs, unlike SQLite."""
from app.models.tenant import Tenant
tid = uuid.uuid4()
db.add(Tenant(id=tid, name="Test Org", slug=f"test-{tid.hex[:8]}"))
await db.commit()
return tid
@pytest.fixture @pytest_asyncio.fixture
def user_id() -> uuid.UUID: async def user_id(db: AsyncSession, tenant_id: uuid.UUID) -> uuid.UUID:
return uuid.uuid4() """Create a real user row belonging to the test tenant."""
from app.models.user import User
uid = uuid.uuid4()
db.add(
User(
id=uid,
email=f"test-{uid.hex[:8]}@example.com",
name="Test User",
password_hash="not-a-real-hash",
is_active=True,
)
)
await db.commit()
return uid
# ─── AgentService Tests ─── # ─── AgentService Tests ───
@@ -425,11 +497,17 @@ class TestDryRunMode:
assert automation.dry_run is True assert automation.dry_run is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_dry_run_flag_in_run(self, db: AsyncSession, tenant_id: uuid.UUID): async def test_dry_run_flag_in_run(self, db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID):
"""Test that dry_run flag is stored in AutomationRun.""" """Test that dry_run flag is stored in AutomationRun."""
# PostgreSQL enforces the FK to automations — create a real one first
data = {"name": "dry-run-flag", "description": "", "trigger_type": "manual",
"trigger_config": {}, "conditions": [], "actions": [],
"is_active": True, "dry_run": True}
automation = await AutomationService.create(db, tenant_id, data, user_id=user_id)
run = AutomationRun( run = AutomationRun(
tenant_id=tenant_id, tenant_id=tenant_id,
automation_id=uuid.uuid4(), automation_id=automation.id,
status="dry_run", status="dry_run",
started_at=datetime.now(UTC), started_at=datetime.now(UTC),
dry_run=True, dry_run=True,
@@ -475,7 +553,9 @@ class TestRateLimiting:
) )
recent_runs = result.scalar() or 0 recent_runs = result.scalar() or 0
assert recent_runs == 2 assert recent_runs == 2
assert recent_runs < agent.max_executions_per_hour # 2 < 2 is False, so limit would be hit # With max_executions_per_hour=2 and 2 runs in the window, the limit
# is reached — the next execution must be blocked.
assert recent_runs >= agent.max_executions_per_hour
# ─── Budget Limit Tests ─── # ─── Budget Limit Tests ───
@@ -512,7 +592,8 @@ class TestBudgetLimit:
.where(AgentRun.agent_id == agent.id) .where(AgentRun.agent_id == agent.id)
) )
total_cost = float(cost_result.scalar() or 0.0) total_cost = float(cost_result.scalar() or 0.0)
assert total_cost == 0.6 # FLOAT column accumulates binary rounding (0.6000000000000001)
assert total_cost == pytest.approx(0.6)
assert total_cost >= agent.budget_limit_usd # 0.6 >= 0.5, budget exceeded assert total_cost >= agent.budget_limit_usd # 0.6 >= 0.5, budget exceeded
+2 -3
View File
@@ -6,6 +6,7 @@ import uuid
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from pgvector.sqlalchemy import Vector
from sqlalchemy import ( from sqlalchemy import (
Boolean, Boolean,
DateTime, DateTime,
@@ -13,14 +14,12 @@ from sqlalchemy import (
Index, Index,
String, String,
) )
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin from app.core.db import Base, TenantMixin
from app.models.owned_mixin import OwnedMixin from app.models.owned_mixin import OwnedMixin
from sqlalchemy.dialects.postgresql import TSVECTOR
from pgvector.sqlalchemy import Vector
class Calendar(Base, TenantMixin, OwnedMixin): class Calendar(Base, TenantMixin, OwnedMixin):
+18 -6
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from app.plugins.base import BasePlugin from app.plugins.base import BasePlugin
from app.plugins.manifest import ( from app.plugins.manifest import (
FrontendDetailTab, FrontendDashboardWidget,
FrontendMenuItem, FrontendMenuItem,
FrontendPageRoute, FrontendPageRoute,
PluginManifest, PluginManifest,
@@ -40,6 +40,18 @@ class CalendarPlugin(BasePlugin):
], ],
events=[], events=[],
migrations=["0001_initial.sql", "0002_add_deleted_at.sql"], migrations=["0001_initial.sql", "0002_add_deleted_at.sql"],
dashboard_widgets=[
FrontendDashboardWidget(
id="calendar_upcoming",
label_key="dashboard.calendarUpcoming",
label="Upcoming Appointments",
component="@/components/dashboard/CalendarUpcomingWidget",
icon="Calendar",
order=30,
col_span=1,
permission="calendar:read",
),
],
permissions=[ permissions=[
"calendar:read", "calendar:read",
"calendar:write", "calendar:write",
@@ -48,14 +60,14 @@ class CalendarPlugin(BasePlugin):
"calendar:admin", "calendar:admin",
], ],
menu_items=[ menu_items=[
FrontendMenuItem(label_key='nav.calendar', label='Kalender', path='/calendar', icon='Calendar', order=20), FrontendMenuItem(label_key='nav.calendar', label='Kalender', path='/calendar', icon='Calendar', order=20, permission='calendar:read'),
], ],
page_routes=[ page_routes=[
FrontendPageRoute(path='/calendar', component='@/pages/Calendar', protected=True), FrontendPageRoute(path='/calendar', component='@/pages/Calendar', protected=True, permission='calendar:read'),
],
detail_tabs=[
FrontendDetailTab(entity_type='contact', label_key='tabs.calendar', label='Calendar', component='@/components/contact/ContactCalendarTab', icon='Calendar', order=30, permission='calendar:read'),
], ],
# BUG (ghost component): ContactCalendarTab does not exist in the
# frontend — tab removed until implemented (Block I-D).
detail_tabs=[],
author="LeoCRM Team", author="LeoCRM Team",
min_app_version="1.0.0", min_app_version="1.0.0",
hooks=["calendar.before_appointment", "calendar.after_appointment"], hooks=["calendar.before_appointment", "calendar.after_appointment"],
+1 -2
View File
@@ -21,6 +21,7 @@ from fastapi.responses import StreamingResponse
from sqlalchemy import select, update from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit
from app.core.db import get_db from app.core.db import get_db
from app.deps import get_current_user, require_admin, require_permission from app.deps import get_current_user, require_admin, require_permission
from app.plugins.builtins.calendar.ics_utils import ( from app.plugins.builtins.calendar.ics_utils import (
@@ -1023,5 +1024,3 @@ async def book_resource(
"start_at": booking.start_at.isoformat(), "start_at": booking.start_at.isoformat(),
"end_at": booking.end_at.isoformat(), "end_at": booking.end_at.isoformat(),
} }
from app.core.audit import log_audit
@@ -0,0 +1,62 @@
"""Public contract for the contacts plugin.
Exposes the symbols that other core modules and plugins need without
importing from internal modules directly (Block C7: dashboard counts).
"""
from __future__ import annotations
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.visibility import apply_visibility_filter
from app.models.contact import Contact
from app.plugins.builtins.contracts import get_contract_registry
class ContactsContract:
"""Public API surface for the contacts plugin."""
contract_name = "contacts"
@staticmethod
async def get_counts(
db: AsyncSession,
tenant_id: Any,
user_id: Any,
is_system_admin: bool = False,
) -> dict[str, int]:
"""Return visibility-filtered contact/company/person counts."""
queries = []
for type_filter in (None, "company", "person"):
query = select(func.count(Contact.id)).where(
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
)
if type_filter is not None:
query = query.where(Contact.type == type_filter)
query = await apply_visibility_filter(
db, query, "contact", Contact, user_id, tenant_id, is_system_admin
)
queries.append(query)
results = [((await db.execute(q)).scalar() or 0) for q in queries]
return {
"contacts": results[0],
"companies": results[1],
"persons": results[2],
"total": results[0],
}
@classmethod
def get_function(cls, name: str):
"""Return a callable exposed by this contract, or None if absent."""
return getattr(cls, name, None)
# ─── self-registration ───
_contract = ContactsContract()
get_contract_registry().register("contacts", _contract)
+46 -6
View File
@@ -9,27 +9,67 @@ from __future__ import annotations
import logging import logging
from app.plugins.base import BasePlugin from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest from app.plugins.manifest import (
FrontendDashboardWidget,
PluginManifest,
PluginRouteDef,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ContactsPlugin(BasePlugin): class ContactsPlugin(BasePlugin):
"""Contacts plugin — manages Contact entity lifecycle (models, permissions, restore, history). """Contacts plugin — owns the full Contact domain (Block B1).
Routes remain in app/routes/contacts.py as core routes, but entity lifecycle Routes (contacts, companies, contact folders, folder permissions) live in
(permissions, entity models, restore, history) is managed through on_activate/on_deactivate. this plugin and are mounted via manifest.routes with
require_active_plugin("contacts") protection. Entity lifecycle
(permissions, entity models, restore, history) is managed through
on_activate/on_deactivate like every other business plugin.
""" """
manifest = PluginManifest( manifest = PluginManifest(
name="contacts", name="contacts",
version="1.0.0", version="1.1.0",
display_name="Contacts", display_name="Contacts",
description="Core CRM contacts — persons and companies.", description="Core CRM contacts — persons and companies.",
dependencies=[], dependencies=[],
routes=[], # Routes are registered as core routes in main.py routes=[
PluginRouteDef(
path="/api/v1/contacts",
module="app.plugins.builtins.contacts.routes",
router_attr="router",
),
PluginRouteDef(
path="/api/v1/companies",
module="app.plugins.builtins.contacts.company_routes",
router_attr="router",
),
PluginRouteDef(
path="/api/v1/contact-folders",
module="app.plugins.builtins.contacts.folder_routes",
router_attr="router",
),
PluginRouteDef(
path="/api/v1/contact-folders",
module="app.plugins.builtins.contacts.folder_permission_routes",
router_attr="router",
),
],
events=[], events=[],
migrations=[], migrations=[],
dashboard_widgets=[
FrontendDashboardWidget(
id="recent_contacts",
label_key="dashboard.recentContacts",
label="Recent Contacts",
component="@/components/dashboard/RecentContactsWidget",
icon="Users",
order=10,
col_span=2,
permission="contacts:read",
),
],
permissions=[ permissions=[
"contacts:read", "contacts:read",
"contacts:write", "contacts:write",
+18 -2
View File
@@ -59,20 +59,31 @@ class ContractRegistry:
cls._instance = super().__new__(cls) cls._instance = super().__new__(cls)
cls._instance._contracts: dict[str, Any] = {} cls._instance._contracts: dict[str, Any] = {}
cls._instance._loaded: set[str] = set() cls._instance._loaded: set[str] = set()
cls._instance._unregistered: set[str] = set()
return cls._instance return cls._instance
# ─── registration ─── # ─── registration ───
def register(self, plugin_name: str, contract: Any) -> None: def register(self, plugin_name: str, contract: Any) -> None:
"""Register or replace a contract for a plugin.""" """Register or replace a contract for a plugin.
Clears the unregistered marker so a later deactivation can be
distinguished from a fresh lazy-load again (ARCH-014).
"""
self._unregistered.discard(plugin_name)
self._contracts[plugin_name] = contract self._contracts[plugin_name] = contract
self._loaded.add(plugin_name) self._loaded.add(plugin_name)
logger.debug("Contract registered for plugin '%s'", plugin_name) logger.debug("Contract registered for plugin '%s'", plugin_name)
def unregister(self, plugin_name: str) -> None: def unregister(self, plugin_name: str) -> None:
"""Remove a contract (e.g. when the plugin is deactivated).""" """Remove a contract (e.g. when the plugin is deactivated).
Marks the plugin as explicitly unregistered so later ``get_contract``
calls cannot resurrect the contract via lazy-loading (ARCH-014).
"""
self._contracts.pop(plugin_name, None) self._contracts.pop(plugin_name, None)
self._loaded.discard(plugin_name) self._loaded.discard(plugin_name)
self._unregistered.add(plugin_name)
# ─── lookup ─── # ─── lookup ───
@@ -85,6 +96,11 @@ class ContractRegistry:
if plugin_name in self._contracts: if plugin_name in self._contracts:
return self._contracts[plugin_name] return self._contracts[plugin_name]
# Explicitly unregistered (deactivated): never resurrect via
# lazy-loading (ARCH-014) — the deactivated contract must stay gone.
if plugin_name in self._unregistered:
return None
if plugin_name not in self._loaded: if plugin_name not in self._loaded:
self._try_lazy_load(plugin_name) self._try_lazy_load(plugin_name)
+4 -4
View File
@@ -4,16 +4,16 @@ from __future__ import annotations
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import Any
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, UniqueConstraint, Text from pgvector.sqlalchemy import Vector
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import TSVECTOR
from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin from app.core.db import Base, TenantMixin
from app.models.owned_mixin import OwnedMixin from app.models.owned_mixin import OwnedMixin
from pgvector.sqlalchemy import Vector
from sqlalchemy.dialects.postgresql import TSVECTOR
from typing import Any
class Folder(Base, TenantMixin, OwnedMixin): class Folder(Base, TenantMixin, OwnedMixin):
+20 -8
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
from app.plugins.base import BasePlugin from app.plugins.base import BasePlugin
from app.plugins.manifest import ( from app.plugins.manifest import (
FrontendDetailTab,
FrontendMenuItem, FrontendMenuItem,
FrontendPageRoute, FrontendPageRoute,
PluginManifest, PluginManifest,
@@ -38,22 +37,35 @@ class DmsPlugin(BasePlugin):
"dms:admin", "dms:admin",
], ],
menu_items=[ menu_items=[
FrontendMenuItem(label_key='nav.dms', label='Dateien', path='/dms', icon='FolderOpen', group='Dateien', order=40), FrontendMenuItem(label_key='nav.dms', label='Dateien', path='/dms', icon='FolderOpen', group='Dateien', order=40, permission='dms:read'),
FrontendMenuItem(label_key='nav.dms.trash', label='Papierkorb', path='/dms/trash', icon='Trash2', group='Dateien', order=41), FrontendMenuItem(label_key='nav.dms.trash', label='Papierkorb', path='/dms/trash', icon='Trash2', group='Dateien', order=41, permission='dms:read'),
], ],
page_routes=[ page_routes=[
FrontendPageRoute(path='/dms', component='@/pages/Dms', protected=True), FrontendPageRoute(path='/dms', component='@/pages/Dms', protected=True, permission='dms:read'),
FrontendPageRoute(path='/dms/trash', component='@/pages/DmsTrash', protected=True), FrontendPageRoute(path='/dms/trash', component='@/pages/DmsTrash', protected=True, permission='dms:read'),
],
detail_tabs=[
FrontendDetailTab(entity_type='contact', label_key='tabs.files', label='Dateien', component='@/components/contact/ContactFilesTab', icon='FolderOpen', order=40, permission='dms:read'),
], ],
# BUG (ghost component): ContactFilesTab does not exist in the
# frontend — tab removed until implemented (Block I-D).
detail_tabs=[],
author="LeoCRM Team", author="LeoCRM Team",
min_app_version="1.0.0", min_app_version="1.0.0",
hooks=["dms.before_upload"], hooks=["dms.before_upload"],
contract_version="1.0.0", contract_version="1.0.0",
) )
def get_entity_models(self) -> dict[str, type]:
"""Entity types owned by DMS for the permission resolver.
``file`` is a legacy alias still used by the permissions routes.
"""
from app.plugins.builtins.dms.models import File, Folder
return {
"dms_file": File,
"dms_folder": Folder,
"file": File,
}
async def on_activate(self, db, service_container, event_bus) -> None: async def on_activate(self, db, service_container, event_bus) -> None:
"""Activate plugin: register restore config + history hooks.""" """Activate plugin: register restore config + history hooks."""
await super().on_activate(db, service_container, event_bus) await super().on_activate(db, service_container, event_bus)
+4 -4
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from app.plugins.base import BasePlugin from app.plugins.base import BasePlugin
from app.plugins.manifest import FrontendDetailTab, PluginManifest, PluginRouteDef from app.plugins.manifest import PluginManifest, PluginRouteDef
class EntityLinksPlugin(BasePlugin): class EntityLinksPlugin(BasePlugin):
@@ -42,9 +42,9 @@ class EntityLinksPlugin(BasePlugin):
"entity_links:delete", "entity_links:delete",
], ],
is_core=True, is_core=True,
detail_tabs=[ # BUG (ghost component): ContactLinksTab does not exist in the
FrontendDetailTab(entity_type='contact', label_key='tabs.links', label='Verknüpfungen', component='@/components/contact/ContactLinksTab', icon='Link', order=60, permission='entity_links:read'), # frontend — tab removed until implemented (Block I-D).
], detail_tabs=[],
author="LeoCRM Team", author="LeoCRM Team",
min_app_version="1.0.0", min_app_version="1.0.0",
+4 -2
View File
@@ -40,6 +40,10 @@ class GraphRAGPlugin(BasePlugin):
async def on_activate(self, db, service_container, event_bus) -> None: async def on_activate(self, db, service_container, event_bus) -> None:
"""Activate plugin: register GraphRAG search provider.""" """Activate plugin: register GraphRAG search provider."""
# Base class first (event subscriptions etc.), then own registrations
# so a failure in our code cannot leave the base half-initialized (ARCH-037).
await super().on_activate(db, service_container, event_bus)
from app.plugins.builtins.graph_rag.provider import GraphRAGSearchProvider from app.plugins.builtins.graph_rag.provider import GraphRAGSearchProvider
from app.plugins.builtins.unified_search.contracts import get_search_registry from app.plugins.builtins.unified_search.contracts import get_search_registry
@@ -50,8 +54,6 @@ class GraphRAGPlugin(BasePlugin):
import logging import logging
logging.getLogger(__name__).exception("Failed to register GraphRAGSearchProvider") logging.getLogger(__name__).exception("Failed to register GraphRAGSearchProvider")
await super().on_activate(db, service_container, event_bus)
async def on_deactivate(self, db, service_container, event_bus) -> None: async def on_deactivate(self, db, service_container, event_bus) -> None:
"""Deactivate plugin: unregister search provider and contract.""" """Deactivate plugin: unregister search provider and contract."""
from app.plugins.builtins.unified_search.contracts import get_search_registry from app.plugins.builtins.unified_search.contracts import get_search_registry
+2 -1
View File
@@ -25,7 +25,8 @@ async def cleanup_knowledge_job(ctx: dict[str, Any]) -> None:
Runs daily. Keeps approved extractions indefinitely. Runs daily. Keeps approved extractions indefinitely.
Iterates per-tenant for RLS compliance. Iterates per-tenant for RLS compliance.
""" """
from sqlalchemy import text as sa_text, delete as sa_delete from sqlalchemy import delete as sa_delete
from sqlalchemy import text as sa_text
from app.core.db import get_worker_session_factory from app.core.db import get_worker_session_factory
+7 -2
View File
@@ -1,12 +1,17 @@
"""Knowledge extraction models — tracks LLM extractions and review queue.""" """Knowledge extraction models — tracks LLM extractions and review queue."""
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID from sqlalchemy import DateTime, Float, ForeignKey, Index, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin from app.core.db import Base, TenantMixin
class KnowledgeExtraction(Base, TenantMixin): class KnowledgeExtraction(Base, TenantMixin):
"""Tracks a single knowledge extraction run from a source (wiki, dms, mail, comm).""" """Tracks a single knowledge extraction run from a source (wiki, dms, mail, comm)."""
__tablename__ = "knowledge_extractions" __tablename__ = "knowledge_extractions"
+13 -7
View File
@@ -1,8 +1,10 @@
"""Knowledge plugin — LLM-based entity/relationship extraction, ask-knowledge, review queue.""" """Knowledge plugin — LLM-based entity/relationship extraction, ask-knowledge, review queue."""
from __future__ import annotations from __future__ import annotations
import logging import logging
import uuid import uuid
from typing import Any from typing import Any
from app.plugins.base import BasePlugin from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest, PluginRouteDef from app.plugins.manifest import PluginManifest, PluginRouteDef
@@ -25,7 +27,7 @@ class KnowledgePlugin(BasePlugin):
"""Register event-driven extraction hooks on activation.""" """Register event-driven extraction hooks on activation."""
await super().on_activate(db, service_container, event_bus) await super().on_activate(db, service_container, event_bus)
try: try:
from app.core.hooks import register_action from app.core.hooks import get_hook_registry
from app.plugins.builtins.knowledge.services import extract_knowledge from app.plugins.builtins.knowledge.services import extract_knowledge
async def on_wiki_create(*args, **kwargs): async def on_wiki_create(*args, **kwargs):
article_id = kwargs.get("article_id") or kwargs.get("entity_id") article_id = kwargs.get("article_id") or kwargs.get("entity_id")
@@ -41,7 +43,9 @@ class KnowledgePlugin(BasePlugin):
source_type="wiki_article", source_id=uuid.UUID(str(article_id)), source_type="wiki_article", source_id=uuid.UUID(str(article_id)),
source_title=title, source_text=content, source_title=title, source_text=content,
) )
register_action("wiki.article.created", on_wiki_create, priority=20, owner_tag="knowledge") get_hook_registry().register_action(
"wiki.article.created", on_wiki_create, priority=20, owner_tag="knowledge"
)
# H-DATA-LIFE: Re-extract when wiki article is updated # H-DATA-LIFE: Re-extract when wiki article is updated
async def on_wiki_update(*args, **kwargs): async def on_wiki_update(*args, **kwargs):
article_id = kwargs.get("article_id") or kwargs.get("entity_id") article_id = kwargs.get("article_id") or kwargs.get("entity_id")
@@ -57,7 +61,9 @@ class KnowledgePlugin(BasePlugin):
source_type="wiki_article", source_id=uuid.UUID(str(article_id)), source_type="wiki_article", source_id=uuid.UUID(str(article_id)),
source_title=title, source_text=content, source_title=title, source_text=content,
) )
register_action("wiki.article.updated", on_wiki_update, priority=20, owner_tag="knowledge") get_hook_registry().register_action(
"wiki.article.updated", on_wiki_update, priority=20, owner_tag="knowledge"
)
logger.info("Registered knowledge extraction hooks") logger.info("Registered knowledge extraction hooks")
except Exception: except Exception:
logger.exception("Failed to register knowledge hooks") logger.exception("Failed to register knowledge hooks")
@@ -74,8 +80,8 @@ class KnowledgePlugin(BasePlugin):
async def _ask_knowledge_handler(arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]: async def _ask_knowledge_handler(arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
"""Ask a knowledge question.""" """Ask a knowledge question."""
from app.plugins.builtins.knowledge.services import ask_knowledge
from app.core.db import get_worker_session_factory from app.core.db import get_worker_session_factory
from app.plugins.builtins.knowledge.services import ask_knowledge
question = arguments.get("question", "") question = arguments.get("question", "")
tenant_id = context.get("tenant_id") tenant_id = context.get("tenant_id")
if not question or not tenant_id: if not question or not tenant_id:
@@ -103,8 +109,8 @@ class KnowledgePlugin(BasePlugin):
async def _search_knowledge_handler(arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]: async def _search_knowledge_handler(arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
"""Search wiki articles via unified search.""" """Search wiki articles via unified search."""
from app.plugins.builtins.unified_search.provider_registry import get_search_registry
from app.core.db import get_worker_session_factory from app.core.db import get_worker_session_factory
from app.plugins.builtins.unified_search.provider_registry import get_search_registry
query = arguments.get("query", "") query = arguments.get("query", "")
tenant_id = context.get("tenant_id") tenant_id = context.get("tenant_id")
if not query or not tenant_id: if not query or not tenant_id:
@@ -149,8 +155,8 @@ class KnowledgePlugin(BasePlugin):
async def on_deactivate(self, db, service_container, event_bus) -> None: async def on_deactivate(self, db, service_container, event_bus) -> None:
"""Clean up on deactivation.""" """Clean up on deactivation."""
from app.core.hooks import unregister_actions_by_owner from app.core.hooks import get_hook_registry
unregister_actions_by_owner("knowledge") get_hook_registry().unregister_all_for_plugin("knowledge")
# Unregister knowledge agent tools from the core AI tool registry # Unregister knowledge agent tools from the core AI tool registry
try: try:
from app.ai.tool_registry import get_tool_registry from app.ai.tool_registry import get_tool_registry
+41 -24
View File
@@ -1,51 +1,70 @@
"""Knowledge plugin routes — extraction, ask, review queue.""" """Knowledge plugin routes — extraction, ask, review queue."""
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db from app.core.db import get_db
from app.deps import require_permission from app.deps import require_permission
from app.plugins.builtins.knowledge.services import extract_knowledge, ask_knowledge, get_review_queue, review_extraction from app.plugins.builtins.knowledge.services import (
ask_knowledge,
extract_knowledge,
get_review_queue,
review_extraction,
)
router = APIRouter(prefix="/api/v1/knowledge", tags=["knowledge"]) router = APIRouter(prefix="/api/v1/knowledge", tags=["knowledge"])
class KnowledgeExtractRequest(BaseModel):
"""Extract knowledge from a source."""
source_type: str = Field(..., min_length=1)
source_id: uuid.UUID
source_title: str | None = None
source_text: str = Field(..., min_length=1)
class KnowledgeAskRequest(BaseModel):
"""Ask a knowledge question."""
question: str = Field(..., min_length=1)
class KnowledgeReviewRequest(BaseModel):
"""Approve or reject a knowledge extraction."""
approved: bool = False
notes: str | None = None
@router.post("/extract") @router.post("/extract")
async def extract( async def extract(
body: dict, body: KnowledgeExtractRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("wiki:read")), current_user: dict = Depends(require_permission("wiki:read")),
): ):
"""Extract knowledge from a source (wiki article, dms file, mail, communication).""" """Extract knowledge from a source (wiki article, dms file, mail, communication)."""
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
source_type = body.get("source_type", "")
source_id = body.get("source_id", "")
source_title = body.get("source_title")
source_text = body.get("source_text", "")
if not source_type or not source_id or not source_text:
raise HTTPException(400, detail={"detail": "source_type, source_id, source_text required", "code": "missing_fields"})
try:
sid = uuid.UUID(source_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid source_id", "code": "invalid_id"}) from None
result = await extract_knowledge( result = await extract_knowledge(
db=db, tenant_id=tenant_id, source_type=source_type, source_id=sid, db=db, tenant_id=tenant_id, source_type=body.source_type, source_id=body.source_id,
source_title=source_title, source_text=source_text, source_title=body.source_title, source_text=body.source_text,
user_id=uuid.UUID(current_user["user_id"]) if current_user.get("user_id") else None, user_id=uuid.UUID(current_user["user_id"]) if current_user.get("user_id") else None,
) )
return result return result
@router.post("/ask") @router.post("/ask")
async def ask( async def ask(
body: dict, body: KnowledgeAskRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("wiki:read")), current_user: dict = Depends(require_permission("wiki:read")),
): ):
"""Ask a knowledge question — uses wiki + graph_rag as context.""" """Ask a knowledge question — uses wiki + graph_rag as context."""
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
question = body.get("question") or body.get("query", "") result = await ask_knowledge(db=db, tenant_id=tenant_id, question=body.question)
if not question:
raise HTTPException(400, detail={"detail": "question required", "code": "missing_question"})
result = await ask_knowledge(db=db, tenant_id=tenant_id, question=question)
return result return result
@router.get("/review") @router.get("/review")
@@ -62,22 +81,20 @@ async def review_queue(
@router.post("/review/{extraction_id}") @router.post("/review/{extraction_id}")
async def review( async def review(
extraction_id: str, extraction_id: str,
body: dict, body: KnowledgeReviewRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("wiki:write")), current_user: dict = Depends(require_permission("wiki:write")),
): ):
"""Approve or reject a knowledge extraction.""" """Approve or reject a knowledge extraction."""
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
approved = body.get("approved", False)
notes = body.get("notes")
try: try:
eid = uuid.UUID(extraction_id) eid = uuid.UUID(extraction_id)
except ValueError: except ValueError:
raise HTTPException(400, detail={"detail": "Invalid extraction_id", "code": "invalid_id"}) from None raise HTTPException(400, detail={"detail": "Invalid extraction_id", "code": "invalid_id"}) from None
result = await review_extraction( result = await review_extraction(
db=db, tenant_id=tenant_id, extraction_id=eid, approved=approved, db=db, tenant_id=tenant_id, extraction_id=eid, approved=body.approved,
user_id=uuid.UUID(current_user["user_id"]) if current_user.get("user_id") else None, user_id=uuid.UUID(current_user["user_id"]) if current_user.get("user_id") else None,
notes=notes, notes=body.notes,
) )
if "error" in result: if "error" in result:
raise HTTPException(404, detail={"detail": result["error"], "code": "not_found"}) raise HTTPException(404, detail={"detail": result["error"], "code": "not_found"})
+4 -1
View File
@@ -1,10 +1,13 @@
"""Knowledge extraction services — LLM-based entity/relationship extraction.""" """Knowledge extraction services — LLM-based entity/relationship extraction."""
from __future__ import annotations from __future__ import annotations
import logging import logging
import uuid import uuid
from typing import Any from typing import Any
from sqlalchemy import select, update
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.ai.llm_client import llm_complete from app.ai.llm_client import llm_complete
from app.plugins.builtins.knowledge.models import KnowledgeExtraction from app.plugins.builtins.knowledge.models import KnowledgeExtraction
@@ -0,0 +1,7 @@
-- Dual-path convergence (Gate B): add columns that Alembic migrations
-- 0120 (is_system) and 0140 (folder_id) add on the core path. Idempotent
-- so both install paths converge to the identical schema.
ALTER TABLE comm_conversations ADD COLUMN IF NOT EXISTS is_system BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS ix_comm_conversations_tenant_system ON comm_conversations(tenant_id, is_system);
ALTER TABLE comm_conversations ADD COLUMN IF NOT EXISTS folder_id UUID;
CREATE INDEX IF NOT EXISTS ix_comm_conversations_folder ON comm_conversations(folder_id);
+10 -3
View File
@@ -39,7 +39,7 @@ class KommunikationPlugin(BasePlugin):
"participant.left", "participant.left",
"reaction.added", "reaction.added",
], ],
migrations=["0001_initial.sql"], migrations=["0001_initial.sql", "0002_system_channel_folders.sql"],
permissions=[ permissions=[
"comm:read", "comm:read",
"comm:write", "comm:write",
@@ -50,10 +50,10 @@ class KommunikationPlugin(BasePlugin):
], ],
is_core=True, is_core=True,
menu_items=[ menu_items=[
FrontendMenuItem(label_key='nav.communication', label='Kommunikation', path='/communication', icon='MessageSquare', order=80), FrontendMenuItem(label_key='nav.communication', label='Kommunikation', path='/communication', icon='MessageSquare', order=80, permission='comm:read'),
], ],
page_routes=[ page_routes=[
FrontendPageRoute(path='/communication', component='@/pages/Communication', protected=True), FrontendPageRoute(path='/communication', component='@/pages/Communication', protected=True, permission='comm:read'),
], ],
author="LeoCRM Team", author="LeoCRM Team",
min_app_version="1.0.0", min_app_version="1.0.0",
@@ -136,6 +136,13 @@ class KommunikationPlugin(BasePlugin):
from app.plugins.builtins.contracts import get_contract_registry from app.plugins.builtins.contracts import get_contract_registry
get_contract_registry().unregister(self.manifest.name) get_contract_registry().unregister(self.manifest.name)
# Remove services registered in on_activate BEFORE super() so that
# event handlers being unsubscribed can no longer reach them (ARCH-033).
for service_name in ("comm_websocket", "comm_miniapps"):
if service_container.has(service_name):
service_container.remove(service_name)
logger.info("Removed '%s' from service container", service_name)
await super().on_deactivate(db, service_container, event_bus) await super().on_deactivate(db, service_container, event_bus)
logger.info("Kommunikation plugin deactivated") logger.info("Kommunikation plugin deactivated")
+3 -3
View File
@@ -4,7 +4,9 @@ from __future__ import annotations
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any
from pgvector.sqlalchemy import Vector
from sqlalchemy import ( from sqlalchemy import (
JSON, JSON,
Boolean, Boolean,
@@ -16,14 +18,12 @@ from sqlalchemy import (
Text, Text,
UniqueConstraint, UniqueConstraint,
) )
from sqlalchemy.dialects.postgresql import TSVECTOR
from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin from app.core.db import Base, TenantMixin
from app.models.owned_mixin import OwnedMixin from app.models.owned_mixin import OwnedMixin
from sqlalchemy.dialects.postgresql import TSVECTOR
from pgvector.sqlalchemy import Vector
from typing import Any
# --- Mail Accounts (F-MAIL-14, F-MAIL-18) --- # --- Mail Accounts (F-MAIL-14, F-MAIL-18) ---
+17 -8
View File
@@ -9,7 +9,6 @@ from typing import Any
from app.plugins.base import BasePlugin from app.plugins.base import BasePlugin
from app.plugins.manifest import ( from app.plugins.manifest import (
FrontendDetailTab,
FrontendMenuItem, FrontendMenuItem,
FrontendPageRoute, FrontendPageRoute,
FrontendSettingsPage, FrontendSettingsPage,
@@ -115,7 +114,11 @@ async def _auto_sync_loop() -> None:
class MailPlugin(BasePlugin): class MailPlugin(BasePlugin):
"""Mail plugin for email management: IMAP sync, SMTP send, threading, rules, PGP.""" """Mail plugin for email management: IMAP sync, SMTP send, threading, rules, PGP."""
_auto_sync_task: asyncio.Task | None = None def __init__(self) -> None:
super().__init__()
# Instance attribute: multiple plugin instances must not share the
# background task state (ARCH-036).
self._auto_sync_task: asyncio.Task | None = None
manifest = PluginManifest( manifest = PluginManifest(
name="mail", name="mail",
@@ -137,24 +140,30 @@ class MailPlugin(BasePlugin):
migrations=["0001_initial.sql", "0006_flag_type.sql", "0007_sync_queue.sql", "0008_sync_queue_deleted_at.sql", "0009_remove_mail_soft_delete.sql", "0010_add_deleted_at.sql"], migrations=["0001_initial.sql", "0006_flag_type.sql", "0007_sync_queue.sql", "0008_sync_queue_deleted_at.sql", "0009_remove_mail_soft_delete.sql", "0010_add_deleted_at.sql"],
permissions=["mail:read", "mail:send", "mail:config", "mail:share", "mail:write", "mail:delete"], permissions=["mail:read", "mail:send", "mail:config", "mail:share", "mail:write", "mail:delete"],
menu_items=[ menu_items=[
FrontendMenuItem(label_key='nav.mail', label='E-Mail', path='/mail', icon='Mail', order=30), FrontendMenuItem(label_key='nav.mail', label='E-Mail', path='/mail', icon='Mail', order=30, permission='mail:read'),
], ],
page_routes=[ page_routes=[
FrontendPageRoute(path='/mail', component='@/pages/Mail', protected=True), FrontendPageRoute(path='/mail', component='@/pages/Mail', protected=True, permission='mail:read'),
FrontendPageRoute(path='/mail/settings', component='@/pages/MailSettings', protected=True), FrontendPageRoute(path='/mail/settings', component='@/pages/MailSettings', protected=True, permission='mail:config'),
], ],
settings_pages=[ settings_pages=[
FrontendSettingsPage(path='mail', label_key='settings.mail', label='Mail', component='@/pages/MailSettings', icon='Mail', order=50), FrontendSettingsPage(path='mail', label_key='settings.mail', label='Mail', component='@/pages/MailSettings', icon='Mail', order=50),
], ],
detail_tabs=[ # BUG (ghost component): ContactMailTab does not exist in the
FrontendDetailTab(entity_type='contact', label_key='tabs.email', label='E-Mails', component='@/components/contact/ContactMailTab', icon='Mail', order=20, permission='mail:read'), # frontend — tab removed until implemented (Block I-D).
], detail_tabs=[],
author="LeoCRM Team", author="LeoCRM Team",
min_app_version="1.0.0", min_app_version="1.0.0",
hooks=["mail.before_send", "mail.after_send"], hooks=["mail.before_send", "mail.after_send"],
contract_version="1.0.0", contract_version="1.0.0",
) )
def get_entity_models(self) -> dict[str, type]:
"""Entity types owned by Mail for the permission resolver."""
from app.plugins.builtins.mail.models import MailAccount
return {"mail_account": MailAccount}
async def on_activate( async def on_activate(
self, db, service_container, event_bus self, db, service_container, event_bus
) -> None: ) -> None:
+2 -3
View File
@@ -1494,8 +1494,8 @@ async def create_event_from_mail(
try: try:
from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract
_cal = get_calendar_contract() _cal = get_calendar_contract()
calendar = _cal.calendar calendar = _cal.Calendar
calendar_entry = _cal.calendar_entry calendar_entry = _cal.CalendarEntry
except ImportError: except ImportError:
return {"created": False, "error": "calendar plugin not available"} return {"created": False, "error": "calendar plugin not available"}
cal_id = _parse_uuid(data.calendar_id, "calendar_id") cal_id = _parse_uuid(data.calendar_id, "calendar_id")
@@ -1862,4 +1862,3 @@ async def get_mail(
) )
return mail_to_response(mail, attachments=list(attachments), labels=list(label_assignments)) return mail_to_response(mail, attachments=list(attachments), labels=list(label_assignments))
from app.core.audit import log_audit
+2 -2
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import logging import logging
import uuid import uuid
from datetime import datetime from datetime import UTC, datetime
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
@@ -170,7 +170,7 @@ async def list_server_tools(
tools_resp = await client.list_tools() tools_resp = await client.list_tools()
# Update last_connected_at # Update last_connected_at
await db.execute( await db.execute(
update(McpServerConfigModel).where(McpServerConfigModel.id == sid).values(last_connected_at=datetime.utcnow()) update(McpServerConfigModel).where(McpServerConfigModel.id == sid).values(last_connected_at=datetime.now(UTC))
) )
await db.commit() await db.commit()
return tools_resp return tools_resp
+1 -1
View File
@@ -14,7 +14,7 @@ class McpServerPlugin(BasePlugin):
version="1.0.0", version="1.0.0",
display_name="MCP Server", display_name="MCP Server",
description="Exposes LeoCRM data (contacts, calendar, mail, DMS) to external MCP clients via API-token auth.", description="Exposes LeoCRM data (contacts, calendar, mail, DMS) to external MCP clients via API-token auth.",
dependencies=["permissions"], dependencies=["permissions", "unified_search"],
routes=[ routes=[
PluginRouteDef( PluginRouteDef(
path="/api/v1/mcp", path="/api/v1/mcp",
@@ -0,0 +1,5 @@
-- Dual-path convergence (Gate B): add the folder_id column that Alembic
-- migration 0139 adds on the core path. Idempotent so both install paths
-- converge to the identical schema.
ALTER TABLE report_templates ADD COLUMN IF NOT EXISTS folder_id UUID;
CREATE INDEX IF NOT EXISTS ix_report_templates_folder ON report_templates(folder_id);
@@ -24,13 +24,13 @@ class ReportGeneratorPlugin(BasePlugin):
), ),
], ],
events=["report.requested", "report.generated"], events=["report.requested", "report.generated"],
migrations=["0001_initial.sql"], migrations=["0001_initial.sql", "0002_reports_folder_id.sql"],
permissions=["reports:read", "reports:generate", "reports:manage_templates"], permissions=["reports:read", "reports:generate", "reports:manage_templates"],
menu_items=[ menu_items=[
FrontendMenuItem(label_key='nav.reports', label='Berichte', path='/reports', icon='BarChart3', order=70), FrontendMenuItem(label_key='nav.reports', label='Berichte', path='/reports', icon='BarChart3', order=70, permission='reports:read'),
], ],
page_routes=[ page_routes=[
FrontendPageRoute(path='/reports', component='@/pages/Reports', protected=True), FrontendPageRoute(path='/reports', component='@/pages/Reports', protected=True, permission='reports:read'),
], ],
author="LeoCRM Team", author="LeoCRM Team",
@@ -18,7 +18,8 @@ from sqlalchemy import (
Text, Text,
func, func,
) )
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin from app.core.db import Base, TenantMixin
@@ -24,7 +24,7 @@ class SelfImprovementPlugin(BasePlugin):
version="1.0.0", version="1.0.0",
display_name="Self-Improvement", display_name="Self-Improvement",
description="Controlled self-improvement loop: signals, patterns, proposals, evaluation, approval, activation, rollback, impact measurement.", description="Controlled self-improvement loop: signals, patterns, proposals, evaluation, approval, activation, rollback, impact measurement.",
dependencies=["permissions", "automation", "ai_proactive"], dependencies=["permissions", "automation", "ai_proactive", "kommunikation"],
routes=[ routes=[
PluginRouteDef( PluginRouteDef(
path="/api/v1/improvement", path="/api/v1/improvement",
+60 -52
View File
@@ -6,6 +6,7 @@ from datetime import datetime
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db from app.core.db import get_db
@@ -28,6 +29,46 @@ from app.plugins.builtins.self_improvement.services import (
router = APIRouter(prefix="/api/v1/improvement", tags=["improvement"]) router = APIRouter(prefix="/api/v1/improvement", tags=["improvement"])
class CollectSignalsRequest(BaseModel):
"""Collect improvement signals from existing system data."""
since: datetime | None = None
limit: int = Field(100, ge=1, le=500)
class DetectPatternsRequest(BaseModel):
"""Detect recurring patterns from collected signals."""
min_occurrences: int = Field(2, ge=2)
class CreateProposalRequest(BaseModel):
"""Create a new improvement proposal."""
pattern_id: uuid.UUID | None = None
title: str = Field(..., min_length=1)
description: str = ""
target_type: str = Field(..., min_length=1)
target_ref_id: uuid.UUID | None = None
target_name: str | None = None
proposed_config: dict[str, Any] = Field(default_factory=dict)
rationale: str = ""
expected_benefit: str = ""
risk_assessment: str = ""
class RequestApprovalRequest(BaseModel):
"""Request human approval for a proposal."""
approver_id: uuid.UUID | None = None
class RollbackProposalRequest(BaseModel):
"""Rollback an active proposal."""
reason: str = ""
# ────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────
# J-SIGNAL: Signal Collection # J-SIGNAL: Signal Collection
# NOTE: /signals/collect must be defined before /signals to avoid route conflicts # NOTE: /signals/collect must be defined before /signals to avoid route conflicts
@@ -35,21 +76,13 @@ router = APIRouter(prefix="/api/v1/improvement", tags=["improvement"])
@router.post("/signals/collect") @router.post("/signals/collect")
async def collect( async def collect(
body: dict, body: CollectSignalsRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("automation:read")), current_user: dict = Depends(require_permission("automation:read")),
): ):
"""Collect improvement signals from existing system data.""" """Collect improvement signals from existing system data."""
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
since_str = body.get("since") result = await collect_signals(db=db, tenant_id=tenant_id, since=body.since, limit=body.limit)
since = None
if since_str:
try:
since = datetime.fromisoformat(since_str)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid since format", "code": "invalid_date"}) from None
limit = min(body.get("limit", 100), 500)
result = await collect_signals(db=db, tenant_id=tenant_id, since=since, limit=limit)
await db.commit() await db.commit()
return result return result
@@ -73,14 +106,13 @@ async def signals(
@router.post("/patterns/detect") @router.post("/patterns/detect")
async def detect( async def detect(
body: dict, body: DetectPatternsRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("automation:read")), current_user: dict = Depends(require_permission("automation:read")),
): ):
"""Detect recurring patterns from collected signals.""" """Detect recurring patterns from collected signals."""
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
min_occurrences = body.get("min_occurrences", 2) result = await detect_patterns(db=db, tenant_id=tenant_id, min_occurrences=body.min_occurrences)
result = await detect_patterns(db=db, tenant_id=tenant_id, min_occurrences=min_occurrences)
await db.commit() await db.commit()
return result return result
@@ -103,7 +135,7 @@ async def patterns(
@router.post("/proposals") @router.post("/proposals")
async def create( async def create(
body: dict, body: CreateProposalRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("automation:write")), current_user: dict = Depends(require_permission("automation:write")),
): ):
@@ -111,35 +143,18 @@ async def create(
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) if current_user.get("user_id") else None user_id = uuid.UUID(current_user["user_id"]) if current_user.get("user_id") else None
pattern_id = None
if body.get("pattern_id"):
try:
pattern_id = uuid.UUID(body["pattern_id"])
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid pattern_id", "code": "invalid_id"}) from None
target_ref_id = None
if body.get("target_ref_id"):
try:
target_ref_id = uuid.UUID(body["target_ref_id"])
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid target_ref_id", "code": "invalid_id"}) from None
if not body.get("title") or not body.get("target_type"):
raise HTTPException(400, detail={"detail": "title and target_type required", "code": "missing_fields"})
proposal = await create_proposal( proposal = await create_proposal(
db=db, tenant_id=tenant_id, db=db, tenant_id=tenant_id,
pattern_id=pattern_id, pattern_id=body.pattern_id,
title=body["title"], title=body.title,
description=body.get("description", ""), description=body.description,
target_type=body["target_type"], target_type=body.target_type,
target_ref_id=target_ref_id, target_ref_id=body.target_ref_id,
target_name=body.get("target_name"), target_name=body.target_name,
proposed_config=body.get("proposed_config", {}), proposed_config=body.proposed_config,
rationale=body.get("rationale", ""), rationale=body.rationale,
expected_benefit=body.get("expected_benefit", ""), expected_benefit=body.expected_benefit,
risk_assessment=body.get("risk_assessment", ""), risk_assessment=body.risk_assessment,
user_id=user_id, user_id=user_id,
) )
await db.commit() await db.commit()
@@ -207,7 +222,7 @@ async def evaluate(
@router.post("/proposals/{proposal_id}/request-approval") @router.post("/proposals/{proposal_id}/request-approval")
async def req_approval( async def req_approval(
proposal_id: str, proposal_id: str,
body: dict, body: RequestApprovalRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("automation:write")), current_user: dict = Depends(require_permission("automation:write")),
): ):
@@ -218,13 +233,7 @@ async def req_approval(
pid = uuid.UUID(proposal_id) pid = uuid.UUID(proposal_id)
except ValueError: except ValueError:
raise HTTPException(400, detail={"detail": "Invalid proposal_id", "code": "invalid_id"}) from None raise HTTPException(400, detail={"detail": "Invalid proposal_id", "code": "invalid_id"}) from None
approver_id = None result = await request_approval(db=db, tenant_id=tenant_id, proposal_id=pid, requested_by=user_id, approver_id=body.approver_id)
if body.get("approver_id"):
try:
approver_id = uuid.UUID(body["approver_id"])
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid approver_id", "code": "invalid_id"}) from None
result = await request_approval(db=db, tenant_id=tenant_id, proposal_id=pid, requested_by=user_id, approver_id=approver_id)
if "error" in result: if "error" in result:
raise HTTPException(400, detail={"detail": result["error"], "code": "invalid_state"}) raise HTTPException(400, detail={"detail": result["error"], "code": "invalid_state"})
await db.commit() await db.commit()
@@ -258,7 +267,7 @@ async def activate(
@router.post("/proposals/{proposal_id}/rollback") @router.post("/proposals/{proposal_id}/rollback")
async def rollback( async def rollback(
proposal_id: str, proposal_id: str,
body: dict, body: RollbackProposalRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("automation:admin")), current_user: dict = Depends(require_permission("automation:admin")),
): ):
@@ -268,8 +277,7 @@ async def rollback(
pid = uuid.UUID(proposal_id) pid = uuid.UUID(proposal_id)
except ValueError: except ValueError:
raise HTTPException(400, detail={"detail": "Invalid proposal_id", "code": "invalid_id"}) from None raise HTTPException(400, detail={"detail": "Invalid proposal_id", "code": "invalid_id"}) from None
reason = body.get("reason", "") result = await rollback_proposal(db=db, tenant_id=tenant_id, proposal_id=pid, reason=body.reason)
result = await rollback_proposal(db=db, tenant_id=tenant_id, proposal_id=pid, reason=reason)
if "error" in result: if "error" in result:
raise HTTPException(400, detail={"detail": result["error"], "code": "invalid_state"}) raise HTTPException(400, detail={"detail": result["error"], "code": "invalid_state"})
await db.commit() await db.commit()
@@ -21,11 +21,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.ai.llm_client import llm_complete from app.ai.llm_client import llm_complete
from app.plugins.builtins.self_improvement.models import ( from app.plugins.builtins.self_improvement.models import (
ImpactMeasurement,
ImprovementPattern, ImprovementPattern,
ImprovementProposal, ImprovementProposal,
ImprovementSignal, ImprovementSignal,
ImpactMeasurement,
PROPOSAL_STATUSES,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -579,29 +578,30 @@ async def request_approval(
proposal.approval_request_id = req.id proposal.approval_request_id = req.id
await db.flush() await db.flush()
# Post to Communication via KommunikationContract # Post to Communication via KommunikationContract (ARCH-013: contract
# only — no direct plugin imports; skip cleanly when contract is absent)
try: try:
from app.plugins.builtins.contracts import get_contract from app.plugins.builtins.contracts import get_contract
# Find or create a system conversation for improvement proposals
_komm = get_contract("kommunikation") _komm = get_contract("kommunikation")
if not _komm or not hasattr(_komm, "create_plugin_room"): if not _komm or not hasattr(_komm, "create_plugin_room"):
from app.plugins.builtins.kommunikation.services import create_plugin_room logger.warning("kommunikation contract unavailable - skipping proposal notification")
room = await create_plugin_room( else:
db=db, tenant_id=tenant_id, user_id=requested_by, room = await _komm.create_plugin_room(
plugin_name="self_improvement", title="Improvement Proposals", db=db, tenant_id=tenant_id, user_id=requested_by,
participant_type="system", plugin_name="self_improvement", title="Improvement Proposals",
) participant_type="system",
conversation_id = room.get("conversation_id") if isinstance(room, dict) else None )
if not conversation_id and hasattr(room, "id"): conversation_id = room.get("conversation_id") if isinstance(room, dict) else None
conversation_id = room.id if not conversation_id and hasattr(room, "id"):
if conversation_id: conversation_id = room.id
await KommunikationContract.send_message( if conversation_id:
db=db, await _komm.send_message(
tenant_id=tenant_id, db=db,
conversation_id=conversation_id, tenant_id=tenant_id,
sender_id=requested_by, conversation_id=conversation_id,
sender_type="system", sender_id=requested_by,
content=f"Improvement Proposal: {proposal.title}", sender_type="system",
content=f"Improvement Proposal: {proposal.title}",
blocks=[ blocks=[
{ {
"type": "action_card", "type": "action_card",
@@ -0,0 +1,18 @@
-- Dual-path convergence (Gate B): apply the tag tree structure that
-- Alembic migration 0138 adds on the core path. Idempotent so both install
-- paths converge to the identical schema.
ALTER TABLE tags ADD COLUMN IF NOT EXISTS parent_id UUID;
CREATE INDEX IF NOT EXISTS ix_tags_parent ON tags(parent_id);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_tags_parent_id'
) THEN
ALTER TABLE tags ADD CONSTRAINT fk_tags_parent_id
FOREIGN KEY (parent_id) REFERENCES tags(id) ON DELETE SET NULL;
END IF;
END $$;
ALTER TABLE tags ADD COLUMN IF NOT EXISTS applicable_to JSONB;
ALTER TABLE tags ADD COLUMN IF NOT EXISTS icon VARCHAR(50);
+4 -4
View File
@@ -3,16 +3,16 @@
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from typing import Any
from pgvector.sqlalchemy import Vector
from sqlalchemy import ForeignKey, Index, String, UniqueConstraint from sqlalchemy import ForeignKey, Index, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin from app.core.db import Base, TenantMixin
from app.models.owned_mixin import OwnedMixin from app.models.owned_mixin import OwnedMixin
from sqlalchemy.dialects.postgresql import TSVECTOR
from pgvector.sqlalchemy import Vector
from typing import Any
class Tag(Base, TenantMixin, OwnedMixin): class Tag(Base, TenantMixin, OwnedMixin):
+5 -5
View File
@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from app.plugins.base import BasePlugin from app.plugins.base import BasePlugin
from app.plugins.manifest import FrontendDetailTab, PluginManifest, PluginRouteDef from app.plugins.manifest import PluginManifest, PluginRouteDef
class TagsPlugin(BasePlugin): class TagsPlugin(BasePlugin):
@@ -23,7 +23,7 @@ class TagsPlugin(BasePlugin):
), ),
], ],
events=[], events=[],
migrations=["0001_initial.sql", "0002_add_deleted_at.sql"], migrations=["0001_initial.sql", "0002_add_deleted_at.sql", "0003_tree_structure.sql"],
permissions=[ permissions=[
"tags:read", "tags:read",
"tags:write", "tags:write",
@@ -31,9 +31,9 @@ class TagsPlugin(BasePlugin):
"tags:admin", "tags:admin",
], ],
is_core=True, is_core=True,
detail_tabs=[ # BUG (ghost component): ContactTagsTab does not exist in the
FrontendDetailTab(entity_type='contact', label_key='tabs.tags', label='Tags', component='@/components/contact/ContactTagsTab', icon='Tag', order=50, permission='tags:read'), # frontend — tab removed until implemented (Block I-D).
], detail_tabs=[],
author="LeoCRM Team", author="LeoCRM Team",
min_app_version="1.0.0", min_app_version="1.0.0",
+1 -2
View File
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Body, Depends, HTTPException, Response, status
from sqlalchemy import delete, func, select from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit
from app.core.db import get_db from app.core.db import get_db
from app.core.visibility import apply_visibility_filter from app.core.visibility import apply_visibility_filter
from app.deps import get_current_user, require_permission from app.deps import get_current_user, require_permission
@@ -389,5 +390,3 @@ async def list_tag_entities(
} }
for a in assignments for a in assignments
] ]
from app.core.audit import log_audit
@@ -0,0 +1,31 @@
-- Dual-path convergence (Gate B): apply the unified task system that
-- Alembic migrations 0124 (columns/indexes/FK) and 0127 (drop legacy FK)
-- create on the core path. Idempotent so both install paths converge to
-- the identical schema. The end state has NO foreign key on contact_id.
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS assignee_type VARCHAR(20) NOT NULL DEFAULT 'user';
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS assignee_id UUID;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS entity_type VARCHAR(80);
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS entity_id UUID;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS creator_type VARCHAR(20) NOT NULL DEFAULT 'user';
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS creator_id UUID;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS parent_task_id UUID;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS depends_on JSONB NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS task_type VARCHAR(30) NOT NULL DEFAULT 'todo';
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS success_criteria JSONB;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS target_date TIMESTAMPTZ;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS progress INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS ix_tasks_tenant_entity ON tasks(tenant_id, entity_type, entity_id);
CREATE INDEX IF NOT EXISTS ix_tasks_tenant_assignee ON tasks(tenant_id, assignee_type, assignee_id);
CREATE INDEX IF NOT EXISTS ix_tasks_tenant_parent ON tasks(tenant_id, parent_task_id);
CREATE INDEX IF NOT EXISTS ix_tasks_tenant_type ON tasks(tenant_id, task_type);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_tasks_parent_task_id'
) THEN
ALTER TABLE tasks ADD CONSTRAINT fk_tasks_parent_task_id
FOREIGN KEY (parent_task_id) REFERENCES tasks(id) ON DELETE CASCADE;
END IF;
END $$;
+17 -2
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from app.plugins.base import BasePlugin from app.plugins.base import BasePlugin
from app.plugins.manifest import ( from app.plugins.manifest import (
CronJobContribution, CronJobContribution,
FrontendDashboardWidget,
FrontendMenuItem, FrontendMenuItem,
FrontendPageRoute, FrontendPageRoute,
PluginManifest, PluginManifest,
@@ -20,7 +21,7 @@ class TasksPlugin(BasePlugin):
version="1.0.0", version="1.0.0",
display_name="Tasks", display_name="Tasks",
description="Manage free tasks/activities with status, priority, due dates, and contact links.", description="Manage free tasks/activities with status, priority, due dates, and contact links.",
dependencies=["permissions"], dependencies=["permissions", "kommunikation"],
routes=[ routes=[
PluginRouteDef( PluginRouteDef(
path="/api/v1/tasks", path="/api/v1/tasks",
@@ -29,7 +30,19 @@ class TasksPlugin(BasePlugin):
), ),
], ],
events=[], events=[],
migrations=["0001_initial.sql"], migrations=["0001_initial.sql", "0002_unified_task_system.sql"],
dashboard_widgets=[
FrontendDashboardWidget(
id="tasks_summary",
label_key="dashboard.tasksSummary",
label="Tasks Summary",
component="@/components/dashboard/TasksSummaryWidget",
icon="CheckSquare",
order=20,
col_span=1,
permission="tasks:read",
),
],
permissions=[ permissions=[
"tasks:read", "tasks:read",
"tasks:write", "tasks:write",
@@ -43,6 +56,7 @@ class TasksPlugin(BasePlugin):
path="/tasks", path="/tasks",
icon="CheckSquare", icon="CheckSquare",
order=30, order=30,
permission="tasks:read",
), ),
], ],
page_routes=[ page_routes=[
@@ -51,6 +65,7 @@ class TasksPlugin(BasePlugin):
component="@/pages/Tasks", component="@/pages/Tasks",
protected=True, protected=True,
order=30, order=30,
permission="tasks:read",
), ),
], ],
cron_jobs=[ cron_jobs=[
+2 -2
View File
@@ -7,6 +7,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit
from app.core.db import get_db from app.core.db import get_db
from app.deps import get_current_user, require_permission from app.deps import get_current_user, require_permission
from app.plugins.builtins.tasks import services from app.plugins.builtins.tasks import services
@@ -126,6 +127,7 @@ async def delete_task(
): ):
"""Delete a task (soft-delete).""" """Delete a task (soft-delete)."""
tenant_id = uuid.UUID(current_user["tenant_id"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
tid = _parse_uuid(task_id, "task_id") tid = _parse_uuid(task_id, "task_id")
deleted = await services.delete_task(db, tenant_id, tid) deleted = await services.delete_task(db, tenant_id, tid)
if not deleted: if not deleted:
@@ -261,5 +263,3 @@ async def decompose_goal(
if result is None: if result is None:
raise HTTPException(404, detail={"detail": "Goal not found", "code": "not_found"}) raise HTTPException(404, detail={"detail": "Goal not found", "code": "not_found"})
return result return result
from app.core.audit import log_audit
-1
View File
@@ -7,7 +7,6 @@ from typing import Any
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
TASK_STATUSES = "^(open|in_progress|review|blocked|done|cancelled)$" TASK_STATUSES = "^(open|in_progress|review|blocked|done|cancelled)$"
TASK_TYPES = "^(todo|approval|follow_up|review|goal|milestone|agent_subtask)$" TASK_TYPES = "^(todo|approval|follow_up|review|goal|milestone|agent_subtask)$"
ASSIGNEE_TYPES = "^(user|agent|group)$" ASSIGNEE_TYPES = "^(user|agent|group)$"
+4 -6
View File
@@ -16,12 +16,11 @@ def _to_uuid(val: str | UUID | None) -> UUID | None:
return val return val
return uuid.UUID(str(val)) return uuid.UUID(str(val))
from sqlalchemy import func, select from sqlalchemy import func, select # noqa: E402 — after helper defs by design
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession # noqa: E402
from app.core.visibility import apply_visibility_filter
from app.plugins.builtins.tasks.models import Task
from app.core.visibility import apply_visibility_filter # noqa: E402
from app.plugins.builtins.tasks.models import Task # noqa: E402
# Lifecycle statuses in display order (Kanban columns) # Lifecycle statuses in display order (Kanban columns)
STATUS_ORDER = ["open", "in_progress", "review", "blocked", "done", "cancelled"] STATUS_ORDER = ["open", "in_progress", "review", "blocked", "done", "cancelled"]
@@ -307,7 +306,6 @@ async def create_task(
entity_id = contact_id entity_id = contact_id
# Don't store contact_id in FK column if it's just a polymorphic entity link # Don't store contact_id in FK column if it's just a polymorphic entity link
# — contact_id FK requires a real Contact row. Use entity_id instead. # — contact_id FK requires a real Contact row. Use entity_id instead.
contact_id_for_fk = _to_uuid(contact_id) if contact_id else None
# Resolve polymorphic creator. # Resolve polymorphic creator.
creator_type = data.get("creator_type", "user") creator_type = data.get("creator_type", "user")
+4 -4
View File
@@ -67,10 +67,10 @@ async def post_task_to_workstream(
try: try:
from app.plugins.builtins.contracts import get_contract from app.plugins.builtins.contracts import get_contract
_komm = get_contract("kommunikation") _komm = get_contract("kommunikation")
if _komm and hasattr(_komm, "send_message"): if not _komm or not hasattr(_komm, "send_message"):
send_message = _komm.send_message logger.warning("kommunikation contract unavailable - skipping workstream post for task %s", task.id)
else: return None
from app.plugins.builtins.kommunikation.services import send_message send_message = _komm.send_message
block_type = "goal_card" if task.task_type in ("goal", "milestone") else "task_card" block_type = "goal_card" if task.task_type in ("goal", "milestone") else "task_card"
block_data = _goal_card_data(task) if block_type == "goal_card" else _task_card_data(task) block_data = _goal_card_data(task) if block_type == "goal_card" else _task_card_data(task)
@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
from typing import Any
from app.plugins.builtins.contracts import get_contract_registry from app.plugins.builtins.contracts import get_contract_registry
from app.plugins.builtins.unified_search.base_provider import BaseSearchProvider from app.plugins.builtins.unified_search.base_provider import BaseSearchProvider
from app.plugins.builtins.unified_search.embedding import generate_embedding from app.plugins.builtins.unified_search.embedding import generate_embedding
@@ -50,6 +52,15 @@ class UnifiedSearchContract:
simple_search = staticmethod(simple_search) simple_search = staticmethod(simple_search)
BaseSearchProvider = BaseSearchProvider BaseSearchProvider = BaseSearchProvider
@staticmethod
async def auto_register_providers(db: Any) -> None:
"""Register providers for all active plugins (worker startup path)."""
from app.plugins.builtins.unified_search.provider_registry import (
auto_register_providers as _auto_register,
)
await _auto_register(db)
@classmethod @classmethod
def get_function(cls, name: str): def get_function(cls, name: str):
"""Return a callable exposed by this contract, or None if absent.""" """Return a callable exposed by this contract, or None if absent."""
@@ -50,8 +50,7 @@ class SearchIndexLog(Base, TenantMixin, OwnedMixin):
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending") status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
error_message: Mapped[str | None] = mapped_column(Text, nullable=True) error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
from pgvector.sqlalchemy import Vector # noqa: E402 from pgvector.sqlalchemy import Vector # noqa: E402 — optional dependency import after model defs
from app.models.owned_mixin import OwnedMixin
class DocumentChunk(Base, TenantMixin, OwnedMixin): class DocumentChunk(Base, TenantMixin, OwnedMixin):
+23 -8
View File
@@ -1,6 +1,8 @@
"""Wiki plugin - knowledge articles, categories, versioning.""" """Wiki plugin - knowledge articles, categories, versioning."""
from __future__ import annotations from __future__ import annotations
import logging import logging
from app.plugins.base import BasePlugin from app.plugins.base import BasePlugin
from app.plugins.manifest import FrontendMenuItem, FrontendPageRoute, PluginManifest, PluginRouteDef from app.plugins.manifest import FrontendMenuItem, FrontendPageRoute, PluginManifest, PluginRouteDef
@@ -13,13 +15,13 @@ class WikiPlugin(BasePlugin):
version="1.0.0", version="1.0.0",
display_name="Wiki", display_name="Wiki",
description="Knowledge articles with Markdown, categories, tags, versioning, entity links.", description="Knowledge articles with Markdown, categories, tags, versioning, entity links.",
dependencies=["permissions"], dependencies=["permissions", "unified_search"],
routes=[ routes=[
PluginRouteDef(path="/api/v1/wiki", module="app.plugins.builtins.wiki.routes", router_attr="router"), PluginRouteDef(path="/api/v1/wiki", module="app.plugins.builtins.wiki.routes", router_attr="router"),
], ],
permissions=["wiki:read", "wiki:write", "wiki:delete", "wiki:admin"], permissions=["wiki:read", "wiki:write", "wiki:delete", "wiki:admin"],
menu_items=[FrontendMenuItem(label_key="wiki.menu.wiki", label="Wiki", path="/wiki", icon="BookOpen")], menu_items=[FrontendMenuItem(label_key="wiki.menu.wiki", label="Wiki", path="/wiki", icon="BookOpen", permission="wiki:read")],
page_routes=[FrontendPageRoute(path="/wiki", component="@/pages/Wiki")], page_routes=[FrontendPageRoute(path="/wiki", component="@/pages/Wiki", permission="wiki:read")],
) )
async def on_activate(self, db, service_container, event_bus) -> None: async def on_activate(self, db, service_container, event_bus) -> None:
@@ -27,9 +29,11 @@ class WikiPlugin(BasePlugin):
try: try:
from app.plugins.builtins.contracts import get_contract from app.plugins.builtins.contracts import get_contract
search_contract = get_contract("unified_search") search_contract = get_contract("unified_search")
if search_contract and hasattr(search_contract, "register_provider"): if search_contract and hasattr(search_contract, "get_search_registry"):
from app.plugins.builtins.unified_search.providers.wiki_provider import WikiSearchProvider from app.plugins.builtins.unified_search.providers.wiki_provider import (
search_contract.register_provider(WikiSearchProvider()) WikiSearchProvider,
)
search_contract.get_search_registry().register(WikiSearchProvider())
logger.info("Registered WikiSearchProvider via contract") logger.info("Registered WikiSearchProvider via contract")
else: else:
logger.warning("unified_search contract not available, skipping WikiSearchProvider registration") logger.warning("unified_search contract not available, skipping WikiSearchProvider registration")
@@ -37,6 +41,17 @@ class WikiPlugin(BasePlugin):
logger.exception("Failed to register WikiSearchProvider") logger.exception("Failed to register WikiSearchProvider")
async def on_deactivate(self, db, service_container, event_bus) -> None: async def on_deactivate(self, db, service_container, event_bus) -> None:
from app.core.hooks import unregister_actions_by_owner from app.core.hooks import get_hook_registry
unregister_actions_by_owner("wiki") get_hook_registry().unregister_all_for_plugin("wiki")
# Unregister the search provider registered in on_activate (ARCH-012).
try:
from app.plugins.builtins.contracts import get_contract
search_contract = get_contract("unified_search")
if search_contract and hasattr(search_contract, "get_search_registry"):
search_contract.get_search_registry().unregister("wiki_article")
logger.info("Unregistered WikiSearchProvider via contract")
except Exception:
logger.exception("Failed to unregister WikiSearchProvider")
await super().on_deactivate(db, service_container, event_bus) await super().on_deactivate(db, service_container, event_bus)
+11 -4
View File
@@ -1,13 +1,20 @@
"""Wiki plugin routes — articles CRUD, categories, versioning.""" """Wiki plugin routes — articles CRUD, categories, versioning."""
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import get_current_user, require_permission
from app.plugins.builtins.wiki import services
from app.plugins.builtins.wiki.schemas import ArticleCreate, ArticleUpdate, CategoryCreate, CategoryUpdate
from app.core.audit import log_audit from app.core.audit import log_audit
from app.core.db import get_db
from app.deps import require_permission
from app.plugins.builtins.wiki import services
from app.plugins.builtins.wiki.schemas import (
ArticleCreate,
ArticleUpdate,
CategoryCreate,
)
router = APIRouter(prefix="/api/v1/wiki", tags=["wiki"]) router = APIRouter(prefix="/api/v1/wiki", tags=["wiki"])

Some files were not shown because too many files have changed in this diff Show More