minder-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. minder/__init__.py +12 -0
  2. minder/api/routers/prompts.py +177 -0
  3. minder/application/__init__.py +1 -0
  4. minder/application/admin/__init__.py +11 -0
  5. minder/application/admin/dto.py +453 -0
  6. minder/application/admin/jobs.py +327 -0
  7. minder/application/admin/use_cases.py +1895 -0
  8. minder/auth/__init__.py +12 -0
  9. minder/auth/context.py +26 -0
  10. minder/auth/middleware.py +70 -0
  11. minder/auth/principal.py +59 -0
  12. minder/auth/rate_limiter.py +89 -0
  13. minder/auth/rbac.py +60 -0
  14. minder/auth/service.py +541 -0
  15. minder/bootstrap/__init__.py +9 -0
  16. minder/bootstrap/providers.py +109 -0
  17. minder/bootstrap/transport.py +807 -0
  18. minder/cache/__init__.py +10 -0
  19. minder/cache/providers.py +140 -0
  20. minder/chunking/__init__.py +4 -0
  21. minder/chunking/code_splitter.py +184 -0
  22. minder/chunking/splitter.py +136 -0
  23. minder/cli.py +1542 -0
  24. minder/config.py +179 -0
  25. minder/continuity.py +363 -0
  26. minder/dev.py +160 -0
  27. minder/embedding/__init__.py +9 -0
  28. minder/embedding/base.py +7 -0
  29. minder/embedding/local.py +65 -0
  30. minder/embedding/openai.py +7 -0
  31. minder/graph/__init__.py +11 -0
  32. minder/graph/edges.py +13 -0
  33. minder/graph/executor.py +127 -0
  34. minder/graph/graph.py +263 -0
  35. minder/graph/nodes/__init__.py +27 -0
  36. minder/graph/nodes/evaluator.py +21 -0
  37. minder/graph/nodes/guard.py +64 -0
  38. minder/graph/nodes/llm.py +59 -0
  39. minder/graph/nodes/planning.py +30 -0
  40. minder/graph/nodes/reasoning.py +87 -0
  41. minder/graph/nodes/reranker.py +141 -0
  42. minder/graph/nodes/retriever.py +86 -0
  43. minder/graph/nodes/verification.py +230 -0
  44. minder/graph/nodes/workflow_planner.py +250 -0
  45. minder/graph/runtime.py +15 -0
  46. minder/graph/state.py +26 -0
  47. minder/llm/__init__.py +5 -0
  48. minder/llm/base.py +14 -0
  49. minder/llm/local.py +381 -0
  50. minder/llm/openai.py +89 -0
  51. minder/models/__init__.py +109 -0
  52. minder/models/base.py +10 -0
  53. minder/models/client.py +137 -0
  54. minder/models/document.py +34 -0
  55. minder/models/error.py +32 -0
  56. minder/models/graph.py +114 -0
  57. minder/models/history.py +32 -0
  58. minder/models/job.py +62 -0
  59. minder/models/prompt.py +41 -0
  60. minder/models/repository.py +62 -0
  61. minder/models/rule.py +68 -0
  62. minder/models/session.py +51 -0
  63. minder/models/skill.py +52 -0
  64. minder/models/user.py +41 -0
  65. minder/models/workflow.py +35 -0
  66. minder/observability/__init__.py +57 -0
  67. minder/observability/audit.py +243 -0
  68. minder/observability/logging.py +253 -0
  69. minder/observability/metrics.py +448 -0
  70. minder/observability/tracing.py +215 -0
  71. minder/presentation/__init__.py +1 -0
  72. minder/presentation/http/__init__.py +1 -0
  73. minder/presentation/http/admin/__init__.py +3 -0
  74. minder/presentation/http/admin/api.py +1309 -0
  75. minder/presentation/http/admin/context.py +94 -0
  76. minder/presentation/http/admin/dashboard.py +111 -0
  77. minder/presentation/http/admin/jobs.py +208 -0
  78. minder/presentation/http/admin/memories.py +185 -0
  79. minder/presentation/http/admin/prompts.py +219 -0
  80. minder/presentation/http/admin/routes.py +127 -0
  81. minder/presentation/http/admin/runtime.py +650 -0
  82. minder/presentation/http/admin/search.py +368 -0
  83. minder/presentation/http/admin/skills.py +230 -0
  84. minder/prompts/__init__.py +646 -0
  85. minder/prompts/formatter.py +142 -0
  86. minder/resources/__init__.py +318 -0
  87. minder/retrieval/__init__.py +5 -0
  88. minder/retrieval/hybrid.py +178 -0
  89. minder/retrieval/mmr.py +116 -0
  90. minder/retrieval/multi_hop.py +115 -0
  91. minder/runtime.py +15 -0
  92. minder/server.py +145 -0
  93. minder/store/__init__.py +64 -0
  94. minder/store/document.py +115 -0
  95. minder/store/error.py +82 -0
  96. minder/store/feedback.py +114 -0
  97. minder/store/graph.py +588 -0
  98. minder/store/history.py +57 -0
  99. minder/store/interfaces.py +512 -0
  100. minder/store/milvus/__init__.py +11 -0
  101. minder/store/milvus/client.py +26 -0
  102. minder/store/milvus/collections.py +15 -0
  103. minder/store/milvus/vector_store.py +232 -0
  104. minder/store/mongodb/__init__.py +11 -0
  105. minder/store/mongodb/client.py +49 -0
  106. minder/store/mongodb/indexes.py +90 -0
  107. minder/store/mongodb/operational_store.py +993 -0
  108. minder/store/relational.py +1087 -0
  109. minder/store/repo_state.py +58 -0
  110. minder/store/rule.py +93 -0
  111. minder/store/vector.py +79 -0
  112. minder/tools/__init__.py +47 -0
  113. minder/tools/auth.py +94 -0
  114. minder/tools/graph.py +839 -0
  115. minder/tools/ingest.py +353 -0
  116. minder/tools/memory.py +381 -0
  117. minder/tools/query.py +307 -0
  118. minder/tools/registry.py +269 -0
  119. minder/tools/repo_scanner.py +1266 -0
  120. minder/tools/search.py +15 -0
  121. minder/tools/session.py +316 -0
  122. minder/tools/skills.py +899 -0
  123. minder/tools/workflow.py +215 -0
  124. minder/transport/__init__.py +4 -0
  125. minder/transport/base.py +286 -0
  126. minder/transport/sse.py +252 -0
  127. minder/transport/stdio.py +29 -0
  128. minder_cli-0.2.0.dist-info/METADATA +318 -0
  129. minder_cli-0.2.0.dist-info/RECORD +132 -0
  130. minder_cli-0.2.0.dist-info/WHEEL +4 -0
  131. minder_cli-0.2.0.dist-info/entry_points.txt +2 -0
  132. minder_cli-0.2.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,62 @@
