minder-cli 0.4.0__tar.gz → 0.4.1__tar.gz
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.
- {minder_cli-0.4.0 → minder_cli-0.4.1}/.gitignore +3 -1
- {minder_cli-0.4.0 → minder_cli-0.4.1}/PKG-INFO +1 -1
- {minder_cli-0.4.0 → minder_cli-0.4.1}/pyproject.toml +1 -1
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/application/admin/dto.py +3 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/application/admin/use_cases.py +290 -83
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/graph/nodes/reasoning.py +8 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/graph/nodes/workflow_planner.py +2 -2
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/models/graph.py +3 -2
- minder_cli-0.4.1/src/minder/presentation/cli/commands/agent.py +187 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/presentation/cli/commands/ide.py +6 -2
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/presentation/cli/commands/mcp.py +12 -1
- minder_cli-0.4.1/src/minder/presentation/cli/commands/sync.py +177 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/presentation/cli/main.py +11 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/presentation/cli/utils/git.py +3 -1
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/presentation/http/admin/api.py +48 -1
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/presentation/http/admin/memories.py +2 -3
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/presentation/http/admin/runtime.py +9 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/presentation/http/admin/search.py +8 -16
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/presentation/http/admin/skills.py +2 -2
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/prompts/__init__.py +3 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/resources/__init__.py +14 -10
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/store/graph.py +267 -40
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/store/interfaces.py +30 -1
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/tools/graph.py +169 -82
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/tools/ingest.py +17 -10
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/tools/memory.py +14 -7
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/tools/query.py +24 -1
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/tools/repo_scanner.py +142 -36
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/tools/skills.py +5 -10
- minder_cli-0.4.0/src/minder/presentation/cli/commands/sync.py +0 -97
- {minder_cli-0.4.0 → minder_cli-0.4.1}/LICENSE +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/README-pypi.md +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/README.md +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/__init__.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/api/routers/prompts.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/application/__init__.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/application/admin/__init__.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/application/admin/jobs.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/auth/__init__.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/auth/context.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/auth/middleware.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/auth/principal.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/auth/rate_limiter.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/auth/rbac.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/auth/service.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/bootstrap/__init__.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/bootstrap/providers.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/bootstrap/transport.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/cache/__init__.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/cache/providers.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/chunking/__init__.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/chunking/code_splitter.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/chunking/splitter.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/cli.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/config.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/continuity.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/dev.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/embedding/__init__.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/embedding/base.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/embedding/local.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/embedding/openai.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/graph/__init__.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/graph/edges.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/graph/executor.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/graph/graph.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/graph/nodes/__init__.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/graph/nodes/evaluator.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/graph/nodes/guard.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/graph/nodes/llm.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/graph/nodes/planning.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/graph/nodes/reranker.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/graph/nodes/retriever.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/graph/nodes/verification.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/graph/runtime.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/graph/state.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/llm/__init__.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/llm/base.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/llm/factory.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/llm/litert.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/llm/openai.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/models/__init__.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/models/base.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/models/client.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/models/document.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/models/error.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/models/history.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/models/job.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/models/prompt.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/models/repository.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/models/rule.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/models/session.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/models/skill.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/models/user.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/models/workflow.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/observability/__init__.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/observability/audit.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/observability/logging.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/observability/metrics.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/observability/tracing.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/presentation/__init__.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/presentation/cli/__init__.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/presentation/cli/commands/auth.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/presentation/cli/commands/update.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/presentation/cli/utils/common.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/presentation/cli/utils/config.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/presentation/cli/utils/version.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/presentation/http/__init__.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/presentation/http/admin/__init__.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/presentation/http/admin/context.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/presentation/http/admin/dashboard.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/presentation/http/admin/jobs.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/presentation/http/admin/prompts.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/presentation/http/admin/routes.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/prompts/formatter.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/retrieval/__init__.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/retrieval/hybrid.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/retrieval/mmr.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/retrieval/multi_hop.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/runtime.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/server.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/store/__init__.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/store/document.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/store/error.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/store/feedback.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/store/history.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/store/milvus/__init__.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/store/milvus/client.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/store/milvus/collections.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/store/milvus/vector_store.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/store/mongodb/__init__.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/store/mongodb/client.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/store/mongodb/indexes.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/store/mongodb/operational_store.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/store/relational.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/store/repo_state.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/store/rule.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/store/vector.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/tools/__init__.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/tools/auth.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/tools/registry.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/tools/search.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/tools/session.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/tools/workflow.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/transport/__init__.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/transport/base.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/transport/sse.py +0 -0
- {minder_cli-0.4.0 → minder_cli-0.4.1}/src/minder/transport/stdio.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: minder-cli
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Minder CLI is the command-line interface for the Minder self-hosted MCP platform.
|
|
5
5
|
Project-URL: Homepage, https://github.com/hiimtrung/minder
|
|
6
6
|
Project-URL: Repository, https://github.com/hiimtrung/minder
|
|
@@ -276,6 +276,7 @@ class ClientRepositoryResolveRequest(BaseModel):
|
|
|
276
276
|
class ClientRepositoryResolvePayload(TypedDict):
|
|
277
277
|
repository: RepositoryPayload
|
|
278
278
|
created: bool
|
|
279
|
+
last_sync: dict[str, Any] | None
|
|
279
280
|
|
|
280
281
|
|
|
281
282
|
class GraphSyncNodeRefRequest(BaseModel):
|
|
@@ -314,7 +315,9 @@ class GraphSyncRequest(BaseModel):
|
|
|
314
315
|
repo_path: str | None = None
|
|
315
316
|
branch: str | None = None
|
|
316
317
|
diff_base: str | None = None
|
|
318
|
+
changed_files: list[str] = Field(default_factory=list)
|
|
317
319
|
deleted_files: list[str] = Field(default_factory=list)
|
|
320
|
+
commit_hash: str | None = None
|
|
318
321
|
sync_metadata: dict[str, Any] = Field(default_factory=dict)
|
|
319
322
|
nodes: list[GraphSyncNodeRequest] = Field(default_factory=list)
|
|
320
323
|
edges: list[GraphSyncEdgeRequest] = Field(default_factory=list)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import logging
|
|
3
4
|
import uuid
|
|
4
5
|
from collections import Counter
|
|
5
6
|
from datetime import UTC, datetime
|
|
@@ -78,6 +79,8 @@ DASHBOARD_TOOL_SCOPE_PRESETS: dict[str, list[str]] = {
|
|
|
78
79
|
],
|
|
79
80
|
}
|
|
80
81
|
|
|
82
|
+
logger = logging.getLogger(__name__)
|
|
83
|
+
|
|
81
84
|
|
|
82
85
|
class AdminConsoleUseCases:
|
|
83
86
|
def __init__(
|
|
@@ -743,7 +746,7 @@ class AdminConsoleUseCases:
|
|
|
743
746
|
)
|
|
744
747
|
if existing_remote != normalized_url:
|
|
745
748
|
updates["repo_url"] = normalized_url
|
|
746
|
-
if str(getattr(repository, "state_path", "") or "")
|
|
749
|
+
if not str(getattr(repository, "state_path", "") or "").strip() and state_path:
|
|
747
750
|
updates["state_path"] = state_path
|
|
748
751
|
if (
|
|
749
752
|
normalized_branch
|
|
@@ -760,6 +763,7 @@ class AdminConsoleUseCases:
|
|
|
760
763
|
return {
|
|
761
764
|
"repository": self.serialize_repository(repository),
|
|
762
765
|
"created": created,
|
|
766
|
+
"last_sync": self._repository_last_sync(repository),
|
|
763
767
|
}
|
|
764
768
|
|
|
765
769
|
@staticmethod
|
|
@@ -810,9 +814,33 @@ class AdminConsoleUseCases:
|
|
|
810
814
|
deleted_nodes = 0
|
|
811
815
|
nodes_upserted = 0
|
|
812
816
|
edges_upserted = 0
|
|
813
|
-
|
|
817
|
+
|
|
818
|
+
# --- Check for redundant sync using commit_hash ---
|
|
819
|
+
relationships = dict(getattr(repository, "relationships", {}) or {})
|
|
820
|
+
graph_sync = dict(relationships.get("graph_sync", {}) or {})
|
|
821
|
+
last_sync = dict(graph_sync.get("last_sync", {}) or {})
|
|
822
|
+
|
|
823
|
+
if (
|
|
824
|
+
payload.commit_hash
|
|
825
|
+
and payload.commit_hash == last_sync.get("commit_hash")
|
|
826
|
+
and not payload.nodes
|
|
827
|
+
and not payload.edges
|
|
828
|
+
and not payload.deleted_files
|
|
829
|
+
):
|
|
830
|
+
return {
|
|
831
|
+
"repo_id": str(repo_id),
|
|
832
|
+
"repository_name": repo_name,
|
|
833
|
+
"payload_version": payload.payload_version,
|
|
834
|
+
"source": payload.source,
|
|
835
|
+
"branch": branch,
|
|
836
|
+
"deleted_nodes": 0,
|
|
837
|
+
"nodes_upserted": 0,
|
|
838
|
+
"edges_upserted": 0,
|
|
839
|
+
"accepted_at": accepted_at,
|
|
840
|
+
}
|
|
841
|
+
|
|
814
842
|
# --- Scoped deletion: prune stale nodes for changed/deleted files ---
|
|
815
|
-
changed_files = payload.
|
|
843
|
+
changed_files = payload.changed_files
|
|
816
844
|
paths_to_prune: set[str] = set(payload.deleted_files)
|
|
817
845
|
if isinstance(changed_files, list):
|
|
818
846
|
paths_to_prune.update(
|
|
@@ -835,7 +863,7 @@ class AdminConsoleUseCases:
|
|
|
835
863
|
)
|
|
836
864
|
else:
|
|
837
865
|
for graph_node in await self._graph_store.list_nodes():
|
|
838
|
-
metadata = dict(
|
|
866
|
+
metadata = dict(graph_node.extra_metadata or {})
|
|
839
867
|
if metadata.get("repo_id") != str(repo_id):
|
|
840
868
|
continue
|
|
841
869
|
if branch is not None and metadata.get("branch") not in {
|
|
@@ -851,6 +879,13 @@ class AdminConsoleUseCases:
|
|
|
851
879
|
# --- Upsert nodes with proper repo/branch scope (v2) ---
|
|
852
880
|
_branch = branch or ""
|
|
853
881
|
_repo_id_str = str(repo_id)
|
|
882
|
+
|
|
883
|
+
# We strip large collections from sync_metadata before broadcasting to nodes
|
|
884
|
+
_filtered_sync_meta = {
|
|
885
|
+
k: v for k, v in payload.sync_metadata.items()
|
|
886
|
+
if k not in {"changed_files", "deleted_files"}
|
|
887
|
+
}
|
|
888
|
+
|
|
854
889
|
_common_meta = {
|
|
855
890
|
"repo_id": _repo_id_str,
|
|
856
891
|
"repository_name": repo_name,
|
|
@@ -860,20 +895,8 @@ class AdminConsoleUseCases:
|
|
|
860
895
|
"branch": _branch,
|
|
861
896
|
"repo_path": payload.repo_path,
|
|
862
897
|
"diff_base": payload.diff_base,
|
|
863
|
-
**
|
|
898
|
+
**_filtered_sync_meta,
|
|
864
899
|
}
|
|
865
|
-
|
|
866
|
-
for node in payload.nodes:
|
|
867
|
-
persisted = await self._graph_store.upsert_node(
|
|
868
|
-
node.node_type,
|
|
869
|
-
node.name,
|
|
870
|
-
metadata={**_common_meta, **node.metadata},
|
|
871
|
-
repo_id=_repo_id_str,
|
|
872
|
-
branch=_branch,
|
|
873
|
-
)
|
|
874
|
-
node_ids[(node.node_type, node.name)] = persisted.id
|
|
875
|
-
nodes_upserted += 1
|
|
876
|
-
|
|
877
900
|
_edge_common_meta = {
|
|
878
901
|
"repo_id": _repo_id_str,
|
|
879
902
|
"repository_name": repo_name,
|
|
@@ -884,40 +907,54 @@ class AdminConsoleUseCases:
|
|
|
884
907
|
"repo_path": payload.repo_path,
|
|
885
908
|
}
|
|
886
909
|
|
|
910
|
+
# --- Bulk upsert nodes ---
|
|
911
|
+
# Use a dict to deduplicate nodes by (type, name)
|
|
912
|
+
deduped_nodes: dict[tuple[str, str], dict[str, Any]] = {}
|
|
913
|
+
|
|
914
|
+
for node in payload.nodes:
|
|
915
|
+
key = (node.node_type, node.name)
|
|
916
|
+
deduped_nodes[key] = {
|
|
917
|
+
"node_type": node.node_type,
|
|
918
|
+
"name": node.name,
|
|
919
|
+
"metadata": {**_common_meta, **node.metadata},
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
# Also need to collect nodes mentioned in edges that might not be in payload.nodes
|
|
887
923
|
for edge in payload.edges:
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
)
|
|
920
|
-
|
|
924
|
+
for side in [edge.source, edge.target]:
|
|
925
|
+
key = (side.node_type, side.name)
|
|
926
|
+
if key not in deduped_nodes:
|
|
927
|
+
deduped_nodes[key] = {
|
|
928
|
+
"node_type": side.node_type,
|
|
929
|
+
"name": side.name,
|
|
930
|
+
"metadata": _edge_common_meta,
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
node_ids = await self._graph_store.bulk_upsert_nodes(
|
|
934
|
+
list(deduped_nodes.values()),
|
|
935
|
+
repo_id=_repo_id_str,
|
|
936
|
+
branch=_branch,
|
|
937
|
+
)
|
|
938
|
+
nodes_upserted = len(deduped_nodes)
|
|
939
|
+
|
|
940
|
+
# --- Bulk upsert edges ---
|
|
941
|
+
deduped_edges: dict[tuple[uuid.UUID, uuid.UUID, str], dict[str, Any]] = {}
|
|
942
|
+
for edge in payload.edges:
|
|
943
|
+
source_id = node_ids.get((edge.source.node_type, edge.source.name))
|
|
944
|
+
target_id = node_ids.get((edge.target.node_type, edge.target.name))
|
|
945
|
+
if source_id and target_id:
|
|
946
|
+
edge_key = (source_id, target_id, edge.relation)
|
|
947
|
+
deduped_edges[edge_key] = {
|
|
948
|
+
"source_id": source_id,
|
|
949
|
+
"target_id": target_id,
|
|
950
|
+
"relation": edge.relation,
|
|
951
|
+
"weight": edge.weight,
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
edges_upserted = await self._graph_store.bulk_upsert_edges(
|
|
955
|
+
list(deduped_edges.values()),
|
|
956
|
+
repo_id=_repo_id_str,
|
|
957
|
+
)
|
|
921
958
|
|
|
922
959
|
# --- Update repository: tracked_branches + graph_sync metadata ---
|
|
923
960
|
relationships = dict(getattr(repository, "relationships", {}) or {})
|
|
@@ -929,12 +966,12 @@ class AdminConsoleUseCases:
|
|
|
929
966
|
"repo_remote": repo_remote,
|
|
930
967
|
"diff_base": payload.diff_base,
|
|
931
968
|
"deleted_files": payload.deleted_files,
|
|
969
|
+
"commit_hash": payload.commit_hash,
|
|
932
970
|
"deleted_nodes": deleted_nodes,
|
|
933
971
|
"nodes_upserted": nodes_upserted,
|
|
934
972
|
"edges_upserted": edges_upserted,
|
|
935
973
|
"accepted_at": accepted_at,
|
|
936
974
|
}
|
|
937
|
-
graph_sync = dict(relationships.get("graph_sync", {}) or {})
|
|
938
975
|
graph_sync["last_sync"] = graph_sync_state
|
|
939
976
|
graph_sync.update(graph_sync_state)
|
|
940
977
|
branch_registry = dict(graph_sync.get("branches", {}) or {})
|
|
@@ -983,6 +1020,9 @@ class AdminConsoleUseCases:
|
|
|
983
1020
|
tracked_branches=raw_branches,
|
|
984
1021
|
)
|
|
985
1022
|
|
|
1023
|
+
# Auto-prune stale branches that are no longer tracked
|
|
1024
|
+
await self.prune_repository_stale_data(repo_id)
|
|
1025
|
+
|
|
986
1026
|
return {
|
|
987
1027
|
"repo_id": str(repo_id),
|
|
988
1028
|
"repository_name": repo_name,
|
|
@@ -1031,35 +1071,82 @@ class AdminConsoleUseCases:
|
|
|
1031
1071
|
|
|
1032
1072
|
repo_nodes = await self._repository_graph_nodes(repository, branch=branch)
|
|
1033
1073
|
counts = Counter(str(getattr(node, "node_type", "")) for node in repo_nodes)
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
]
|
|
1074
|
+
# 1. Fetch all edges for the repo
|
|
1075
|
+
repo_edges = await self._graph_store.list_edges_by_scope(repo_id=str(repo_id))
|
|
1076
|
+
|
|
1077
|
+
# 2. Build map of nodes and containment map (parent map)
|
|
1078
|
+
node_id_to_node = {str(getattr(node, "id")): node for node in repo_nodes}
|
|
1079
|
+
parent_map: dict[str, str] = {} # child_id -> parent_id
|
|
1080
|
+
for edge in repo_edges:
|
|
1081
|
+
if edge.relation == "contains":
|
|
1082
|
+
parent_map[str(edge.target_id)] = str(edge.source_id)
|
|
1083
|
+
|
|
1084
|
+
# 3. Identify dependency relations
|
|
1085
|
+
dependency_relations = {"depends_on", "uses_external_service", "calls", "imports"}
|
|
1086
|
+
|
|
1087
|
+
# 4. Find all dependency edges and group by high-level owner
|
|
1088
|
+
# owner_id -> set of target info
|
|
1089
|
+
owner_to_deps: dict[str, set[tuple[str, str, str]]] = {}
|
|
1090
|
+
|
|
1091
|
+
high_level_types = {"service", "repository", "module", "controller"}
|
|
1092
|
+
|
|
1093
|
+
for edge in repo_edges:
|
|
1094
|
+
if edge.relation not in dependency_relations:
|
|
1095
|
+
continue
|
|
1096
|
+
|
|
1097
|
+
source_id = str(edge.source_id)
|
|
1098
|
+
target_id = str(edge.target_id)
|
|
1099
|
+
|
|
1100
|
+
# Find high-level owner for the source node
|
|
1101
|
+
curr_id = source_id
|
|
1102
|
+
owner_node = None
|
|
1103
|
+
|
|
1104
|
+
# Search upwards for a high-level owner
|
|
1105
|
+
visited = {curr_id}
|
|
1106
|
+
while curr_id in node_id_to_node:
|
|
1107
|
+
node = node_id_to_node[curr_id]
|
|
1108
|
+
if str(getattr(node, "node_type", "")) in high_level_types:
|
|
1109
|
+
owner_node = node
|
|
1110
|
+
break
|
|
1111
|
+
|
|
1112
|
+
parent_id = parent_map.get(curr_id)
|
|
1113
|
+
if not parent_id or parent_id in visited:
|
|
1114
|
+
break
|
|
1115
|
+
curr_id = parent_id
|
|
1116
|
+
visited.add(curr_id)
|
|
1117
|
+
|
|
1118
|
+
if not owner_node:
|
|
1119
|
+
continue
|
|
1120
|
+
|
|
1121
|
+
# Get target info
|
|
1122
|
+
target_node = node_id_to_node.get(target_id)
|
|
1123
|
+
if not target_node:
|
|
1124
|
+
continue
|
|
1125
|
+
|
|
1126
|
+
owner_id = str(owner_node.id)
|
|
1127
|
+
if owner_id not in owner_to_deps:
|
|
1128
|
+
owner_to_deps[owner_id] = set()
|
|
1129
|
+
|
|
1130
|
+
owner_to_deps[owner_id].add((
|
|
1131
|
+
target_id,
|
|
1132
|
+
str(getattr(target_node, "name", "")),
|
|
1133
|
+
str(getattr(target_node, "node_type", "")),
|
|
1134
|
+
))
|
|
1135
|
+
|
|
1136
|
+
# 5. Format the results
|
|
1040
1137
|
dependencies: list[dict[str, Any]] = []
|
|
1041
|
-
for
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
)
|
|
1047
|
-
targets = [
|
|
1048
|
-
{
|
|
1049
|
-
"id": str(getattr(neighbor, "id")),
|
|
1050
|
-
"name": str(getattr(neighbor, "name", "")),
|
|
1051
|
-
"node_type": str(getattr(neighbor, "node_type", "")),
|
|
1052
|
-
}
|
|
1053
|
-
for neighbor in neighbors
|
|
1054
|
-
if str(getattr(neighbor, "id")) in repo_node_ids
|
|
1138
|
+
for owner_id, targets in owner_to_deps.items():
|
|
1139
|
+
owner_node = node_id_to_node[owner_id]
|
|
1140
|
+
depends_on_items: list[dict[str, str]] = [
|
|
1141
|
+
{"id": tid, "name": tname, "node_type": ttype}
|
|
1142
|
+
for tid, tname, ttype in targets
|
|
1055
1143
|
]
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
)
|
|
1144
|
+
dependencies.append({
|
|
1145
|
+
"service": str(getattr(owner_node, "name", "")),
|
|
1146
|
+
"source_type": str(getattr(owner_node, "node_type", "")),
|
|
1147
|
+
"depends_on": sorted(depends_on_items, key=lambda item: item["name"]),
|
|
1148
|
+
})
|
|
1149
|
+
dependencies.sort(key=lambda item: str(item["service"]))
|
|
1063
1150
|
|
|
1064
1151
|
return {
|
|
1065
1152
|
"repository": repository_payload,
|
|
@@ -1071,13 +1158,19 @@ class AdminConsoleUseCases:
|
|
|
1071
1158
|
"node_count": len(repo_nodes),
|
|
1072
1159
|
"counts_by_type": dict(counts),
|
|
1073
1160
|
"routes": self._serialize_repo_graph_nodes(
|
|
1074
|
-
repo_nodes,
|
|
1161
|
+
repo_nodes,
|
|
1162
|
+
allowed_types={"route", "api_endpoint", "websocket_endpoint"},
|
|
1163
|
+
limit=50
|
|
1075
1164
|
),
|
|
1076
1165
|
"todos": self._serialize_repo_graph_nodes(
|
|
1077
|
-
repo_nodes,
|
|
1166
|
+
repo_nodes,
|
|
1167
|
+
allowed_types={"todo"},
|
|
1168
|
+
limit=50
|
|
1078
1169
|
),
|
|
1079
1170
|
"external_services": self._serialize_repo_graph_nodes(
|
|
1080
|
-
repo_nodes,
|
|
1171
|
+
repo_nodes,
|
|
1172
|
+
allowed_types={"external_service_api", "external_service"},
|
|
1173
|
+
limit=50
|
|
1081
1174
|
),
|
|
1082
1175
|
"dependencies": dependencies,
|
|
1083
1176
|
}
|
|
@@ -1087,6 +1180,8 @@ class AdminConsoleUseCases:
|
|
|
1087
1180
|
*,
|
|
1088
1181
|
repo_id: uuid.UUID,
|
|
1089
1182
|
branch: str | None = None,
|
|
1183
|
+
node_types: list[str] | None = None,
|
|
1184
|
+
limit: int | None = 1000,
|
|
1090
1185
|
) -> RepositoryGraphMapPayload:
|
|
1091
1186
|
repository = await self._store.get_repository_by_id(repo_id)
|
|
1092
1187
|
if repository is None:
|
|
@@ -1124,7 +1219,41 @@ class AdminConsoleUseCases:
|
|
|
1124
1219
|
repo_name=getattr(repository, "repo_name", None),
|
|
1125
1220
|
repo_path=self._repository_root_path(repository),
|
|
1126
1221
|
branch=effective_branch,
|
|
1222
|
+
node_types=node_types,
|
|
1127
1223
|
)
|
|
1224
|
+
|
|
1225
|
+
total_nodes = len(repo_nodes)
|
|
1226
|
+
total_edges = len(repo_edges)
|
|
1227
|
+
|
|
1228
|
+
# If too many nodes, we limit the return set to prevent browser freeze
|
|
1229
|
+
# We prioritize nodes by their 'importance' (node_type)
|
|
1230
|
+
if limit and len(repo_nodes) > limit:
|
|
1231
|
+
# Simple heuristic: prioritize non-file nodes first (higher level abstraction)
|
|
1232
|
+
type_priority = {
|
|
1233
|
+
"repository": 0,
|
|
1234
|
+
"service": 1,
|
|
1235
|
+
"module": 2,
|
|
1236
|
+
"controller": 3,
|
|
1237
|
+
"route": 4,
|
|
1238
|
+
"api_endpoint": 5,
|
|
1239
|
+
"websocket_endpoint": 6,
|
|
1240
|
+
"mq_topic": 7,
|
|
1241
|
+
"external_service_api": 8,
|
|
1242
|
+
"workflow": 9,
|
|
1243
|
+
"folder": 10,
|
|
1244
|
+
"file": 11,
|
|
1245
|
+
"todo": 12,
|
|
1246
|
+
"class": 20,
|
|
1247
|
+
"interface": 21,
|
|
1248
|
+
"abstract_class": 22,
|
|
1249
|
+
"function": 23,
|
|
1250
|
+
}
|
|
1251
|
+
repo_nodes.sort(key=lambda n: type_priority.get(getattr(n, "node_type", ""), 15))
|
|
1252
|
+
repo_nodes = repo_nodes[:limit]
|
|
1253
|
+
|
|
1254
|
+
# Filter edges to only those connecting remaining nodes
|
|
1255
|
+
node_ids = {n.id for n in repo_nodes}
|
|
1256
|
+
repo_edges = [e for e in repo_edges if e.source_id in node_ids and e.target_id in node_ids]
|
|
1128
1257
|
node_counts = Counter(
|
|
1129
1258
|
str(getattr(node, "node_type", "")) for node in repo_nodes
|
|
1130
1259
|
)
|
|
@@ -1139,14 +1268,92 @@ class AdminConsoleUseCases:
|
|
|
1139
1268
|
"branch_links": branch_links["links"],
|
|
1140
1269
|
"nodes": [self._serialize_graph_node(node) for node in repo_nodes],
|
|
1141
1270
|
"edges": [self._serialize_graph_edge(edge) for edge in repo_edges],
|
|
1271
|
+
"summary": {
|
|
1272
|
+
"node_count": total_nodes,
|
|
1273
|
+
"edge_count": total_edges,
|
|
1274
|
+
"returned_node_count": len(repo_nodes),
|
|
1275
|
+
"returned_edge_count": len(repo_edges),
|
|
1276
|
+
"counts_by_type": dict(node_counts),
|
|
1277
|
+
"counts_by_relation": dict(relation_counts),
|
|
1278
|
+
},
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
async def get_repository_node_neighborhood(
|
|
1282
|
+
self,
|
|
1283
|
+
*,
|
|
1284
|
+
repo_id: uuid.UUID,
|
|
1285
|
+
node_id: uuid.UUID,
|
|
1286
|
+
depth: int = 4,
|
|
1287
|
+
limit: int = 200,
|
|
1288
|
+
) -> RepositoryGraphMapPayload:
|
|
1289
|
+
"""Fetch a subgraph around a specific node with limited depth/count."""
|
|
1290
|
+
repository = await self._store.get_repository_by_id(repo_id)
|
|
1291
|
+
if repository is None:
|
|
1292
|
+
raise LookupError("Repository not found")
|
|
1293
|
+
|
|
1294
|
+
if self._graph_store is None:
|
|
1295
|
+
raise RuntimeError("Graph store is not configured")
|
|
1296
|
+
|
|
1297
|
+
repo_nodes, repo_edges = await self._graph_store.get_neighborhood(
|
|
1298
|
+
node_id=node_id,
|
|
1299
|
+
max_depth=depth,
|
|
1300
|
+
max_nodes=limit,
|
|
1301
|
+
)
|
|
1302
|
+
|
|
1303
|
+
repository_payload = self.serialize_repository(repository)
|
|
1304
|
+
|
|
1305
|
+
# Calculate summary
|
|
1306
|
+
node_counts = Counter(
|
|
1307
|
+
str(getattr(node, "node_type", "")) for node in repo_nodes
|
|
1308
|
+
)
|
|
1309
|
+
relation_counts = Counter(
|
|
1310
|
+
str(getattr(edge, "relation", "")) for edge in repo_edges
|
|
1311
|
+
)
|
|
1312
|
+
|
|
1313
|
+
return {
|
|
1314
|
+
"repository": repository_payload,
|
|
1315
|
+
"graph_available": bool(repo_nodes),
|
|
1316
|
+
"branch": getattr(repo_nodes[0], "branch", None) if repo_nodes else None,
|
|
1317
|
+
"branch_state": None,
|
|
1318
|
+
"branch_links": [],
|
|
1319
|
+
"nodes": [self._serialize_graph_node(node) for node in repo_nodes],
|
|
1320
|
+
"edges": [self._serialize_graph_edge(edge) for edge in repo_edges],
|
|
1142
1321
|
"summary": {
|
|
1143
1322
|
"node_count": len(repo_nodes),
|
|
1144
1323
|
"edge_count": len(repo_edges),
|
|
1324
|
+
"returned_node_count": len(repo_nodes),
|
|
1325
|
+
"returned_edge_count": len(repo_edges),
|
|
1145
1326
|
"counts_by_type": dict(node_counts),
|
|
1146
1327
|
"counts_by_relation": dict(relation_counts),
|
|
1147
1328
|
},
|
|
1148
1329
|
}
|
|
1149
1330
|
|
|
1331
|
+
async def prune_repository_stale_data(self, repo_id: uuid.UUID) -> dict[str, Any]:
|
|
1332
|
+
"""Delete graph data for branches that are no longer tracked."""
|
|
1333
|
+
if self._graph_store is None:
|
|
1334
|
+
return {"deleted": 0}
|
|
1335
|
+
|
|
1336
|
+
repository = await self._store.get_repository_by_id(repo_id)
|
|
1337
|
+
if not repository:
|
|
1338
|
+
return {"deleted": 0}
|
|
1339
|
+
|
|
1340
|
+
tracked = set(getattr(repository, "tracked_branches", []) or [])
|
|
1341
|
+
if not tracked:
|
|
1342
|
+
return {"deleted": 0}
|
|
1343
|
+
|
|
1344
|
+
repo_id_str = str(repo_id)
|
|
1345
|
+
branches_in_graph = await self._graph_store.list_repo_branches(repo_id_str)
|
|
1346
|
+
|
|
1347
|
+
deleted_total = 0
|
|
1348
|
+
for branch in branches_in_graph:
|
|
1349
|
+
if branch not in tracked:
|
|
1350
|
+
logger.info(f"Pruning stale branch data: {repo_id_str}/{branch}")
|
|
1351
|
+
deleted_total += await self._graph_store.delete_nodes_by_scope(
|
|
1352
|
+
repo_id=repo_id_str, branch=branch
|
|
1353
|
+
)
|
|
1354
|
+
|
|
1355
|
+
return {"deleted": deleted_total}
|
|
1356
|
+
|
|
1150
1357
|
async def search_repository_graph(
|
|
1151
1358
|
self,
|
|
1152
1359
|
*,
|
|
@@ -1599,7 +1806,7 @@ class AdminConsoleUseCases:
|
|
|
1599
1806
|
|
|
1600
1807
|
@staticmethod
|
|
1601
1808
|
def _serialize_graph_node(node: Any) -> RepositoryGraphNodePayload:
|
|
1602
|
-
metadata = dict(
|
|
1809
|
+
metadata = dict(node.extra_metadata or {})
|
|
1603
1810
|
return {
|
|
1604
1811
|
"id": str(getattr(node, "id")),
|
|
1605
1812
|
"node_type": str(getattr(node, "node_type", "")),
|
|
@@ -1632,7 +1839,7 @@ class AdminConsoleUseCases:
|
|
|
1632
1839
|
filtered.sort(
|
|
1633
1840
|
key=lambda node: (
|
|
1634
1841
|
str(getattr(node, "node_type", "")),
|
|
1635
|
-
str(
|
|
1842
|
+
str((node.extra_metadata or {}).get("path", "")),
|
|
1636
1843
|
str(getattr(node, "name", "")),
|
|
1637
1844
|
)
|
|
1638
1845
|
)
|
|
@@ -78,6 +78,14 @@ class ReasoningNode:
|
|
|
78
78
|
else "No repository context found."
|
|
79
79
|
),
|
|
80
80
|
"correction_required": retry_reason,
|
|
81
|
+
"chat_history": (
|
|
82
|
+
"\n".join(
|
|
83
|
+
f"{'User' if m.get('role') == 'user' else 'Assistant'}: {m.get('content')}"
|
|
84
|
+
for m in (state.chat_history or [])
|
|
85
|
+
)
|
|
86
|
+
if state.chat_history
|
|
87
|
+
else "No conversation history."
|
|
88
|
+
),
|
|
81
89
|
},
|
|
82
90
|
defaults=prompt_defaults,
|
|
83
91
|
)
|
|
@@ -174,8 +174,8 @@ class WorkflowPlannerNode:
|
|
|
174
174
|
for n in service_nodes
|
|
175
175
|
if n.name == repo_name
|
|
176
176
|
or (
|
|
177
|
-
hasattr(n, "
|
|
178
|
-
and n.
|
|
177
|
+
hasattr(n, "extra_metadata")
|
|
178
|
+
and (n.extra_metadata or {}).get("path", "").startswith(repo_path)
|
|
179
179
|
)
|
|
180
180
|
]
|
|
181
181
|
|
|
@@ -20,6 +20,7 @@ from typing import Any
|
|
|
20
20
|
|
|
21
21
|
from pydantic import Field
|
|
22
22
|
from sqlalchemy import DateTime, Float, JSON, String, UUID, UniqueConstraint, func
|
|
23
|
+
from sqlalchemy.ext.mutable import MutableDict
|
|
23
24
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
24
25
|
|
|
25
26
|
from .base import Base, BaseModelMeta
|
|
@@ -36,7 +37,7 @@ class GraphNodeSchema(BaseModelMeta):
|
|
|
36
37
|
branch: str = ""
|
|
37
38
|
node_type: str # module | file | service | owner | route | api_endpoint | websocket_endpoint | mq_topic | …
|
|
38
39
|
name: str
|
|
39
|
-
|
|
40
|
+
extra_metadata: dict[str, Any] = Field(default_factory=dict)
|
|
40
41
|
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
41
42
|
|
|
42
43
|
|
|
@@ -80,7 +81,7 @@ class GraphNode(Base):
|
|
|
80
81
|
branch: Mapped[str] = mapped_column(String, index=True, default="", server_default="")
|
|
81
82
|
node_type: Mapped[str] = mapped_column(String, index=True)
|
|
82
83
|
name: Mapped[str] = mapped_column(String, index=True)
|
|
83
|
-
|
|
84
|
+
extra_metadata: Mapped[dict] = mapped_column("metadata", MutableDict.as_mutable(JSON), default=dict)
|
|
84
85
|
created_at: Mapped[datetime] = mapped_column(
|
|
85
86
|
DateTime(timezone=True), server_default=func.now()
|
|
86
87
|
)
|