58 Commits

Author SHA1 Message Date
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
165 changed files with 6811 additions and 8249 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
| Phase | Status | Start | Ende | Done | Partial | Not Done | Total | Anmerkung |
@@ -18,7 +18,26 @@ branch_labels = 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:
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("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"))
@@ -29,6 +48,9 @@ def upgrade() -> 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", "transfer_notice")
op.drop_column("ai_providers", "training_on_customer_data")
@@ -17,126 +17,145 @@ branch_labels = 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:
# 1. Add is_system column to comm_conversations
op.add_column(
"comm_conversations",
sa.Column("is_system", sa.Boolean(), nullable=False, server_default=sa.text("false")),
)
op.create_index(
"ix_comm_conversations_tenant_system",
"comm_conversations",
["tenant_id", "is_system"],
)
conn = op.get_bind()
if _table_exists(conn, "comm_conversations"):
# 1. Add is_system column to comm_conversations
op.add_column(
"comm_conversations",
sa.Column("is_system", sa.Boolean(), nullable=False, server_default=sa.text("false")),
)
op.create_index(
"ix_comm_conversations_tenant_system",
"comm_conversations",
["tenant_id", "is_system"],
)
# 2. Create system channel per tenant (for tenants that have notifications)
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)
SELECT
gen_random_uuid(),
n.tenant_id,
'System Channel',
false,
true,
false,
false,
true,
NULL,
'system',
'{}'::jsonb,
NOW(),
NOW()
FROM (
SELECT DISTINCT tenant_id FROM notifications WHERE deleted_at IS NULL
) n
WHERE NOT EXISTS (
SELECT 1 FROM comm_conversations cc
WHERE cc.tenant_id = n.tenant_id AND cc.is_system = true AND cc.deleted_at IS NULL
);
""")
# 2. Create system channel per tenant (for tenants that have notifications)
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)
SELECT
gen_random_uuid(),
n.tenant_id,
'System Channel',
false,
true,
false,
false,
true,
NULL,
'system',
'{}'::jsonb,
NOW(),
NOW()
FROM (
SELECT DISTINCT tenant_id FROM notifications WHERE deleted_at IS NULL
) n
WHERE NOT EXISTS (
SELECT 1 FROM comm_conversations cc
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
op.execute("""
INSERT INTO comm_messages (id, tenant_id, conversation_id, sender_id, sender_type, content, content_format, metadata, created_at, updated_at)
SELECT
gen_random_uuid(),
n.tenant_id,
sc.id,
n.user_id,
'system',
COALESCE(n.title, '') || CASE WHEN n.body IS NOT NULL THEN E'\n' || n.body ELSE '' END,
'text',
jsonb_build_object(
'notification_type', n.type,
'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,
'migrated_from_notification', true,
'original_notification_id', n.id::text
),
n.created_at,
COALESCE(n.read_at, n.created_at)
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
WHERE n.deleted_at IS NULL;
""")
# 3. Insert notifications as CommMessages in the system channel
op.execute("""
INSERT INTO comm_messages (id, tenant_id, conversation_id, sender_id, sender_type, content, content_format, metadata, created_at, updated_at)
SELECT
gen_random_uuid(),
n.tenant_id,
sc.id,
n.user_id,
'system',
COALESCE(n.title, '') || CASE WHEN n.body IS NOT NULL THEN E'\n' || n.body ELSE '' END,
'text',
jsonb_build_object(
'notification_type', n.type,
'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,
'migrated_from_notification', true,
'original_notification_id', n.id::text
),
n.created_at,
COALESCE(n.read_at, n.created_at)
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
WHERE n.deleted_at IS NULL;
""")
# 4. Insert text blocks for each migrated message
op.execute("""
INSERT INTO comm_message_blocks (id, tenant_id, message_id, block_type, block_data, sort_order)
SELECT
gen_random_uuid(),
cm.tenant_id,
cm.id,
'text',
jsonb_build_object('text', cm.content),
0
FROM comm_messages cm
WHERE cm.metadata->>'migrated_from_notification' = 'true';
""")
# 4. Insert text blocks for each migrated message
op.execute("""
INSERT INTO comm_message_blocks (id, tenant_id, message_id, block_type, block_data, sort_order)
SELECT
gen_random_uuid(),
cm.tenant_id,
cm.id,
'text',
jsonb_build_object('text', cm.content),
0
FROM comm_messages cm
WHERE cm.metadata->>'migrated_from_notification' = 'true';
""")
# 5. Insert action_card blocks for messages with entity references
op.execute("""
INSERT INTO comm_message_blocks (id, tenant_id, message_id, block_type, block_data, sort_order)
SELECT
gen_random_uuid(),
cm.tenant_id,
cm.id,
'action_card',
jsonb_build_object(
'label', 'Open',
'entity_type', (cm.metadata->'entity_ref'->>'entity_type'),
'entity_id', (cm.metadata->'entity_ref'->>'entity_id')
),
1
FROM comm_messages cm
WHERE cm.metadata->>'migrated_from_notification' = 'true'
AND cm.metadata->'entity_ref' IS NOT NULL;
""")
# 5. Insert action_card blocks for messages with entity references
op.execute("""
INSERT INTO comm_message_blocks (id, tenant_id, message_id, block_type, block_data, sort_order)
SELECT
gen_random_uuid(),
cm.tenant_id,
cm.id,
'action_card',
jsonb_build_object(
'label', 'Open',
'entity_type', (cm.metadata->'entity_ref'->>'entity_type'),
'entity_id', (cm.metadata->'entity_ref'->>'entity_id')
),
1
FROM comm_messages cm
WHERE cm.metadata->>'migrated_from_notification' = 'true'
AND cm.metadata->'entity_ref' IS NOT NULL;
""")
# 6. For read notifications, create CommMessageRead entries
op.execute("""
INSERT INTO comm_message_reads (id, tenant_id, conversation_id, user_id, last_read_msg_id, last_read_at)
SELECT
gen_random_uuid(),
cm.tenant_id,
cm.conversation_id,
cm.sender_id,
cm.id,
COALESCE(n.read_at, n.created_at)
FROM comm_messages cm
JOIN notifications n ON n.id::text = cm.metadata->>'original_notification_id'
WHERE cm.metadata->>'migrated_from_notification' = 'true'
AND n.read_at IS NOT NULL
AND n.deleted_at IS NULL;
""")
# 6. For read notifications, create CommMessageRead entries
op.execute("""
INSERT INTO comm_message_reads (id, tenant_id, conversation_id, user_id, last_read_msg_id, last_read_at)
SELECT
gen_random_uuid(),
cm.tenant_id,
cm.conversation_id,
cm.sender_id,
cm.id,
COALESCE(n.read_at, n.created_at)
FROM comm_messages cm
JOIN notifications n ON n.id::text = cm.metadata->>'original_notification_id'
WHERE cm.metadata->>'migrated_from_notification' = 'true'
AND n.read_at IS NOT 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("CREATE VIEW notifications_legacy AS SELECT * FROM notifications")
def downgrade() -> None:
conn = op.get_bind()
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_messages WHERE metadata->>'migrated_from_notification' = 'true'")
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
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:
conn = op.get_bind()
if not _table_exists(conn, "automation_agent_runs"):
return
op.create_table(
"automation_agent_run_steps",
sa.Column("id", PGUUID(as_uuid=True), primary_key=True),
@@ -18,7 +18,24 @@ branch_labels = 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:
conn = op.get_bind()
if not _table_exists(conn, "automation_agent_definitions"):
return
op.add_column(
"automation_agent_definitions",
sa.Column("temperature", sa.Float, nullable=False, server_default="0.3"),
@@ -20,7 +20,27 @@ branch_labels = 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:
conn = op.get_bind()
if not _table_exists(conn, "tasks"):
return
# ── 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_id", PGUUID(as_uuid=True), nullable=True))
@@ -12,6 +12,7 @@ Revises: 0126
"""
from alembic import op
import sqlalchemy as sa
revision = "0127"
down_revision = "0126"
@@ -19,7 +20,19 @@ branch_labels = 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:
conn = op.get_bind()
if not _table_exists(conn, "tasks"):
return
# Drop the FK constraint on tasks.contact_id
op.drop_constraint("tasks_contact_id_fkey", "tasks", type_="foreignkey")
@@ -9,6 +9,7 @@ Revises: 0128
"""
from alembic import op
import sqlalchemy as sa
revision = "0129"
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:
conn = op.get_bind()
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"CREATE POLICY tenant_isolation ON {table} "
@@ -37,6 +55,9 @@ def upgrade() -> None:
def downgrade() -> None:
conn = op.get_bind()
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"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY;")
@@ -10,6 +10,7 @@ Revises: 0135
Create Date: 2026-08-21
"""
from alembic import op
import sqlalchemy as sa
revision = "0136"
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:
conn = op.get_bind()
for table in TABLES_WITH_BAD_RLS:
if not _table_exists(conn, table):
continue
# Drop old policy with app.tenant_id
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table};")
# Create new policy with app.current_tenant_id
@@ -41,7 +59,10 @@ def upgrade() -> None:
def downgrade() -> None:
conn = op.get_bind()
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"CREATE POLICY tenant_isolation ON {table} "
@@ -19,7 +19,25 @@ branch_labels = 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:
conn = op.get_bind()
if not _table_exists(conn, "tags"):
return
# parent_id for tree structure (self-referencing FK)
op.add_column("tags", sa.Column("parent_id", PGUUID(as_uuid=True), nullable=True))
op.create_foreign_key(
@@ -35,6 +53,9 @@ def upgrade() -> None:
def downgrade() -> None:
conn = op.get_bind()
if not _table_exists(conn, "tags"):
return
op.drop_column("tags", "icon")
op.drop_column("tags", "applicable_to")
op.drop_index("ix_tags_parent", table_name="tags")
@@ -18,11 +18,31 @@ branch_labels = 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:
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.create_index("ix_report_templates_folder", "report_templates", ["folder_id"])
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_column("report_templates", "folder_id")
@@ -18,11 +18,31 @@ branch_labels = 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:
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.create_index("ix_comm_conversations_folder", "comm_conversations", ["folder_id"])
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_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 uuid
from dataclasses import dataclass, field
from dataclasses import dataclass
from typing import Any
from sqlalchemy import select
-2
View File
@@ -25,8 +25,6 @@ import logging
import uuid
from typing import TYPE_CHECKING, Any
from app.core.sensitive_data import sanitize_dict
if TYPE_CHECKING:
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.core.sensitive_data import (
SENSITIVE_FIELDS,
filter_for_llm_context,
get_data_class_for_field,
)
logger = logging.getLogger(__name__)
-1
View File
@@ -6,7 +6,6 @@ import uuid
from dataclasses import dataclass, field
from typing import Any
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
EXTRACTION_TRIGGERS = {
"mail.received",
"dms.file_uploaded",
+1 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
from dataclasses import dataclass
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_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_monthly_budget_usd: float = 100.0 # per-tenant monthly LLM budget
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
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]:
"""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)
def subscribe(self, event_name: str, handler: EventHandler) -> None:
"""Subscribe a handler to an event."""
self._handlers[event_name].append(handler)
"""Subscribe a handler to an event.
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:
"""Unsubscribe a handler from an event."""
+96
View File
@@ -3,6 +3,8 @@
from __future__ import annotations
import logging
import re
import uuid as uuid_mod
from fastapi import Request, status
from starlette.middleware.base import BaseHTTPMiddleware
@@ -137,3 +139,97 @@ class CSRFMiddleware(BaseHTTPMiddleware):
pass
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": "currencies:read", "label": "Currencies: Read", "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:write", "label": "Import/Export: Write", "category": "core", "module": "import_export"},
{"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."""
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:
"""Initialize core services."""
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]:
"""Read metadata of a stored file without loading its content.
Works with the *local* storage backend. For S3, use the S3 client
``stat_object`` API directly.
Works with the *local* storage backend without touching the event
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
----------
@@ -530,14 +557,15 @@ def get_file_metadata(path: str) -> dict[str, Any]:
"modified": stat.st_mtime,
"exists": True,
}
# S3 or other backends — fall back to exists() check
import asyncio as _asyncio
loop = _asyncio.new_event_loop()
# Async-only backend outside a running loop is fine; inside one we
# must never build a nested event loop.
try:
exists = loop.run_until_complete(backend.exists(path))
if not exists:
return {"size": None, "modified": None, "exists": False}
return {"size": None, "modified": None, "exists": True}
finally:
loop.close()
asyncio.get_running_loop()
except RuntimeError:
pass
else:
raise RuntimeError(
"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."""
from app.core.db import get_session_factory
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")
AutomationDefinition = automation_contract.Automation # noqa: N806
if automation_contract is None:
logger.debug("Automation plugin not available — trigger skipped")
return
AutomationDefinition = automation_contract.Automation # noqa: N806
factory = get_session_factory()
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:
factory = async_session
async with factory() as db:
# auto_register_providers is not exposed via contract yet;
# 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)
await search_contract.auto_register_providers(db)
logger.info("Search providers registered for worker")
else:
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.
Iterates per-tenant for RLS compliance.
"""
from sqlalchemy import text as sa_text, delete as sa_delete
from datetime import datetime, timedelta
from datetime import UTC, 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.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_ids = [row[0] for row in tenant_result]
cutoff = datetime.utcnow() - timedelta(days=365)
cutoff = datetime.now(UTC) - timedelta(days=365)
total_deleted = 0
for tenant_id in tenant_ids:
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.
Default retention: 90 days in trash before permanent deletion.
"""
from sqlalchemy import text as sa_text, delete as sa_delete
from datetime import datetime, timedelta
from datetime import UTC, 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.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_ids = [row[0] for row in tenant_result]
cutoff = datetime.utcnow() - timedelta(days=90)
cutoff = datetime.now(UTC) - timedelta(days=90)
total_deleted = 0
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__)
# Known write-permission modules — used by require_write() to check
# specific permissions instead of broad wildcards like *:write
# Legacy fallback list — used by require_write() only when the permission
# registry is not initialized. The live source of truth is generated from
# the registry (see _get_write_permissions, ARCH-022).
_WRITE_PERMISSIONS = [
"users: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:
"""FastAPI dependency for Redis client."""
return get_redis()
@@ -261,7 +286,7 @@ async def require_write(
# Check via permission system for specific write permissions
from app.core.permissions import check_permission
for perm in _WRITE_PERMISSIONS:
for perm in _get_write_permissions():
if check_permission(current_user, perm):
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.core.db import close_engine, get_engine # noqa: E402
from app.core.error_codes import ApiError, build_error_response # noqa: E402
from app.core.middleware import CSRFMiddleware, SecurityHeadersMiddleware # noqa: E402
from app.core.error_codes import ERROR_CODES, ApiError, build_error_response # 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.rate_limit import GeneralRateLimitMiddleware # noqa: E402
from app.core.resilience import CircuitBreakerMiddleware # noqa: E402
@@ -36,16 +40,14 @@ from app.routes import ( # noqa: E402
attachments,
audit,
auth,
compliance,
backups,
bank_accounts,
contact_folder_permissions,
contact_folders,
contacts,
compliance,
currencies,
custom_field_definitions,
custom_fields,
dashboard,
delegations,
entity_history,
entity_permissions,
errors,
@@ -59,7 +61,6 @@ from app.routes import ( # noqa: E402
owner_transfer,
permission_templates,
plugins,
delegations,
policies,
roles,
saved_filters,
@@ -284,22 +285,23 @@ async def lifespan(app: FastAPI):
logger.info(f"Plugin {name} is inactive — skipping activation")
continue
# Activate plugin with a FRESH session per plugin to avoid RLS state leakage
# RLS fail-closed requires app.current_tenant_id for tenant-table writes.
# Plugin activation may fail on duplicate cron job inserts — this is harmless
# since cron jobs already exist from previous startups.
# Activate plugin ONCE per process (ARCH-002 fix): a fresh session with
# the first tenant's RLS context satisfies fail-closed RLS for any
# tenant-table writes during activation. Plugins that need per-tenant
# 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
for tenant_id in all_tenant_ids:
if all_tenant_ids:
try:
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_db.flush()
await plugin_db.commit()
plugin_activated = True
except Exception as exc:
logger.warning(f"[STARTUP] Plugin {name} activation issue for tenant {tenant_id}: {exc}")
break
logger.warning(f"[STARTUP] Plugin {name} activation issue: {exc}")
if plugin_activated:
plugin_record.status = "active"
@@ -474,6 +476,7 @@ def create_app() -> FastAPI:
)
app.add_middleware(CSRFMiddleware)
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(AuditMiddleware)
app.add_middleware(GeneralRateLimitMiddleware)
app.add_middleware(RequestLoggingMiddleware)
app.add_middleware(CircuitBreakerMiddleware)
@@ -516,11 +519,26 @@ def create_app() -> FastAPI:
504: "service_timeout",
}
code = status_to_code.get(exc.status_code, "internal_error" if exc.status_code >= 500 else "validation_error")
body = build_error_response(
code=code,
detail=str(exc.detail) if exc.detail else None,
trace_id=trace_id,
)
# Structured detail passthrough (AGENTS.md): when a route raises
# HTTPException with a dict detail containing a machine-readable ``code``,
# preserve the structured shape instead of stringifying it.
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)
if 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(tenants.router)
app.include_router(notifications.router)
from app.routes.companies import router as companies_router
app.include_router(companies_router)
app.include_router(contacts.router)
app.include_router(contact_folders.router)
app.include_router(contact_folder_permissions.router)
# NOTE: contacts/companies/contact-folders routes are plugin-owned now
# (Block B1) and mounted via the manifest.routes mechanism below with
# require_active_plugin("contacts") protection.
app.include_router(entity_permissions.router)
app.include_router(dashboard.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.backup import Backup
from app.models.bank_account import BankAccount
from app.models.consumer_inbox import ConsumerInbox
from app.models.compliance import ComplianceIncident
from app.models.consumer_inbox import ConsumerInbox
from app.models.contact import Contact, ContactPerson
from app.models.contact_folder import ContactFolder
from app.models.contact_merge import ContactMergeHistory
+1 -2
View File
@@ -11,13 +11,12 @@ from datetime import datetime
from typing import Any
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.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
from app.models.owned_mixin import OwnedMixin
from sqlalchemy.dialects.postgresql import TSVECTOR
# Re-export EntityHistory as DeletionLog for backward compatibility.
# 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
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 UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -77,3 +77,49 @@ class EventOutbox(Base):
failed_at: Mapped[datetime | None] = mapped_column(
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")
# Backup configuration
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_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
# 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 app.core.db import Base, SoftDeleteMixin, TimestampMixin
from app.models.owned_mixin import OwnedMixin
class User(Base, TimestampMixin, SoftDeleteMixin):
+9
View File
@@ -97,6 +97,15 @@ class BasePlugin(ABC):
"""
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 ───
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.models.owned_mixin import OwnedMixin
from pgvector.sqlalchemy import Vector
class AgentMemory(Base, TenantMixin, OwnedMixin):
@@ -22,6 +22,7 @@ from app.plugins.builtins.ai_assistant.schemas import (
ExternalAgentRequest,
ExternalAgentResponse,
)
from app.plugins.builtins.ai_assistant.services import stream_chat_comm as stream_chat
logger = logging.getLogger(__name__)
@@ -120,13 +121,18 @@ async def run_agent_external(
}
# Run the agent via streaming chat (non-streaming mode)
from app.plugins.builtins.ai_assistant.services import stream_chat_comm
full_response = ""
async with get_db() as stream_db:
await set_tenant_context(stream_db, tenant_id)
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":
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=[],
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=[
"ai:read",
"ai:write",
@@ -52,10 +52,10 @@ class AIAssistantPlugin(BasePlugin):
],
is_core=True,
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=[
FrontendPageRoute(path='/ai-assistant', component='@/pages/AIAssistant', protected=True),
FrontendPageRoute(path='/ai-assistant', component='@/pages/AIAssistant', protected=True, permission='ai:read'),
],
settings_pages=[
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
_cal = get_calendar_contract()
calendar_entry = _cal.calendar_entry
calendar_entry_link = _cal.calendar_entry_link
calendar_entry = _cal.CalendarEntry
calendar_entry_link = _cal.CalendarEntryLink
db, tenant_id, _ = await _get_db_and_tenant(context)
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)
try:
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.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")
if komm:
factory = get_worker_session_factory()
@@ -274,8 +276,8 @@ async def gather_context(
# Upcoming calendar events
from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract
_cal = get_calendar_contract()
calendar_entry = _cal.calendar_entry
calendar_entry_link = _cal.calendar_entry_link
calendar_entry = _cal.CalendarEntry
calendar_entry_link = _cal.CalendarEntryLink
now = datetime.now(UTC)
event_result = await db.execute(
@@ -389,8 +391,8 @@ async def gather_context(
# Upcoming events
from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract
_cal = get_calendar_contract()
calendar_entry = _cal.calendar_entry
calendar_entry_link = _cal.calendar_entry_link
calendar_entry = _cal.CalendarEntry
calendar_entry_link = _cal.CalendarEntryLink
now = datetime.now(UTC)
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
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"):
service_container.remove("ai_ui_control_ws")
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
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
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.
"""
from fastapi.responses import StreamingResponse
from app.ai.agent_stream import stream_react_loop
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
import logging
import uuid
from datetime import UTC, datetime
from typing import Any
@@ -37,12 +38,12 @@ async def run_agent(
3. Infinite loop: same tool 5x consecutively (handled in ReAct loop)
4. Budget limit: cumulative cost_usd
"""
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
from app.plugins.builtins.automation.models import (
AgentDefinition,
AgentRun,
AgentRunStep,
)
from app.plugins.builtins.ai_assistant.contracts import get_tool_registry
factory = get_session_factory()
@@ -110,9 +111,9 @@ async def run_agent(
try:
from app.plugins.builtins.contracts import get_contract
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 app.plugins.builtins.mail.models import Mail
Mail = mail_contract.Mail
async with factory() as db:
mail_q = await db.execute(
_select(Mail)
@@ -167,7 +168,7 @@ async def run_agent(
perm_ctx = await resolve_agent_permissions(
db=db,
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,
)
@@ -379,23 +380,16 @@ async def run_agent(
komm = get_contract_registry().get("kommunikation")
if komm:
async with factory() as db:
# Find or create agent conversation room
from app.plugins.builtins.contracts import get_contract as _get_contract
_komm_contract = _get_contract("kommunikation")
from app.plugins.builtins.kommunikation.models import CommConversation
from sqlalchemy import select as sa_select
# Find or create agent conversation room via contract
# (find_locked_room_id matches create_plugin_room semantics)
room_title = f"Agent: {agent.name}"
existing = await db.execute(
sa_select(CommConversation).where(
CommConversation.tenant_id == agent.tenant_id,
CommConversation.title == room_title,
CommConversation.is_locked.is_(True),
CommConversation.locked_by == "automation",
CommConversation.deleted_at.is_(None),
)
conv_id = await komm.find_locked_room_id(
db=db,
tenant_id=agent.tenant_id,
plugin_name="automation",
title=room_title,
)
conv = existing.scalar_one_or_none()
if not conv:
if not conv_id:
room = await komm.create_plugin_room(
db=db,
tenant_id=agent.tenant_id,
@@ -405,8 +399,6 @@ async def run_agent(
participant_type="agent",
)
conv_id = uuid.UUID(room["conversation_id"])
else:
conv_id = conv.id
# Post result as message with action_card block
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 "
"automations with conditions and actions, schedule cron jobs, and track execution logs."
),
dependencies=[],
dependencies=["mail"],
routes=[
PluginRouteDef(
path="/api/v1/automation",
@@ -62,7 +62,7 @@ class AutomationPlugin(BasePlugin):
"mail.received",
"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=[
"automation:read",
"automation:write",
@@ -82,6 +82,7 @@ class AutomationPlugin(BasePlugin):
path="/workflows",
icon="Workflow",
order=52,
permission="automation:read",
),
FrontendMenuItem(
label_key="nav.importExport",
@@ -89,6 +90,7 @@ class AutomationPlugin(BasePlugin):
path="/import-export",
icon="ArrowUpDown",
order=53,
permission="import_export:read",
),
FrontendMenuItem(
label_key="nav.dedupMerge",
@@ -96,6 +98,7 @@ class AutomationPlugin(BasePlugin):
path="/contacts/dedup",
icon="Copy",
order=54,
permission="contacts:read",
),
FrontendMenuItem(
label_key="nav.tags",
@@ -103,6 +106,7 @@ class AutomationPlugin(BasePlugin):
path="/tags",
icon="Tag",
order=55,
permission="tags:read",
),
FrontendMenuItem(
label_key="nav.activity",
@@ -110,6 +114,7 @@ class AutomationPlugin(BasePlugin):
path="/activity",
icon="Activity",
order=56,
permission="contacts:read",
),
],
page_routes=[
@@ -117,21 +122,25 @@ class AutomationPlugin(BasePlugin):
path="/automation",
component="@/pages/AutomationDashboard",
order=50,
permission="automation:read",
),
FrontendPageRoute(
path="/agents",
component="@/pages/AgentDashboard",
order=51,
permission="agents:read",
),
FrontendPageRoute(
path="/workflows",
component="@/pages/Workflows",
order=52,
permission="automation:read",
),
FrontendPageRoute(
path="/import-export",
component="@/pages/ImportExport",
order=53,
permission="import_export:read",
),
],
settings_pages=[
@@ -239,18 +248,25 @@ class AutomationPlugin(BasePlugin):
logger.exception("Failed to register own cron jobs")
# Register pre-built agents in DB (if not already present)
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
# Get first tenant + admin user for seeding
from app.models.user import User
from app.models.tenant import Tenant
tenant_result = await db.execute(sa_select(Tenant).limit(1))
tenant = tenant_result.scalar_one_or_none()
# Get system tenant + admin user for seeding (ARCH-043:
# deterministic slug lookup instead of arbitrary first row)
from app.core.db import get_system_tenant
from app.models.user import User, UserTenant
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:
user_result = await db.execute(
sa_select(User)
@@ -288,15 +304,14 @@ class AutomationPlugin(BasePlugin):
def _register_workflow_agent_tools(self) -> None:
"""Register I-AW agent tools for starting and inspecting workflows."""
import uuid
from typing import Any
from app.ai.tool_registry import get_tool_registry
registry = get_tool_registry()
async def _start_workflow_handler(arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
"""Start a workflow by ID."""
from app.services.workflow_service import create_instance
from app.core.db import get_worker_session_factory
from app.services.workflow_service import create_instance
workflow_id = arguments.get("workflow_id", "")
tenant_id = context.get("tenant_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]:
"""Check the status of a workflow instance."""
from sqlalchemy import select
from app.models.workflow import WorkflowInstance
from app.core.db import get_worker_session_factory
from app.models.workflow import WorkflowInstance
instance_id = arguments.get("instance_id", "")
tenant_id = context.get("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 sqlalchemy import select
# Get default tenant_id from the first tenant in the DB
from app.models.tenant import Tenant
# Get system tenant for contributions (ARCH-043: deterministic slug
# 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.services import (
AgentService,
AutomationService,
CronJobService,
)
tenant_result = await db.execute(select(Tenant).limit(1))
tenant = tenant_result.scalar_one_or_none()
tenant = await get_system_tenant(db)
default_tenant_id = tenant.id if tenant else None
if default_tenant_id is None:
logger.warning("No tenant found — skipping plugin contributions registration")
@@ -3,7 +3,9 @@
Enriches contact data by searching for related information.
"""
from __future__ import annotations
import uuid
from app.plugins.builtins.automation.models import AgentDefinition
CONTACT_ENRICHMENT_SYSTEM_PROMPT = """You are a Contact Enrichment Agent for a CRM system.
@@ -3,7 +3,9 @@
Sorts and prioritizes incoming emails automatically.
"""
from __future__ import annotations
import uuid
from app.plugins.builtins.automation.models import AgentDefinition
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.
"""
from __future__ import annotations
import uuid
from app.plugins.builtins.automation.models import AgentDefinition
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.
"""
from __future__ import annotations
import uuid
from app.plugins.builtins.automation.models import AgentDefinition
REPORT_SYSTEM_PROMPT = """You are a Report Agent for a CRM system.
@@ -1,60 +1,132 @@
"""Tests for the Automation & Agents plugin.
Uses pytest with async fixtures. Tests use SQLite in-memory database
since PostgreSQL may not be available in the dev container.
Uses pytest with async fixtures against an ephemeral PostgreSQL database
(SQLITE-001 fix) matches the project convention and exercises the real
PGUUID/JSONB column types.
"""
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
from collections.abc import AsyncGenerator
from datetime import UTC, datetime, timedelta
import pytest
import pytest_asyncio
from sqlalchemy import text
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.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,
AutomationRun,
)
from app.plugins.builtins.automation.services import (
from app.plugins.builtins.automation.services import ( # noqa: E402 — after dynamic plugin-model discovery
AgentService,
AutomationService,
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 ───
@pytest_asyncio.fixture
async def db() -> AsyncGenerator[AsyncSession, None]:
"""Create an in-memory SQLite database for testing."""
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
echo=False,
)
"""Create an ephemeral PostgreSQL database for this test run."""
db_url = _ephemeral_db_url()
admin_url = db_url.rsplit("/", 1)[0] + "/postgres"
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:
await conn.run_sync(Base.metadata.create_all)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
yield session
await engine.dispose()
try:
async with async_session() as session:
yield session
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
def tenant_id() -> uuid.UUID:
return uuid.uuid4()
@pytest_asyncio.fixture
async def tenant_id(db: AsyncSession) -> uuid.UUID:
"""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
def user_id() -> uuid.UUID:
return uuid.uuid4()
@pytest_asyncio.fixture
async def user_id(db: AsyncSession, tenant_id: uuid.UUID) -> uuid.UUID:
"""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 ───
@@ -425,11 +497,17 @@ class TestDryRunMode:
assert automation.dry_run is True
@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."""
# 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(
tenant_id=tenant_id,
automation_id=uuid.uuid4(),
automation_id=automation.id,
status="dry_run",
started_at=datetime.now(UTC),
dry_run=True,
@@ -475,7 +553,9 @@ class TestRateLimiting:
)
recent_runs = result.scalar() or 0
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 ───
@@ -512,7 +592,8 @@ class TestBudgetLimit:
.where(AgentRun.agent_id == agent.id)
)
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
+2 -3
View File
@@ -6,6 +6,7 @@ import uuid
from datetime import datetime
from typing import Any
from pgvector.sqlalchemy import Vector
from sqlalchemy import (
Boolean,
DateTime,
@@ -13,14 +14,12 @@ from sqlalchemy import (
Index,
String,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
from app.models.owned_mixin import OwnedMixin
from sqlalchemy.dialects.postgresql import TSVECTOR
from pgvector.sqlalchemy import Vector
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.manifest import (
FrontendDetailTab,
FrontendDashboardWidget,
FrontendMenuItem,
FrontendPageRoute,
PluginManifest,
@@ -40,6 +40,18 @@ class CalendarPlugin(BasePlugin):
],
events=[],
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=[
"calendar:read",
"calendar:write",
@@ -48,14 +60,14 @@ class CalendarPlugin(BasePlugin):
"calendar:admin",
],
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=[
FrontendPageRoute(path='/calendar', component='@/pages/Calendar', protected=True),
],
detail_tabs=[
FrontendDetailTab(entity_type='contact', label_key='tabs.calendar', label='Calendar', component='@/components/contact/ContactCalendarTab', icon='Calendar', order=30, permission='calendar:read'),
FrontendPageRoute(path='/calendar', component='@/pages/Calendar', protected=True, 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",
min_app_version="1.0.0",
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.ext.asyncio import AsyncSession
from app.core.audit import log_audit
from app.core.db import get_db
from app.deps import get_current_user, require_admin, require_permission
from app.plugins.builtins.calendar.ics_utils import (
@@ -1023,5 +1024,3 @@ async def book_resource(
"start_at": booking.start_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
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest
from app.plugins.manifest import (
FrontendDashboardWidget,
PluginManifest,
PluginRouteDef,
)
logger = logging.getLogger(__name__)
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
(permissions, entity models, restore, history) is managed through on_activate/on_deactivate.
Routes (contacts, companies, contact folders, folder permissions) live in
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(
name="contacts",
version="1.0.0",
version="1.1.0",
display_name="Contacts",
description="Core CRM contacts — persons and companies.",
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=[],
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=[
"contacts:read",
"contacts:write",
+18 -2
View File
@@ -59,20 +59,31 @@ class ContractRegistry:
cls._instance = super().__new__(cls)
cls._instance._contracts: dict[str, Any] = {}
cls._instance._loaded: set[str] = set()
cls._instance._unregistered: set[str] = set()
return cls._instance
# ─── registration ───
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._loaded.add(plugin_name)
logger.debug("Contract registered for plugin '%s'", plugin_name)
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._loaded.discard(plugin_name)
self._unregistered.add(plugin_name)
# ─── lookup ───
@@ -85,6 +96,11 @@ class ContractRegistry:
if plugin_name in self._contracts:
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:
self._try_lazy_load(plugin_name)
+4 -4
View File
@@ -4,16 +4,16 @@ from __future__ import annotations
import uuid
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.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
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):
+20 -8
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
from app.plugins.base import BasePlugin
from app.plugins.manifest import (
FrontendDetailTab,
FrontendMenuItem,
FrontendPageRoute,
PluginManifest,
@@ -38,22 +37,35 @@ class DmsPlugin(BasePlugin):
"dms:admin",
],
menu_items=[
FrontendMenuItem(label_key='nav.dms', label='Dateien', path='/dms', icon='FolderOpen', group='Dateien', order=40),
FrontendMenuItem(label_key='nav.dms.trash', label='Papierkorb', path='/dms/trash', icon='Trash2', group='Dateien', order=41),
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, permission='dms:read'),
],
page_routes=[
FrontendPageRoute(path='/dms', component='@/pages/Dms', protected=True),
FrontendPageRoute(path='/dms/trash', component='@/pages/DmsTrash', protected=True),
],
detail_tabs=[
FrontendDetailTab(entity_type='contact', label_key='tabs.files', label='Dateien', component='@/components/contact/ContactFilesTab', icon='FolderOpen', order=40, permission='dms:read'),
FrontendPageRoute(path='/dms', component='@/pages/Dms', protected=True, permission='dms:read'),
FrontendPageRoute(path='/dms/trash', component='@/pages/DmsTrash', protected=True, 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",
min_app_version="1.0.0",
hooks=["dms.before_upload"],
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:
"""Activate plugin: register restore config + history hooks."""
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 app.plugins.base import BasePlugin
from app.plugins.manifest import FrontendDetailTab, PluginManifest, PluginRouteDef
from app.plugins.manifest import PluginManifest, PluginRouteDef
class EntityLinksPlugin(BasePlugin):
@@ -42,9 +42,9 @@ class EntityLinksPlugin(BasePlugin):
"entity_links:delete",
],
is_core=True,
detail_tabs=[
FrontendDetailTab(entity_type='contact', label_key='tabs.links', label='Verknüpfungen', component='@/components/contact/ContactLinksTab', icon='Link', order=60, permission='entity_links:read'),
],
# BUG (ghost component): ContactLinksTab does not exist in the
# frontend — tab removed until implemented (Block I-D).
detail_tabs=[],
author="LeoCRM Team",
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:
"""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.unified_search.contracts import get_search_registry
@@ -50,8 +54,6 @@ class GraphRAGPlugin(BasePlugin):
import logging
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:
"""Deactivate plugin: unregister search provider and contract."""
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.
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
+7 -2
View File
@@ -1,12 +1,17 @@
"""Knowledge extraction models — tracks LLM extractions and review queue."""
from __future__ import annotations
import uuid
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 app.core.db import Base, TenantMixin
class KnowledgeExtraction(Base, TenantMixin):
"""Tracks a single knowledge extraction run from a source (wiki, dms, mail, comm)."""
__tablename__ = "knowledge_extractions"
+13 -7
View File
@@ -1,8 +1,10 @@
"""Knowledge plugin — LLM-based entity/relationship extraction, ask-knowledge, review queue."""
from __future__ import annotations
import logging
import uuid
from typing import Any
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest, PluginRouteDef
@@ -25,7 +27,7 @@ class KnowledgePlugin(BasePlugin):
"""Register event-driven extraction hooks on activation."""
await super().on_activate(db, service_container, event_bus)
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
async def on_wiki_create(*args, **kwargs):
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_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
async def on_wiki_update(*args, **kwargs):
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_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")
except Exception:
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]:
"""Ask a knowledge question."""
from app.plugins.builtins.knowledge.services import ask_knowledge
from app.core.db import get_worker_session_factory
from app.plugins.builtins.knowledge.services import ask_knowledge
question = arguments.get("question", "")
tenant_id = context.get("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]:
"""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.plugins.builtins.unified_search.provider_registry import get_search_registry
query = arguments.get("query", "")
tenant_id = context.get("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:
"""Clean up on deactivation."""
from app.core.hooks import unregister_actions_by_owner
unregister_actions_by_owner("knowledge")
from app.core.hooks import get_hook_registry
get_hook_registry().unregister_all_for_plugin("knowledge")
# Unregister knowledge agent tools from the core AI tool registry
try:
from app.ai.tool_registry import get_tool_registry
+41 -24
View File
@@ -1,51 +1,70 @@
"""Knowledge plugin routes — extraction, ask, review queue."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
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"])
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")
async def extract(
body: dict,
body: KnowledgeExtractRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("wiki:read")),
):
"""Extract knowledge from a source (wiki article, dms file, mail, communication)."""
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(
db=db, tenant_id=tenant_id, source_type=source_type, source_id=sid,
source_title=source_title, source_text=source_text,
db=db, tenant_id=tenant_id, source_type=body.source_type, source_id=body.source_id,
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,
)
return result
@router.post("/ask")
async def ask(
body: dict,
body: KnowledgeAskRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("wiki:read")),
):
"""Ask a knowledge question — uses wiki + graph_rag as context."""
tenant_id = uuid.UUID(current_user["tenant_id"])
question = body.get("question") or body.get("query", "")
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)
result = await ask_knowledge(db=db, tenant_id=tenant_id, question=body.question)
return result
@router.get("/review")
@@ -62,22 +81,20 @@ async def review_queue(
@router.post("/review/{extraction_id}")
async def review(
extraction_id: str,
body: dict,
body: KnowledgeReviewRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("wiki:write")),
):
"""Approve or reject a knowledge extraction."""
tenant_id = uuid.UUID(current_user["tenant_id"])
approved = body.get("approved", False)
notes = body.get("notes")
try:
eid = uuid.UUID(extraction_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid extraction_id", "code": "invalid_id"}) from None
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,
notes=notes,
notes=body.notes,
)
if "error" in result:
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."""
from __future__ import annotations
import logging
import uuid
from typing import Any
from sqlalchemy import select, update
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.ai.llm_client import llm_complete
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",
"reaction.added",
],
migrations=["0001_initial.sql"],
migrations=["0001_initial.sql", "0002_system_channel_folders.sql"],
permissions=[
"comm:read",
"comm:write",
@@ -50,10 +50,10 @@ class KommunikationPlugin(BasePlugin):
],
is_core=True,
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=[
FrontendPageRoute(path='/communication', component='@/pages/Communication', protected=True),
FrontendPageRoute(path='/communication', component='@/pages/Communication', protected=True, permission='comm:read'),
],
author="LeoCRM Team",
min_app_version="1.0.0",
@@ -136,6 +136,13 @@ class KommunikationPlugin(BasePlugin):
from app.plugins.builtins.contracts import get_contract_registry
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)
logger.info("Kommunikation plugin deactivated")
+3 -3
View File
@@ -4,7 +4,9 @@ from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
from pgvector.sqlalchemy import Vector
from sqlalchemy import (
JSON,
Boolean,
@@ -16,14 +18,12 @@ from sqlalchemy import (
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import TSVECTOR
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
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) ---
+17 -8
View File
@@ -9,7 +9,6 @@ from typing import Any
from app.plugins.base import BasePlugin
from app.plugins.manifest import (
FrontendDetailTab,
FrontendMenuItem,
FrontendPageRoute,
FrontendSettingsPage,
@@ -115,7 +114,11 @@ async def _auto_sync_loop() -> None:
class MailPlugin(BasePlugin):
"""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(
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"],
permissions=["mail:read", "mail:send", "mail:config", "mail:share", "mail:write", "mail:delete"],
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=[
FrontendPageRoute(path='/mail', component='@/pages/Mail', protected=True),
FrontendPageRoute(path='/mail/settings', component='@/pages/MailSettings', protected=True),
FrontendPageRoute(path='/mail', component='@/pages/Mail', protected=True, permission='mail:read'),
FrontendPageRoute(path='/mail/settings', component='@/pages/MailSettings', protected=True, permission='mail:config'),
],
settings_pages=[
FrontendSettingsPage(path='mail', label_key='settings.mail', label='Mail', component='@/pages/MailSettings', icon='Mail', order=50),
],
detail_tabs=[
FrontendDetailTab(entity_type='contact', label_key='tabs.email', label='E-Mails', component='@/components/contact/ContactMailTab', icon='Mail', order=20, permission='mail:read'),
],
# BUG (ghost component): ContactMailTab does not exist in the
# frontend — tab removed until implemented (Block I-D).
detail_tabs=[],
author="LeoCRM Team",
min_app_version="1.0.0",
hooks=["mail.before_send", "mail.after_send"],
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(
self, db, service_container, event_bus
) -> None:
+2 -3
View File
@@ -1494,8 +1494,8 @@ async def create_event_from_mail(
try:
from app.plugins.builtins.calendar.contracts import get_contract as get_calendar_contract
_cal = get_calendar_contract()
calendar = _cal.calendar
calendar_entry = _cal.calendar_entry
calendar = _cal.Calendar
calendar_entry = _cal.CalendarEntry
except ImportError:
return {"created": False, "error": "calendar plugin not available"}
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))
from app.core.audit import log_audit
+2 -2
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import logging
import uuid
from datetime import datetime
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
@@ -170,7 +170,7 @@ async def list_server_tools(
tools_resp = await client.list_tools()
# Update last_connected_at
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()
return tools_resp
+1 -1
View File
@@ -14,7 +14,7 @@ class McpServerPlugin(BasePlugin):
version="1.0.0",
display_name="MCP Server",
description="Exposes LeoCRM data (contacts, calendar, mail, DMS) to external MCP clients via API-token auth.",
dependencies=["permissions"],
dependencies=["permissions", "unified_search"],
routes=[
PluginRouteDef(
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"],
migrations=["0001_initial.sql"],
migrations=["0001_initial.sql", "0002_reports_folder_id.sql"],
permissions=["reports:read", "reports:generate", "reports:manage_templates"],
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=[
FrontendPageRoute(path='/reports', component='@/pages/Reports', protected=True),
FrontendPageRoute(path='/reports', component='@/pages/Reports', protected=True, permission='reports:read'),
],
author="LeoCRM Team",
@@ -18,7 +18,8 @@ from sqlalchemy import (
Text,
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 app.core.db import Base, TenantMixin
@@ -24,7 +24,7 @@ class SelfImprovementPlugin(BasePlugin):
version="1.0.0",
display_name="Self-Improvement",
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=[
PluginRouteDef(
path="/api/v1/improvement",
+60 -52
View File
@@ -6,6 +6,7 @@ from datetime import datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
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"])
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
# 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")
async def collect(
body: dict,
body: CollectSignalsRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("automation:read")),
):
"""Collect improvement signals from existing system data."""
tenant_id = uuid.UUID(current_user["tenant_id"])
since_str = body.get("since")
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)
result = await collect_signals(db=db, tenant_id=tenant_id, since=body.since, limit=body.limit)
await db.commit()
return result
@@ -73,14 +106,13 @@ async def signals(
@router.post("/patterns/detect")
async def detect(
body: dict,
body: DetectPatternsRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("automation:read")),
):
"""Detect recurring patterns from collected signals."""
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=min_occurrences)
result = await detect_patterns(db=db, tenant_id=tenant_id, min_occurrences=body.min_occurrences)
await db.commit()
return result
@@ -103,7 +135,7 @@ async def patterns(
@router.post("/proposals")
async def create(
body: dict,
body: CreateProposalRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("automation:write")),
):
@@ -111,35 +143,18 @@ async def create(
tenant_id = uuid.UUID(current_user["tenant_id"])
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(
db=db, tenant_id=tenant_id,
pattern_id=pattern_id,
title=body["title"],
description=body.get("description", ""),
target_type=body["target_type"],
target_ref_id=target_ref_id,
target_name=body.get("target_name"),
proposed_config=body.get("proposed_config", {}),
rationale=body.get("rationale", ""),
expected_benefit=body.get("expected_benefit", ""),
risk_assessment=body.get("risk_assessment", ""),
pattern_id=body.pattern_id,
title=body.title,
description=body.description,
target_type=body.target_type,
target_ref_id=body.target_ref_id,
target_name=body.target_name,
proposed_config=body.proposed_config,
rationale=body.rationale,
expected_benefit=body.expected_benefit,
risk_assessment=body.risk_assessment,
user_id=user_id,
)
await db.commit()
@@ -207,7 +222,7 @@ async def evaluate(
@router.post("/proposals/{proposal_id}/request-approval")
async def req_approval(
proposal_id: str,
body: dict,
body: RequestApprovalRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("automation:write")),
):
@@ -218,13 +233,7 @@ async def req_approval(
pid = uuid.UUID(proposal_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid proposal_id", "code": "invalid_id"}) from None
approver_id = None
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)
result = await request_approval(db=db, tenant_id=tenant_id, proposal_id=pid, requested_by=user_id, approver_id=body.approver_id)
if "error" in result:
raise HTTPException(400, detail={"detail": result["error"], "code": "invalid_state"})
await db.commit()
@@ -258,7 +267,7 @@ async def activate(
@router.post("/proposals/{proposal_id}/rollback")
async def rollback(
proposal_id: str,
body: dict,
body: RollbackProposalRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("automation:admin")),
):
@@ -268,8 +277,7 @@ async def rollback(
pid = uuid.UUID(proposal_id)
except ValueError:
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=reason)
result = await rollback_proposal(db=db, tenant_id=tenant_id, proposal_id=pid, reason=body.reason)
if "error" in result:
raise HTTPException(400, detail={"detail": result["error"], "code": "invalid_state"})
await db.commit()
@@ -21,11 +21,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.ai.llm_client import llm_complete
from app.plugins.builtins.self_improvement.models import (
ImpactMeasurement,
ImprovementPattern,
ImprovementProposal,
ImprovementSignal,
ImpactMeasurement,
PROPOSAL_STATUSES,
)
logger = logging.getLogger(__name__)
@@ -579,29 +578,30 @@ async def request_approval(
proposal.approval_request_id = req.id
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:
from app.plugins.builtins.contracts import get_contract
# Find or create a system conversation for improvement proposals
_komm = get_contract("kommunikation")
if not _komm or not hasattr(_komm, "create_plugin_room"):
from app.plugins.builtins.kommunikation.services import create_plugin_room
room = await create_plugin_room(
db=db, tenant_id=tenant_id, user_id=requested_by,
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.id
if conversation_id:
await KommunikationContract.send_message(
db=db,
tenant_id=tenant_id,
conversation_id=conversation_id,
sender_id=requested_by,
sender_type="system",
content=f"Improvement Proposal: {proposal.title}",
logger.warning("kommunikation contract unavailable - skipping proposal notification")
else:
room = await _komm.create_plugin_room(
db=db, tenant_id=tenant_id, user_id=requested_by,
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.id
if conversation_id:
await _komm.send_message(
db=db,
tenant_id=tenant_id,
conversation_id=conversation_id,
sender_id=requested_by,
sender_type="system",
content=f"Improvement Proposal: {proposal.title}",
blocks=[
{
"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
import uuid
from typing import Any
from pgvector.sqlalchemy import Vector
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 app.core.db import Base, TenantMixin
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):
+5 -5
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
from app.plugins.base import BasePlugin
from app.plugins.manifest import FrontendDetailTab, PluginManifest, PluginRouteDef
from app.plugins.manifest import PluginManifest, PluginRouteDef
class TagsPlugin(BasePlugin):
@@ -23,7 +23,7 @@ class TagsPlugin(BasePlugin):
),
],
events=[],
migrations=["0001_initial.sql", "0002_add_deleted_at.sql"],
migrations=["0001_initial.sql", "0002_add_deleted_at.sql", "0003_tree_structure.sql"],
permissions=[
"tags:read",
"tags:write",
@@ -31,9 +31,9 @@ class TagsPlugin(BasePlugin):
"tags:admin",
],
is_core=True,
detail_tabs=[
FrontendDetailTab(entity_type='contact', label_key='tabs.tags', label='Tags', component='@/components/contact/ContactTagsTab', icon='Tag', order=50, permission='tags:read'),
],
# BUG (ghost component): ContactTagsTab does not exist in the
# frontend — tab removed until implemented (Block I-D).
detail_tabs=[],
author="LeoCRM Team",
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.ext.asyncio import AsyncSession
from app.core.audit import log_audit
from app.core.db import get_db
from app.core.visibility import apply_visibility_filter
from app.deps import get_current_user, require_permission
@@ -389,5 +390,3 @@ async def list_tag_entities(
}
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.manifest import (
CronJobContribution,
FrontendDashboardWidget,
FrontendMenuItem,
FrontendPageRoute,
PluginManifest,
@@ -20,7 +21,7 @@ class TasksPlugin(BasePlugin):
version="1.0.0",
display_name="Tasks",
description="Manage free tasks/activities with status, priority, due dates, and contact links.",
dependencies=["permissions"],
dependencies=["permissions", "kommunikation"],
routes=[
PluginRouteDef(
path="/api/v1/tasks",
@@ -29,7 +30,19 @@ class TasksPlugin(BasePlugin):
),
],
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=[
"tasks:read",
"tasks:write",
@@ -43,6 +56,7 @@ class TasksPlugin(BasePlugin):
path="/tasks",
icon="CheckSquare",
order=30,
permission="tasks:read",
),
],
page_routes=[
@@ -51,6 +65,7 @@ class TasksPlugin(BasePlugin):
component="@/pages/Tasks",
protected=True,
order=30,
permission="tasks:read",
),
],
cron_jobs=[
+2 -2
View File
@@ -7,6 +7,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit
from app.core.db import get_db
from app.deps import get_current_user, require_permission
from app.plugins.builtins.tasks import services
@@ -126,6 +127,7 @@ async def delete_task(
):
"""Delete a task (soft-delete)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
tid = _parse_uuid(task_id, "task_id")
deleted = await services.delete_task(db, tenant_id, tid)
if not deleted:
@@ -261,5 +263,3 @@ async def decompose_goal(
if result is None:
raise HTTPException(404, detail={"detail": "Goal not found", "code": "not_found"})
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
TASK_STATUSES = "^(open|in_progress|review|blocked|done|cancelled)$"
TASK_TYPES = "^(todo|approval|follow_up|review|goal|milestone|agent_subtask)$"
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 uuid.UUID(str(val))
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.visibility import apply_visibility_filter
from app.plugins.builtins.tasks.models import Task
from sqlalchemy import func, select # noqa: E402 — after helper defs by design
from sqlalchemy.ext.asyncio import AsyncSession # noqa: E402
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)
STATUS_ORDER = ["open", "in_progress", "review", "blocked", "done", "cancelled"]
@@ -307,7 +306,6 @@ async def create_task(
entity_id = contact_id
# 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_for_fk = _to_uuid(contact_id) if contact_id else None
# Resolve polymorphic creator.
creator_type = data.get("creator_type", "user")
+4 -4
View File
@@ -67,10 +67,10 @@ async def post_task_to_workstream(
try:
from app.plugins.builtins.contracts import get_contract
_komm = get_contract("kommunikation")
if _komm and hasattr(_komm, "send_message"):
send_message = _komm.send_message
else:
from app.plugins.builtins.kommunikation.services import send_message
if not _komm or not hasattr(_komm, "send_message"):
logger.warning("kommunikation contract unavailable - skipping workstream post for task %s", task.id)
return None
send_message = _komm.send_message
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)
@@ -2,6 +2,8 @@
from __future__ import annotations
from typing import Any
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.embedding import generate_embedding
@@ -50,6 +52,15 @@ class UnifiedSearchContract:
simple_search = staticmethod(simple_search)
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
def get_function(cls, name: str):
"""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")
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
from pgvector.sqlalchemy import Vector # noqa: E402
from app.models.owned_mixin import OwnedMixin
from pgvector.sqlalchemy import Vector # noqa: E402 — optional dependency import after model defs
class DocumentChunk(Base, TenantMixin, OwnedMixin):
+23 -8
View File
@@ -1,6 +1,8 @@
"""Wiki plugin - knowledge articles, categories, versioning."""
from __future__ import annotations
import logging
from app.plugins.base import BasePlugin
from app.plugins.manifest import FrontendMenuItem, FrontendPageRoute, PluginManifest, PluginRouteDef
@@ -13,13 +15,13 @@ class WikiPlugin(BasePlugin):
version="1.0.0",
display_name="Wiki",
description="Knowledge articles with Markdown, categories, tags, versioning, entity links.",
dependencies=["permissions"],
dependencies=["permissions", "unified_search"],
routes=[
PluginRouteDef(path="/api/v1/wiki", module="app.plugins.builtins.wiki.routes", router_attr="router"),
],
permissions=["wiki:read", "wiki:write", "wiki:delete", "wiki:admin"],
menu_items=[FrontendMenuItem(label_key="wiki.menu.wiki", label="Wiki", path="/wiki", icon="BookOpen")],
page_routes=[FrontendPageRoute(path="/wiki", component="@/pages/Wiki")],
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", permission="wiki:read")],
)
async def on_activate(self, db, service_container, event_bus) -> None:
@@ -27,9 +29,11 @@ class WikiPlugin(BasePlugin):
try:
from app.plugins.builtins.contracts import get_contract
search_contract = get_contract("unified_search")
if search_contract and hasattr(search_contract, "register_provider"):
from app.plugins.builtins.unified_search.providers.wiki_provider import WikiSearchProvider
search_contract.register_provider(WikiSearchProvider())
if search_contract and hasattr(search_contract, "get_search_registry"):
from app.plugins.builtins.unified_search.providers.wiki_provider import (
WikiSearchProvider,
)
search_contract.get_search_registry().register(WikiSearchProvider())
logger.info("Registered WikiSearchProvider via contract")
else:
logger.warning("unified_search contract not available, skipping WikiSearchProvider registration")
@@ -37,6 +41,17 @@ class WikiPlugin(BasePlugin):
logger.exception("Failed to register WikiSearchProvider")
async def on_deactivate(self, db, service_container, event_bus) -> None:
from app.core.hooks import unregister_actions_by_owner
unregister_actions_by_owner("wiki")
from app.core.hooks import get_hook_registry
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)
+11 -4
View File
@@ -1,13 +1,20 @@
"""Wiki plugin routes — articles CRUD, categories, versioning."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
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.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"])

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