1
+ import uuid
2
+ from datetime import datetime, UTC
3
+ from typing import Dict, Any, List, Optional
4
+ from sqlalchemy.orm import Mapped, mapped_column
5
+ from sqlalchemy import String, DateTime, UUID, JSON, func
6
+ from pydantic import Field
7
+
8
+ from .base import Base, BaseModelMeta
9
+
10
+ class RepositorySchema(BaseModelMeta):
11
+ id: uuid.UUID = Field(default_factory=uuid.uuid4)
12
+ repo_name: str
13
+ repo_url: str
14
+ default_branch: str
15
+ tracked_branches: List[str] = Field(default_factory=list)
16
+ workflow_id: Optional[uuid.UUID] = None
17
+ state_path: str = ".minder"
18
+ context_snapshot: Dict[str, Any] = Field(default_factory=dict)
19
+ relationships: Dict[str, Any] = Field(default_factory=dict)
20
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
21
+ updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
22
+
23
+ class Repository(Base):
24
+ __tablename__ = "repositories"
25
+
26
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
27
+ company_id: Mapped[str] = mapped_column(String, index=True, default="default")
28
+ repo_name: Mapped[str] = mapped_column(String, index=True)
29
+ repo_url: Mapped[str] = mapped_column(String)
30
+ default_branch: Mapped[str] = mapped_column(String)
31
+ # v2: list of branches that have been synced/tracked (stored as JSON array)
32
+ tracked_branches: Mapped[List[str]] = mapped_column(JSON, default=list, nullable=True)
33
+ workflow_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), nullable=True)
34
+ state_path: Mapped[str] = mapped_column(String, default=".minder")
35
+ context_snapshot: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
36
+ relationships: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
37
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
38
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
39
+
40
+ class RepositoryWorkflowStateSchema(BaseModelMeta):
41
+ id: uuid.UUID = Field(default_factory=uuid.uuid4)
42
+ repo_id: uuid.UUID
43
+ session_id: Optional[uuid.UUID] = None
44
+ current_step: str
45
+ completed_steps: List[str] = Field(default_factory=list)
46
+ blocked_by: List[str] = Field(default_factory=list)
47
+ artifacts: Dict[str, Any] = Field(default_factory=dict)
48
+ next_step: Optional[str] = None
49
+ updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
50
+
51
+ class RepositoryWorkflowState(Base):
52
+ __tablename__ = "repository_workflow_states"
53
+
54
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
55
+ repo_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True)
56
+ session_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), nullable=True, index=True)
57
+ current_step: Mapped[str] = mapped_column(String)
58
+ completed_steps: Mapped[Dict[str, Any]] = mapped_column(JSON, default=list) # stored as JSON list
59
+ blocked_by: Mapped[Dict[str, Any]] = mapped_column(JSON, default=list) # stored as JSON list
60
+ artifacts: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
61
+ next_step: Mapped[Optional[str]] = mapped_column(String, nullable=True)
62
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
minder/models/rule.py ADDED
@@ -0,0 +1,68 @@
1
+ import uuid
2
+ from datetime import datetime, UTC
3
+ from sqlalchemy.orm import Mapped, mapped_column
4
+ from sqlalchemy import JSON, String, Boolean, Integer, DateTime, UUID, func
5
+ from pydantic import Field
6
+
7
+ from .base import Base, BaseModelMeta
8
+
9
+
10
+ class RuleSchema(BaseModelMeta):
11
+ id: uuid.UUID = Field(default_factory=uuid.uuid4)
12
+ title: str
13
+ description: str
14
+ pattern: str
15
+ content: str
16
+ priority: int = 0
17
+ scope: str # enum: global, project, language, repository
18
+ active: bool = True
19
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
20
+
21
+
22
+ class Rule(Base):
23
+ __tablename__ = "rules"
24
+
25
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
26
+ title: Mapped[str] = mapped_column(String)
27
+ description: Mapped[str] = mapped_column(String)
28
+ pattern: Mapped[str] = mapped_column(String)
29
+ content: Mapped[str] = mapped_column(String)
30
+ priority: Mapped[int] = mapped_column(Integer, default=0)
31
+ scope: Mapped[str] = mapped_column(String, index=True)
32
+ active: Mapped[bool] = mapped_column(Boolean, default=True)
33
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
34
+
35
+
36
+ class FeedbackSchema(BaseModelMeta):
37
+ id: uuid.UUID = Field(default_factory=uuid.uuid4)
38
+ entity_type: str # enum: skill, response, retrieval, workflow
39
+ entity_id: uuid.UUID
40
+ rating: int # 1 to 5
41
+ feedback_text: str = ""
42
+ context: dict = Field(default_factory=dict)
43
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
44
+
45
+
46
+ class Feedback(Base):
47
+ """SQLAlchemy ORM model for user/system feedback on entities."""
48
+
49
+ __tablename__ = "feedback"
50
+
51
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
52
+ entity_type: Mapped[str] = mapped_column(String, index=True)
53
+ entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True)
54
+ rating: Mapped[int] = mapped_column(Integer)
55
+ feedback_text: Mapped[str] = mapped_column(String, default="")
56
+ context: Mapped[dict] = mapped_column(JSON, default=dict)
57
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
58
+
59
+
60
+ class MetadataSchema(BaseModelMeta):
61
+ id: uuid.UUID = Field(default_factory=uuid.uuid4)
62
+ entity_type: str # enum: skill, history, error, document, workflow
63
+ entity_id: uuid.UUID
64
+ key: str
65
+ value: dict = Field(default_factory=dict)
66
+ source: str # enum: user, system, import
67
+ version: int = 1
68
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
@@ -0,0 +1,51 @@
1
+ import uuid
2
+ from datetime import datetime, UTC
3
+ from typing import Dict, Any, Optional
4
+ from sqlalchemy.orm import Mapped, mapped_column
5
+ from sqlalchemy import String, Integer, DateTime, UUID, JSON, func
6
+ from pydantic import Field
7
+
8
+ from .base import Base, BaseModelMeta
9
+
10
+ class SessionSchema(BaseModelMeta):
11
+ """Pydantic schema for session serialisation / validation."""
12
+
13
+ id: uuid.UUID = Field(default_factory=uuid.uuid4)
14
+ # Owner — exactly one of user_id or client_id is set.
15
+ user_id: Optional[uuid.UUID] = None
16
+ client_id: Optional[uuid.UUID] = None
17
+ # Human-readable project label for cross-environment lookup.
18
+ name: Optional[str] = None
19
+ repo_id: Optional[uuid.UUID] = None
20
+ project_context: Dict[str, Any] = Field(default_factory=dict)
21
+ active_skills: Dict[str, Any] = Field(default_factory=dict)
22
+ state: Dict[str, Any] = Field(default_factory=dict)
23
+ ttl: int = 86400 # 24 h default — long enough for multi-day work continuity
24
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
25
+ last_active: datetime = Field(default_factory=lambda: datetime.now(UTC))
26
+
27
+
28
+ class Session(Base):
29
+ """SQLAlchemy ORM model for the ``sessions`` table.
30
+
31
+ Production store: MongoDB (``src/minder/store/mongodb/operational_store.py``).
32
+ This SQLAlchemy model is retained for unit-test fixtures (SQLite in-memory)
33
+ and is created fresh via ``Base.metadata.create_all`` — no migration needed.
34
+ """
35
+
36
+ __tablename__ = "sessions"
37
+
38
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
39
+ company_id: Mapped[str] = mapped_column(String, index=True, default="default")
40
+ # Owner columns — mutually exclusive, both nullable.
41
+ user_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), nullable=True, index=True)
42
+ client_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), nullable=True, index=True)
43
+ # Optional project label — enables cross-environment lookup by name.
44
+ name: Mapped[Optional[str]] = mapped_column(String, nullable=True, index=True)
45
+ repo_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), nullable=True, index=True)
46
+ project_context: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
47
+ active_skills: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
48
+ state: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
49
+ ttl: Mapped[int] = mapped_column(Integer, default=86400)
50
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
51
+ last_active: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
minder/models/skill.py ADDED
@@ -0,0 +1,52 @@
1
+ import uuid
2
+ from datetime import datetime, UTC
3
+ from typing import Dict, Any, List, Optional
4
+ from sqlalchemy.orm import Mapped, mapped_column
5
+ from sqlalchemy import String, Integer, Float, DateTime, UUID, JSON, func
6
+ from pydantic import Field
7
+
8
+ from .base import Base, BaseModelMeta
9
+
10
+
11
+ # Pydantic Schema
12
+ class SkillSchema(BaseModelMeta):
13
+ id: uuid.UUID = Field(default_factory=uuid.uuid4)
14
+ title: str
15
+ content: str
16
+ language: str
17
+ tags: List[str] = Field(default_factory=list)
18
+ embedding: Optional[List[float]] = None # vector(default 768) stored as JSON list
19
+ usage_count: int = 0
20
+ quality_score: float = 0.0
21
+ source_metadata: Optional[Dict[str, Any]] = None
22
+ excerpt_kind: str = "none"
23
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
24
+ updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
25
+
26
+
27
+ # SQLAlchemy Model
28
+ class Skill(Base):
29
+ __tablename__ = "skills"
30
+
31
+ id: Mapped[uuid.UUID] = mapped_column(
32
+ UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
33
+ )
34
+ company_id: Mapped[str] = mapped_column(String, index=True, default="default")
35
+ title: Mapped[str] = mapped_column(String, index=True)
36
+ content: Mapped[str] = mapped_column(String)
37
+ language: Mapped[str] = mapped_column(String, index=True)
38
+ tags: Mapped[Dict[str, Any]] = mapped_column(JSON, default=list)
39
+ # Embedding stored as JSON list for cross-dialect compatibility (SQLite dev / PostgreSQL prod)
40
+ embedding: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, nullable=True)
41
+ usage_count: Mapped[int] = mapped_column(Integer, default=0)
42
+ quality_score: Mapped[float] = mapped_column(Float, default=0.0)
43
+ source_metadata: Mapped[Optional[Dict[str, Any]]] = mapped_column(
44
+ JSON, nullable=True
45
+ )
46
+ excerpt_kind: Mapped[str] = mapped_column(String, default="none")
47
+ created_at: Mapped[datetime] = mapped_column(
48
+ DateTime(timezone=True), server_default=func.now()
49
+ )
50
+ updated_at: Mapped[datetime] = mapped_column(
51
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
52
+ )
minder/models/user.py ADDED
@@ -0,0 +1,41 @@
1
+ import uuid
2
+ from datetime import datetime, UTC
3
+ from typing import Dict, Any, Optional
4
+ from sqlalchemy.orm import Mapped, mapped_column
5
+ from sqlalchemy import String, Boolean, DateTime, UUID, JSON, func
6
+ from pydantic import Field
7
+
8
+ from .base import Base, BaseModelMeta
9
+
10
+ # Pydantic Schema
11
+ class UserSchema(BaseModelMeta):
12
+ id: uuid.UUID = Field(default_factory=uuid.uuid4)
13
+ email: str
14
+ username: str
15
+ display_name: str
16
+ api_key_hash: str
17
+ # bcrypt/pbkdf2 hash of the login password; None means password login disabled
18
+ password_hash: Optional[str] = None
19
+ role: str
20
+ settings: Dict[str, Any] = Field(default_factory=dict)
21
+ is_active: bool = True
22
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
23
+ last_login: Optional[datetime] = None
24
+
25
+ # SQLAlchemy Model
26
+ class User(Base):
27
+ __tablename__ = "users"
28
+
29
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
30
+ company_id: Mapped[str] = mapped_column(String, index=True, default="default")
31
+ email: Mapped[str] = mapped_column(String, unique=True, index=True)
32
+ username: Mapped[str] = mapped_column(String, unique=True, index=True)
33
+ display_name: Mapped[str] = mapped_column(String)
34
+ api_key_hash: Mapped[str] = mapped_column(String)
35
+ # Optional bcrypt/pbkdf2 hash — null means only API-key auth available
36
+ password_hash: Mapped[Optional[str]] = mapped_column(String, nullable=True)
37
+ role: Mapped[str] = mapped_column(String)
38
+ settings: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
39
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True)
40
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
41
+ last_login: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -0,0 +1,35 @@
1
+ import uuid
2
+ from datetime import datetime, UTC
3
+ from typing import Dict, Any, List
4
+ from sqlalchemy.orm import Mapped, mapped_column
5
+ from sqlalchemy import String, Boolean, Integer, DateTime, UUID, JSON, func
6
+ from pydantic import Field
7
+
8
+ from .base import Base, BaseModelMeta
9
+
10
+ class WorkflowSchema(BaseModelMeta):
11
+ id: uuid.UUID = Field(default_factory=uuid.uuid4)
12
+ name: str
13
+ description: str = ""
14
+ enforcement: str = "strict"
15
+ version: int = 1
16
+ steps: List[Dict[str, Any]] = Field(default_factory=list)
17
+ policies: Dict[str, Any] = Field(default_factory=dict)
18
+ default_for_repo: bool = False
19
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
20
+ updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
21
+
22
+ class Workflow(Base):
23
+ __tablename__ = "workflows"
24
+
25
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
26
+ company_id: Mapped[str] = mapped_column(String, index=True, default="default")
27
+ name: Mapped[str] = mapped_column(String, index=True)
28
+ description: Mapped[str] = mapped_column(String, default="")
29
+ enforcement: Mapped[str] = mapped_column(String, default="strict")
30
+ version: Mapped[int] = mapped_column(Integer, default=1)
31
+ steps: Mapped[Dict[str, Any]] = mapped_column(JSON, default=list) # list of step dicts
32
+ policies: Mapped[Dict[str, Any]] = mapped_column(JSON, default=dict)
33
+ default_for_repo: Mapped[bool] = mapped_column(Boolean, default=False)
34
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
35
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
@@ -0,0 +1,57 @@
1
+ """Observability package for Minder.
2
+
3
+ Public re-exports used throughout the application:
4
+
5
+ from minder.observability import (
6
+ configure_logging,
7
+ configure_tracing,
8
+ get_tracer,
9
+ trace_async,
10
+ record_tool_call,
11
+ record_auth_event,
12
+ record_http_request,
13
+ AuditEmitter,
14
+ metrics_endpoint,
15
+ CorrelationIdMiddleware,
16
+ AccessLogMiddleware,
17
+ )
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from minder.observability.audit import AuditEmitter
22
+ from minder.observability.logging import (
23
+ AccessLogMiddleware,
24
+ CorrelationIdMiddleware,
25
+ JsonFormatter,
26
+ configure_json_logging,
27
+ get_correlation_id,
28
+ )
29
+ from minder.observability.metrics import (
30
+ metrics_endpoint,
31
+ record_admin_operation,
32
+ record_auth_event,
33
+ record_http_request,
34
+ record_tool_call,
35
+ )
36
+ from minder.observability.tracing import configure_tracing, get_tracer, trace_async
37
+
38
+ __all__ = [
39
+ # audit
40
+ "AuditEmitter",
41
+ # logging
42
+ "AccessLogMiddleware",
43
+ "CorrelationIdMiddleware",
44
+ "JsonFormatter",
45
+ "configure_json_logging",
46
+ "get_correlation_id",
47
+ # metrics
48
+ "metrics_endpoint",
49
+ "record_admin_operation",
50
+ "record_auth_event",
51
+ "record_http_request",
52
+ "record_tool_call",
53
+ # tracing
54
+ "configure_tracing",
55
+ "get_tracer",
56
+ "trace_async",
57
+ ]
@@ -0,0 +1,243 @@
1
+ """Durable audit event emitter for Minder.
2
+
3
+ Wraps ``store.create_audit_log()`` with a higher-level convenience API and
4
+ also emits a structured log entry for every event so that operators have
5
+ both persistent audit records and real-time log streams.
6
+
7
+ Usage::
8
+
9
+ emitter = AuditEmitter(store=store)
10
+
11
+ await emitter.emit(
12
+ actor_type="admin",
13
+ actor_id=str(admin.id),
14
+ event_type="client.created",
15
+ resource_type="client",
16
+ resource_id=str(client.id),
17
+ outcome="success",
18
+ )
19
+
20
+ # Convenience helpers
21
+ await emitter.client_created(actor_id=..., client_id=..., metadata={...})
22
+ await emitter.key_rotated(actor_id=..., client_id=...)
23
+ await emitter.auth_login(actor_id=..., outcome="success")
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ import uuid
29
+ from typing import Any
30
+
31
+ from minder.store.interfaces import IOperationalStore
32
+
33
+ _log = logging.getLogger(__name__)
34
+
35
+
36
+ class AuditEmitter:
37
+ """Emit structured audit events to the operational store and to the log.
38
+
39
+ An ``AuditEmitter`` is safe to construct once at application start and
40
+ reused across requests. All methods are coroutines because the store
41
+ write is async.
42
+ """
43
+
44
+ def __init__(self, store: IOperationalStore) -> None:
45
+ self._store = store
46
+
47
+ # ------------------------------------------------------------------
48
+ # Core emit
49
+ # ------------------------------------------------------------------
50
+
51
+ async def emit(
52
+ self,
53
+ *,
54
+ actor_type: str,
55
+ actor_id: str,
56
+ event_type: str,
57
+ resource_type: str,
58
+ resource_id: str,
59
+ outcome: str = "success",
60
+ metadata: dict[str, Any] | None = None,
61
+ ) -> None:
62
+ """Persist one audit event and write it to the structured log.
63
+
64
+ Args:
65
+ actor_type: Who performed the action (``"admin"`` | ``"client"`` | ``"system"``).
66
+ actor_id: UUID string of the actor.
67
+ event_type: Dot-separated action label (``"client.created"``, ``"key.rotated"``…).
68
+ resource_type: Type of the affected resource (``"client"`` | ``"key"`` | …).
69
+ resource_id: UUID string of the affected resource.
70
+ outcome: ``"success"`` | ``"failure"`` | ``"denied"``.
71
+ metadata: Optional extra structured data persisted with the event.
72
+ """
73
+ audit_metadata = metadata or {}
74
+
75
+ try:
76
+ await self._store.create_audit_log(
77
+ id=str(uuid.uuid4()),
78
+ actor_type=actor_type,
79
+ actor_id=actor_id,
80
+ event_type=event_type,
81
+ resource_type=resource_type,
82
+ resource_id=resource_id,
83
+ outcome=outcome,
84
+ audit_metadata=audit_metadata,
85
+ )
86
+ except Exception:
87
+ _log.exception(
88
+ "Failed to persist audit event",
89
+ extra={
90
+ "audit_event_type": event_type,
91
+ "audit_actor_id": actor_id,
92
+ "audit_resource_id": resource_id,
93
+ "audit_outcome": outcome,
94
+ },
95
+ )
96
+
97
+ _log.info(
98
+ "audit: %s %s → %s",
99
+ actor_type,
100
+ event_type,
101
+ outcome,
102
+ extra={
103
+ "audit_actor_type": actor_type,
104
+ "audit_actor_id": actor_id,
105
+ "audit_event_type": event_type,
106
+ "audit_resource_type": resource_type,
107
+ "audit_resource_id": resource_id,
108
+ "audit_outcome": outcome,
109
+ **audit_metadata,
110
+ },
111
+ )
112
+
113
+ # ------------------------------------------------------------------
114
+ # Convenience helpers — auth/lifecycle events
115
+ # ------------------------------------------------------------------
116
+
117
+ async def auth_login(
118
+ self,
119
+ *,
120
+ actor_id: str,
121
+ actor_type: str = "admin",
122
+ outcome: str = "success",
123
+ metadata: dict[str, Any] | None = None,
124
+ ) -> None:
125
+ await self.emit(
126
+ actor_type=actor_type,
127
+ actor_id=actor_id,
128
+ event_type="auth.login",
129
+ resource_type="session",
130
+ resource_id=actor_id,
131
+ outcome=outcome,
132
+ metadata=metadata,
133
+ )
134
+
135
+ async def auth_logout(
136
+ self,
137
+ *,
138
+ actor_id: str,
139
+ actor_type: str = "admin",
140
+ ) -> None:
141
+ await self.emit(
142
+ actor_type=actor_type,
143
+ actor_id=actor_id,
144
+ event_type="auth.logout",
145
+ resource_type="session",
146
+ resource_id=actor_id,
147
+ )
148
+
149
+ async def client_created(
150
+ self,
151
+ *,
152
+ actor_id: str,
153
+ client_id: str,
154
+ metadata: dict[str, Any] | None = None,
155
+ ) -> None:
156
+ await self.emit(
157
+ actor_type="admin",
158
+ actor_id=actor_id,
159
+ event_type="client.created",
160
+ resource_type="client",
161
+ resource_id=client_id,
162
+ metadata=metadata,
163
+ )
164
+
165
+ async def client_updated(
166
+ self,
167
+ *,
168
+ actor_id: str,
169
+ client_id: str,
170
+ fields_changed: list[str] | None = None,
171
+ ) -> None:
172
+ await self.emit(
173
+ actor_type="admin",
174
+ actor_id=actor_id,
175
+ event_type="client.updated",
176
+ resource_type="client",
177
+ resource_id=client_id,
178
+ metadata={"fields_changed": fields_changed or []},
179
+ )
180
+
181
+ async def key_rotated(
182
+ self,
183
+ *,
184
+ actor_id: str,
185
+ client_id: str,
186
+ ) -> None:
187
+ await self.emit(
188
+ actor_type="admin",
189
+ actor_id=actor_id,
190
+ event_type="key.rotated",
191
+ resource_type="client",
192
+ resource_id=client_id,
193
+ )
194
+
195
+ async def key_revoked(
196
+ self,
197
+ *,
198
+ actor_id: str,
199
+ client_id: str,
200
+ ) -> None:
201
+ await self.emit(
202
+ actor_type="admin",
203
+ actor_id=actor_id,
204
+ event_type="key.revoked",
205
+ resource_type="client",
206
+ resource_id=client_id,
207
+ )
208
+
209
+ async def token_exchanged(
210
+ self,
211
+ *,
212
+ actor_id: str,
213
+ client_id: str,
214
+ scopes: list[str] | None = None,
215
+ outcome: str = "success",
216
+ ) -> None:
217
+ await self.emit(
218
+ actor_type="client",
219
+ actor_id=actor_id,
220
+ event_type="token.exchanged",
221
+ resource_type="client",
222
+ resource_id=client_id,
223
+ outcome=outcome,
224
+ metadata={"scopes": scopes or []},
225
+ )
226
+
227
+ async def tool_call(
228
+ self,
229
+ *,
230
+ actor_id: str,
231
+ tool_name: str,
232
+ outcome: str = "success",
233
+ metadata: dict[str, Any] | None = None,
234
+ ) -> None:
235
+ await self.emit(
236
+ actor_type="client",
237
+ actor_id=actor_id,
238
+ event_type=f"tool.{tool_name}",
239
+ resource_type="tool",
240
+ resource_id=tool_name,
241
+ outcome=outcome,
242
+ metadata=metadata,
243
+ )