topos-node 0.1.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.
- shared/__init__.py +59 -0
- shared/filtering.py +640 -0
- shared/schema_registry.py +229 -0
- topos/__init__.py +5 -0
- topos/__version__.py +6 -0
- topos/analytics/__init__.py +15 -0
- topos/analytics/duckdb_adapter.py +48 -0
- topos/analytics/messenger_communities.py +349 -0
- topos/analytics/messenger_graph.py +522 -0
- topos/analytics/messenger_labels.py +321 -0
- topos/analytics/profiles.py +22 -0
- topos/analytics/query_engine.py +64 -0
- topos/analytics/raw_queries.py +174 -0
- topos/api/__init__.py +1 -0
- topos/api/analytics.py +52 -0
- topos/api/app_registry.py +31 -0
- topos/api/backup.py +15 -0
- topos/api/compute_remote.py +175 -0
- topos/api/data_commit.py +158 -0
- topos/api/data_explorer_table_prefs.py +81 -0
- topos/api/db.py +10 -0
- topos/api/device.py +25 -0
- topos/api/enrichment.py +959 -0
- topos/api/filter_lab.py +195 -0
- topos/api/health.py +61 -0
- topos/api/ingestion_api.py +37 -0
- topos/api/ingestion_compat.py +21 -0
- topos/api/ingestion_sources.py +600 -0
- topos/api/llm.py +76 -0
- topos/api/local_mcp.py +46 -0
- topos/api/messenger_analytics.py +385 -0
- topos/api/query_api.py +13 -0
- topos/api/sanitization_ollama_config.py +64 -0
- topos/api/source_install.py +324 -0
- topos/api/sources.py +13 -0
- topos/api/sync.py +10 -0
- topos/api/ui_config.py +83 -0
- topos/api/uma_data.py +311 -0
- topos/api/usage.py +49 -0
- topos/api/user_identity.py +46 -0
- topos/app.py +239 -0
- topos/auth.py +17 -0
- topos/canonicalization/__init__.py +1 -0
- topos/canonicalization/mappers/__init__.py +22 -0
- topos/canonicalization/mappers/base.py +26 -0
- topos/canonicalization/mappers/chatgpt_mapper.py +40 -0
- topos/canonicalization/mappers/grok_mapper.py +17 -0
- topos/canonicalization/mappers/messenger_mapper.py +58 -0
- topos/canonicalization/models.py +31 -0
- topos/canonicalization/resolver.py +23 -0
- topos/cli/__init__.py +1 -0
- topos/cli/__main__.py +6 -0
- topos/cli/commands.py +132 -0
- topos/config/__init__.py +1 -0
- topos/config/sanitization_ollama.py +189 -0
- topos/config/settings.py +310 -0
- topos/contacts/__init__.py +5 -0
- topos/contacts/identity.py +24 -0
- topos/control_plane_client.py +300 -0
- topos/core/__init__.py +1 -0
- topos/core/api_models.py +128 -0
- topos/core/connection_resilience.py +99 -0
- topos/core/device_helpers.py +8 -0
- topos/core/errors.py +13 -0
- topos/core/events.py +12 -0
- topos/core/handlers.py +5625 -0
- topos/core/logging.py +175 -0
- topos/core/metrics.py +21 -0
- topos/core/startup_banner.py +62 -0
- topos/core/state.py +682 -0
- topos/core/table_layers.py +45 -0
- topos/core/types.py +13 -0
- topos/data_explorer_table_prefs.py +150 -0
- topos/engine/__init__.py +29 -0
- topos/engine/backends/__init__.py +50 -0
- topos/engine/backends/base.py +21 -0
- topos/engine/backends/huggingface.py +151 -0
- topos/engine/backends/ollama.py +181 -0
- topos/engine/backends/stub.py +22 -0
- topos/engine/engine.py +165 -0
- topos/engine/intake.py +32 -0
- topos/engine/queue_manager.py +112 -0
- topos/engine/registration.py +126 -0
- topos/engine/result_formatter.py +38 -0
- topos/engine/router.py +19 -0
- topos/engine/scoped_token.py +82 -0
- topos/engine/tasks.py +154 -0
- topos/engine/transport.py +44 -0
- topos/engine/usage_guard.py +100 -0
- topos/engine/usage_observation.py +129 -0
- topos/engine/validator.py +23 -0
- topos/enrichment/__init__.py +1 -0
- topos/enrichment/derived_tables.py +214 -0
- topos/enrichment/jobs/__init__.py +30 -0
- topos/enrichment/jobs/base.py +54 -0
- topos/enrichment/jobs/canonical/__init__.py +1 -0
- topos/enrichment/jobs/canonical/embeddings_job.py +27 -0
- topos/enrichment/jobs/canonical/emo_27_job.py +97 -0
- topos/enrichment/jobs/canonical/entities_job.py +27 -0
- topos/enrichment/jobs/canonical/sentiment_job.py +27 -0
- topos/enrichment/jobs/canonical/topics_job.py +27 -0
- topos/enrichment/jobs/raw/__init__.py +1 -0
- topos/enrichment/jobs/raw/attachments_job.py +12 -0
- topos/enrichment/jobs/raw/language_job.py +12 -0
- topos/enrichment/jobs/raw/time_normalization_job.py +12 -0
- topos/enrichment/jobs/raw/tool_calls_job.py +12 -0
- topos/enrichment/models/__init__.py +1 -0
- topos/enrichment/models/manager.py +8 -0
- topos/enrichment/models/registry.py +71 -0
- topos/enrichment/models/versioning.py +8 -0
- topos/enrichment/orchestrator.py +177 -0
- topos/enrichment/processor.py +17 -0
- topos/enrichment/progress_bar.py +122 -0
- topos/enrichment/website_classifier.py +31 -0
- topos/filter_lab/__init__.py +1 -0
- topos/filter_lab/bundles.py +300 -0
- topos/filter_lab/schema.py +86 -0
- topos/filter_lab/service.py +167 -0
- topos/filter_lab/store.py +374 -0
- topos/filter_lab/worker.py +250 -0
- topos/hosted_pool_lease.py +153 -0
- topos/ingestion/__init__.py +1 -0
- topos/ingestion/checkpoints/__init__.py +6 -0
- topos/ingestion/checkpoints/checkpoint_store.py +24 -0
- topos/ingestion/checkpoints/sqlite_checkpoint_store.py +82 -0
- topos/ingestion/ingest_helpers.py +504 -0
- topos/ingestion/jobs.py +91 -0
- topos/ingestion/local_sync.py +823 -0
- topos/ingestion/log_preview.py +21 -0
- topos/ingestion/manager.py +1100 -0
- topos/ingestion/parser.py +174 -0
- topos/ingestion/parsers/__init__.py +32 -0
- topos/ingestion/parsers/base.py +24 -0
- topos/ingestion/parsers/browser_parser.py +171 -0
- topos/ingestion/parsers/calendar_parser.py +21 -0
- topos/ingestion/parsers/chatgpt_conversation_flattener.py +266 -0
- topos/ingestion/parsers/chatgpt_parser.py +67 -0
- topos/ingestion/parsers/grok_parser.py +21 -0
- topos/ingestion/parsers/messenger_parser.py +97 -0
- topos/ingestion/progress.py +54 -0
- topos/ingestion/sources/__init__.py +20 -0
- topos/ingestion/sources/base.py +39 -0
- topos/ingestion/sources/calendar.py +29 -0
- topos/ingestion/sources/chatgpt.py +29 -0
- topos/ingestion/sources/contact_importers.py +274 -0
- topos/ingestion/sources/grok.py +29 -0
- topos/ingestion/sources/imessage_reader.py +479 -0
- topos/ingestion/sources/signal_export_parser.py +132 -0
- topos/ingestion/sources/signal_reader.py +491 -0
- topos/ingestion/state_machine.py +70 -0
- topos/ingestion/triggers/__init__.py +1 -0
- topos/ingestion/triggers/file_trigger.py +36 -0
- topos/ingestion/triggers/sqlite_trigger.py +18 -0
- topos/ingestion/validation/__init__.py +1 -0
- topos/ingestion/validation/base.py +27 -0
- topos/ingestion/validation/schema_registry.py +111 -0
- topos/ingestion/validation/schema_validator.py +13 -0
- topos/lineage/__init__.py +1 -0
- topos/lineage/provenance.py +9 -0
- topos/lineage/tracker.py +9 -0
- topos/mcp_stdio_proxy.py +83 -0
- topos/observability/__init__.py +1 -0
- topos/observability/alerts.py +7 -0
- topos/observability/metrics.py +25 -0
- topos/observability/tracing.py +18 -0
- topos/openai_client.py +69 -0
- topos/projections/__init__.py +1 -0
- topos/projections/vector_index/__init__.py +1 -0
- topos/projections/vector_index/base.py +21 -0
- topos/projections/vector_index/builders.py +11 -0
- topos/projections/vector_index/health_checks.py +5 -0
- topos/rate_limit.py +43 -0
- topos/sanitization/__init__.py +16 -0
- topos/sanitization/ollama_transforms.py +276 -0
- topos/scope_resolution.py +89 -0
- topos/services/__init__.py +1 -0
- topos/services/container.py +46 -0
- topos/services/embeddings/__init__.py +1 -0
- topos/services/embeddings/base.py +7 -0
- topos/services/embeddings/local.py +9 -0
- topos/services/embeddings/remote.py +9 -0
- topos/services/interfaces.py +40 -0
- topos/services/llm/__init__.py +1 -0
- topos/services/llm/base.py +7 -0
- topos/services/llm/openai.py +126 -0
- topos/services/local.py +123 -0
- topos/services/postgres.py +385 -0
- topos/sources/__init__.py +6 -0
- topos/sources/definitions.py +114 -0
- topos/sources/install_service.py +836 -0
- topos/sources/registry.py +263 -0
- topos/sources/runtime_install.py +427 -0
- topos/storage/__init__.py +1 -0
- topos/storage/canonical/__init__.py +18 -0
- topos/storage/canonical/ai_chat/__init__.py +22 -0
- topos/storage/canonical/ai_chat/canonicalizer.py +147 -0
- topos/storage/canonical/ai_chat/mapper.py +168 -0
- topos/storage/canonical/ai_chat/model.py +87 -0
- topos/storage/canonical/ai_chat/tables.py +179 -0
- topos/storage/canonical/canonical_store.py +24 -0
- topos/storage/canonical/conversations_tables.py +1020 -0
- topos/storage/canonical/mapping_store.py +30 -0
- topos/storage/canonical/postgres.py +10 -0
- topos/storage/db/__init__.py +1 -0
- topos/storage/db/client.py +8 -0
- topos/storage/db/migrations/__init__.py +1 -0
- topos/storage/db/migrations/stage9_column_renames.py +78 -0
- topos/storage/db/paths.py +122 -0
- topos/storage/db/postgres.py +240 -0
- topos/storage/db/schema.py +6 -0
- topos/storage/enrichment/__init__.py +1 -0
- topos/storage/enrichment/canonical_enrichment_store.py +7 -0
- topos/storage/enrichment/raw_enrichment_store.py +18 -0
- topos/storage/normalized/__init__.py +1 -0
- topos/storage/normalized/normalized_store.py +24 -0
- topos/storage/oplog/__init__.py +1 -0
- topos/storage/oplog/decision.py +6 -0
- topos/storage/oplog/oplog_store.py +17 -0
- topos/storage/oplog/postgres.py +10 -0
- topos/storage/projections/__init__.py +1 -0
- topos/storage/projections/index_ops_store.py +6 -0
- topos/storage/projections/vector_index_store.py +6 -0
- topos/storage/raw/__init__.py +1 -0
- topos/storage/raw/browser_flat_tables.py +303 -0
- topos/storage/raw/file_store.py +100 -0
- topos/storage/raw/raw_store.py +29 -0
- topos/storage/raw/raw_tables_manager.py +295 -0
- topos/storage/raw/sqlite_raw_store.py +17 -0
- topos/storage/security/encryption.py +21 -0
- topos/storage/signal_identity.py +71 -0
- topos/storage/source_settings.py +116 -0
- topos/storage/user_identity.py +69 -0
- topos/sync/__init__.py +5 -0
- topos/sync/client.py +272 -0
- topos/sync_handlers.py +70 -0
- topos/testing/__init__.py +1 -0
- topos/testing/lifespan.py +7 -0
- topos/uma_contact_enrichment.py +1032 -0
- topos/uma_filters.py +669 -0
- topos/uma_resource_id.py +24 -0
- topos/uma_rpt.py +69 -0
- topos/utils/base_object.py +61 -0
- topos/websocket_client.py +21 -0
- topos_node-0.1.0.dist-info/METADATA +199 -0
- topos_node-0.1.0.dist-info/RECORD +249 -0
- topos_node-0.1.0.dist-info/WHEEL +5 -0
- topos_node-0.1.0.dist-info/entry_points.txt +2 -0
- topos_node-0.1.0.dist-info/licenses/LICENSE +201 -0
- topos_node-0.1.0.dist-info/top_level.txt +2 -0
topos/core/state.py
ADDED
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import asyncio
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import shutil
|
|
8
|
+
import sqlite3
|
|
9
|
+
import sys
|
|
10
|
+
import uuid
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, Optional
|
|
14
|
+
|
|
15
|
+
MCP_REQUEST_LOG_TABLE = "mcp_request_log"
|
|
16
|
+
UMA_ACCESS_REQUESTS_TABLE = "uma_access_requests"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def derive_uma_access_context(owner_user_id: str, requesting_user_id: Optional[str]) -> str:
|
|
20
|
+
"""Classify whether the caller is the resource owner or a grantee (or unknown)."""
|
|
21
|
+
rid = (requesting_user_id or "").strip()
|
|
22
|
+
if not rid:
|
|
23
|
+
return "unknown"
|
|
24
|
+
oid = (owner_user_id or "").strip()
|
|
25
|
+
if oid and oid == rid:
|
|
26
|
+
return "owner_self"
|
|
27
|
+
return "grantee"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def derive_mcp_access_context(resource_owner_user_id: Optional[str], requester_id: Optional[str]) -> str:
|
|
31
|
+
"""Classify MCP tool calls against this engine's linked owner user_id."""
|
|
32
|
+
rid = (requester_id or "").strip()
|
|
33
|
+
if not rid:
|
|
34
|
+
return "unknown"
|
|
35
|
+
oid = (resource_owner_user_id or "").strip()
|
|
36
|
+
if oid and oid == rid:
|
|
37
|
+
return "owner_self"
|
|
38
|
+
return "other"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
from ..analytics.duckdb_adapter import DuckDBAdapter
|
|
42
|
+
from ..config.settings import settings
|
|
43
|
+
from ..control_plane_client import ControlPlaneClient
|
|
44
|
+
from ..storage.db.paths import get_data_directory
|
|
45
|
+
from ..sync.client import SyncClient
|
|
46
|
+
from ..websocket_client import CloudWebSocketClient
|
|
47
|
+
from ..openai_client import OpenAIClient
|
|
48
|
+
|
|
49
|
+
logger = logging.getLogger("topos.core.state")
|
|
50
|
+
|
|
51
|
+
ws_client = CloudWebSocketClient(api_key=settings.gt_cloud_api_key)
|
|
52
|
+
openai_client = OpenAIClient()
|
|
53
|
+
control_plane_client: ControlPlaneClient | None = None
|
|
54
|
+
|
|
55
|
+
db_conn: sqlite3.Connection | None = None
|
|
56
|
+
_db_conn_path: str | None = None # resolved path for current db_conn; invalidate if settings path changes
|
|
57
|
+
oplog_manager: Any = None
|
|
58
|
+
projection_manager: Any = None
|
|
59
|
+
encryption_manager: Any = None
|
|
60
|
+
analytics: DuckDBAdapter | None = None
|
|
61
|
+
sync_client: SyncClient | None = None
|
|
62
|
+
engine_presence_task: asyncio.Task | None = None
|
|
63
|
+
hosted_pool_lease_client: Any = None
|
|
64
|
+
hosted_pool_lease_task: asyncio.Task | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _resolve_database_path_from_settings() -> Optional[Path]:
|
|
68
|
+
"""Pick the SQLite file path from settings and default search order.
|
|
69
|
+
|
|
70
|
+
When ``database_path`` is unset, the first existing file among canonical candidates wins
|
|
71
|
+
(same order as before). Intentionally does not log: this runs on almost every API call.
|
|
72
|
+
"""
|
|
73
|
+
from ..storage.db.paths import get_database_path
|
|
74
|
+
from ..config.settings import settings
|
|
75
|
+
|
|
76
|
+
if settings.topos_database_path:
|
|
77
|
+
return Path(settings.topos_database_path)
|
|
78
|
+
|
|
79
|
+
potential_paths: list[Path] = []
|
|
80
|
+
potential_paths.append(get_database_path(settings.topos_database_path))
|
|
81
|
+
if platform.system() == "Darwin":
|
|
82
|
+
engine_path = Path.home() / "Library" / "Application Support" / "ToposEngine" / "database.db"
|
|
83
|
+
potential_paths.append(engine_path)
|
|
84
|
+
legacy_path = Path.home() / ".topos_engine" / "database.db"
|
|
85
|
+
potential_paths.append(legacy_path)
|
|
86
|
+
simple_path = Path.home() / ".topos" / "database.db"
|
|
87
|
+
potential_paths.append(simple_path)
|
|
88
|
+
|
|
89
|
+
for path in potential_paths:
|
|
90
|
+
if path.exists() and path.is_file():
|
|
91
|
+
return path
|
|
92
|
+
return get_database_path(settings.topos_database_path)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_db_connection() -> Optional[sqlite3.Connection]:
|
|
96
|
+
"""Get or create database connection.
|
|
97
|
+
|
|
98
|
+
Returns existing connection if it matches the current configured path; otherwise opens the
|
|
99
|
+
correct file (tests may change ``DATABASE_PATH`` between imports; see ``_db_conn_path``).
|
|
100
|
+
"""
|
|
101
|
+
global db_conn, _db_conn_path
|
|
102
|
+
|
|
103
|
+
from ..config.settings import settings
|
|
104
|
+
|
|
105
|
+
# Respect injected in-memory sqlite handles used by tests, even if _db_conn_path
|
|
106
|
+
# still points to a stale file-backed connection from a prior run.
|
|
107
|
+
if db_conn is not None:
|
|
108
|
+
try:
|
|
109
|
+
row = db_conn.execute("PRAGMA database_list").fetchone()
|
|
110
|
+
if row is not None:
|
|
111
|
+
db_file = str(row[2] or "").strip() # seq, name, file
|
|
112
|
+
if db_file in {"", ":memory:"}:
|
|
113
|
+
_db_conn_path = None
|
|
114
|
+
return db_conn
|
|
115
|
+
if _db_conn_path is None:
|
|
116
|
+
_db_conn_path = str(Path(db_file).resolve())
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
if db_conn is not None and settings.topos_database_path and _db_conn_path:
|
|
121
|
+
try:
|
|
122
|
+
db_conn.execute("SELECT 1")
|
|
123
|
+
except (sqlite3.ProgrammingError, sqlite3.OperationalError):
|
|
124
|
+
db_conn = None
|
|
125
|
+
_db_conn_path = None
|
|
126
|
+
else:
|
|
127
|
+
try:
|
|
128
|
+
explicit_resolved = str(Path(settings.topos_database_path).resolve())
|
|
129
|
+
except (OSError, RuntimeError):
|
|
130
|
+
explicit_resolved = None
|
|
131
|
+
if explicit_resolved and explicit_resolved == _db_conn_path:
|
|
132
|
+
return db_conn
|
|
133
|
+
|
|
134
|
+
db_path: Path | None = None
|
|
135
|
+
try:
|
|
136
|
+
db_path = _resolve_database_path_from_settings()
|
|
137
|
+
except Exception as e:
|
|
138
|
+
logger.warning("Failed to determine database path: %s", e)
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
if not db_path:
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
resolved = str(db_path.resolve())
|
|
145
|
+
if db_conn is not None and _db_conn_path != resolved:
|
|
146
|
+
try:
|
|
147
|
+
db_conn.close()
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
db_conn = None
|
|
151
|
+
_db_conn_path = None
|
|
152
|
+
|
|
153
|
+
if db_conn is not None:
|
|
154
|
+
try:
|
|
155
|
+
db_conn.execute("SELECT 1")
|
|
156
|
+
return db_conn
|
|
157
|
+
except (sqlite3.ProgrammingError, sqlite3.OperationalError):
|
|
158
|
+
db_conn = None
|
|
159
|
+
_db_conn_path = None
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
db_conn = sqlite3.connect(str(db_path), check_same_thread=False)
|
|
164
|
+
db_conn.row_factory = sqlite3.Row
|
|
165
|
+
_db_conn_path = resolved
|
|
166
|
+
logger.debug("Created database connection: %s", db_path)
|
|
167
|
+
try:
|
|
168
|
+
from ..storage.raw.browser_flat_tables import backfill_browser_visits_from_raw_retention
|
|
169
|
+
|
|
170
|
+
backfill_browser_visits_from_raw_retention(db_conn)
|
|
171
|
+
except Exception as backfill_exc: # noqa: BLE001
|
|
172
|
+
logger.debug("browser_visits raw→flat backfill skipped: %s", backfill_exc)
|
|
173
|
+
return db_conn
|
|
174
|
+
except Exception as e:
|
|
175
|
+
logger.warning("Failed to create database connection: %s", e)
|
|
176
|
+
db_conn = None
|
|
177
|
+
_db_conn_path = None
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def get_or_create_user_id(conn: sqlite3.Connection) -> str:
|
|
182
|
+
"""Get existing user_id or create a new one.
|
|
183
|
+
|
|
184
|
+
Ensures engine_config table exists before accessing it.
|
|
185
|
+
"""
|
|
186
|
+
try:
|
|
187
|
+
_ensure_engine_config_table(conn)
|
|
188
|
+
cursor = conn.execute("SELECT value FROM engine_config WHERE key = 'user_id'")
|
|
189
|
+
row = cursor.fetchone()
|
|
190
|
+
if row:
|
|
191
|
+
return row["value"]
|
|
192
|
+
user_id = str(uuid.uuid4())
|
|
193
|
+
conn.execute(
|
|
194
|
+
"INSERT INTO engine_config (key, value) VALUES (?, ?)",
|
|
195
|
+
("user_id", user_id),
|
|
196
|
+
)
|
|
197
|
+
conn.commit()
|
|
198
|
+
return user_id
|
|
199
|
+
except Exception as exc:
|
|
200
|
+
logger.error("Failed to get or create user_id: %s", exc, exc_info=True)
|
|
201
|
+
raise
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def store_user_id(conn: sqlite3.Connection, user_id: str) -> None:
|
|
205
|
+
"""Store user_id in engine_config table.
|
|
206
|
+
|
|
207
|
+
Ensures engine_config table exists before writing.
|
|
208
|
+
"""
|
|
209
|
+
try:
|
|
210
|
+
_ensure_engine_config_table(conn)
|
|
211
|
+
conn.execute(
|
|
212
|
+
"INSERT OR REPLACE INTO engine_config (key, value) VALUES (?, ?)",
|
|
213
|
+
("user_id", user_id),
|
|
214
|
+
)
|
|
215
|
+
conn.commit()
|
|
216
|
+
except Exception as exc:
|
|
217
|
+
logger.error("Failed to store user_id: %s", exc, exc_info=True)
|
|
218
|
+
raise
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def get_user_id(conn: sqlite3.Connection) -> str | None:
|
|
222
|
+
"""Get user_id from engine_config table.
|
|
223
|
+
|
|
224
|
+
Ensures engine_config table exists before accessing it.
|
|
225
|
+
Returns None if user_id doesn't exist or on error.
|
|
226
|
+
"""
|
|
227
|
+
try:
|
|
228
|
+
_ensure_engine_config_table(conn)
|
|
229
|
+
cursor = conn.execute("SELECT value FROM engine_config WHERE key = 'user_id'")
|
|
230
|
+
row = cursor.fetchone()
|
|
231
|
+
if row:
|
|
232
|
+
return row["value"]
|
|
233
|
+
return None
|
|
234
|
+
except Exception as exc:
|
|
235
|
+
logger.warning("Failed to get user_id: %s", exc)
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _ensure_engine_config_table(conn: sqlite3.Connection) -> None:
|
|
240
|
+
"""Ensure engine_config table exists, creating it if necessary."""
|
|
241
|
+
try:
|
|
242
|
+
# Check if table exists
|
|
243
|
+
cursor = conn.execute(
|
|
244
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='engine_config'"
|
|
245
|
+
)
|
|
246
|
+
if not cursor.fetchone():
|
|
247
|
+
# Create table if it doesn't exist
|
|
248
|
+
logger.debug("Creating engine_config table...")
|
|
249
|
+
conn.execute("""
|
|
250
|
+
CREATE TABLE IF NOT EXISTS engine_config (
|
|
251
|
+
key TEXT PRIMARY KEY,
|
|
252
|
+
value TEXT NOT NULL,
|
|
253
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
254
|
+
)
|
|
255
|
+
""")
|
|
256
|
+
conn.commit()
|
|
257
|
+
logger.debug("engine_config table created successfully")
|
|
258
|
+
except Exception as exc:
|
|
259
|
+
logger.warning("Failed to ensure engine_config table exists: %s", exc)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def get_engine_config_value(conn: sqlite3.Connection, key: str) -> Optional[str]:
|
|
263
|
+
"""Get a value from engine_config table.
|
|
264
|
+
|
|
265
|
+
If the table doesn't exist, it will be created automatically.
|
|
266
|
+
Returns None if the key doesn't exist or on error.
|
|
267
|
+
"""
|
|
268
|
+
try:
|
|
269
|
+
_ensure_engine_config_table(conn)
|
|
270
|
+
cursor = conn.execute("SELECT value FROM engine_config WHERE key = ?", (key,))
|
|
271
|
+
row = cursor.fetchone()
|
|
272
|
+
if row:
|
|
273
|
+
return row["value"]
|
|
274
|
+
return None
|
|
275
|
+
except Exception as exc:
|
|
276
|
+
# Only log as warning for missing table (expected on fresh databases)
|
|
277
|
+
# Log as error for other issues
|
|
278
|
+
if "no such table" in str(exc).lower():
|
|
279
|
+
logger.debug("engine_config table not found, will be created on next write: %s", key)
|
|
280
|
+
else:
|
|
281
|
+
logger.warning("Failed to get engine_config[%s]: %s", key, exc)
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def set_engine_config_value(conn: sqlite3.Connection, key: str, value: str) -> None:
|
|
286
|
+
"""Set a value in engine_config table.
|
|
287
|
+
|
|
288
|
+
If the table doesn't exist, it will be created automatically.
|
|
289
|
+
"""
|
|
290
|
+
try:
|
|
291
|
+
_ensure_engine_config_table(conn)
|
|
292
|
+
conn.execute(
|
|
293
|
+
"INSERT OR REPLACE INTO engine_config (key, value) VALUES (?, ?)",
|
|
294
|
+
(key, value),
|
|
295
|
+
)
|
|
296
|
+
conn.commit()
|
|
297
|
+
except Exception as exc:
|
|
298
|
+
logger.error("Failed to set engine_config[%s]: %s", key, exc, exc_info=True)
|
|
299
|
+
raise
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _ensure_mcp_request_log_table(conn: sqlite3.Connection) -> None:
|
|
303
|
+
"""Ensure mcp_request_log table exists (engine-owned MCP request counting). Includes source and requester_id for per-source display."""
|
|
304
|
+
try:
|
|
305
|
+
cursor = conn.execute(
|
|
306
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
|
|
307
|
+
(MCP_REQUEST_LOG_TABLE,),
|
|
308
|
+
)
|
|
309
|
+
if not cursor.fetchone():
|
|
310
|
+
conn.execute(f"""
|
|
311
|
+
CREATE TABLE IF NOT EXISTS {MCP_REQUEST_LOG_TABLE} (
|
|
312
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
313
|
+
tool_name TEXT NOT NULL,
|
|
314
|
+
requested_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
315
|
+
source TEXT,
|
|
316
|
+
requester_id TEXT,
|
|
317
|
+
access_context TEXT
|
|
318
|
+
)
|
|
319
|
+
""")
|
|
320
|
+
conn.commit()
|
|
321
|
+
logger.debug("mcp_request_log table created successfully")
|
|
322
|
+
return
|
|
323
|
+
# Add source/requester_id columns if missing (migration for existing DBs)
|
|
324
|
+
cursor = conn.execute(f"PRAGMA table_info({MCP_REQUEST_LOG_TABLE})")
|
|
325
|
+
columns = [row[1] for row in cursor.fetchall()]
|
|
326
|
+
if "source" not in columns:
|
|
327
|
+
conn.execute(f"ALTER TABLE {MCP_REQUEST_LOG_TABLE} ADD COLUMN source TEXT")
|
|
328
|
+
conn.commit()
|
|
329
|
+
if "requester_id" not in columns:
|
|
330
|
+
conn.execute(f"ALTER TABLE {MCP_REQUEST_LOG_TABLE} ADD COLUMN requester_id TEXT")
|
|
331
|
+
conn.commit()
|
|
332
|
+
if "access_context" not in columns:
|
|
333
|
+
conn.execute(f"ALTER TABLE {MCP_REQUEST_LOG_TABLE} ADD COLUMN access_context TEXT")
|
|
334
|
+
conn.commit()
|
|
335
|
+
except Exception as exc:
|
|
336
|
+
logger.warning("Failed to ensure mcp_request_log table exists: %s", exc)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def record_mcp_request(
|
|
340
|
+
conn: Optional[sqlite3.Connection],
|
|
341
|
+
tool_name: str,
|
|
342
|
+
source: Optional[str] = None,
|
|
343
|
+
requester_id: Optional[str] = None,
|
|
344
|
+
resource_owner_user_id: Optional[str] = None,
|
|
345
|
+
) -> None:
|
|
346
|
+
"""
|
|
347
|
+
Record one MCP tool invocation in the engine's DB.
|
|
348
|
+
source: e.g. "claude_desktop", "chatgpt", "local". requester_id: identity of requester (e.g. user sub or engine key prefix).
|
|
349
|
+
resource_owner_user_id: engine-linked owner; used to set access_context (owner_self vs other).
|
|
350
|
+
"""
|
|
351
|
+
if not conn or not tool_name:
|
|
352
|
+
return
|
|
353
|
+
try:
|
|
354
|
+
_ensure_mcp_request_log_table(conn)
|
|
355
|
+
requested_at = datetime.now(timezone.utc).isoformat()
|
|
356
|
+
access_ctx = derive_mcp_access_context(resource_owner_user_id, requester_id)
|
|
357
|
+
conn.execute(
|
|
358
|
+
f"INSERT INTO {MCP_REQUEST_LOG_TABLE} (tool_name, requested_at, source, requester_id, access_context) VALUES (?, ?, ?, ?, ?)",
|
|
359
|
+
(tool_name, requested_at, source or None, requester_id or None, access_ctx),
|
|
360
|
+
)
|
|
361
|
+
conn.commit()
|
|
362
|
+
except Exception as exc:
|
|
363
|
+
logger.debug("Failed to record MCP request (non-critical): %s", exc)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _ensure_uma_access_requests_table(conn: sqlite3.Connection) -> None:
|
|
367
|
+
"""Ensure uma_access_requests table exists (engine-owned UMA request counting)."""
|
|
368
|
+
try:
|
|
369
|
+
cursor = conn.execute(
|
|
370
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
|
|
371
|
+
(UMA_ACCESS_REQUESTS_TABLE,),
|
|
372
|
+
)
|
|
373
|
+
if not cursor.fetchone():
|
|
374
|
+
conn.execute(f"""
|
|
375
|
+
CREATE TABLE IF NOT EXISTS {UMA_ACCESS_REQUESTS_TABLE} (
|
|
376
|
+
id TEXT PRIMARY KEY,
|
|
377
|
+
owner_user_id TEXT NOT NULL,
|
|
378
|
+
requesting_user_id TEXT,
|
|
379
|
+
requesting_user_email TEXT,
|
|
380
|
+
app_id TEXT,
|
|
381
|
+
resource_id TEXT NOT NULL,
|
|
382
|
+
request_type TEXT NOT NULL,
|
|
383
|
+
endpoint TEXT NOT NULL,
|
|
384
|
+
access_channel TEXT,
|
|
385
|
+
access_context TEXT,
|
|
386
|
+
created_at TEXT NOT NULL
|
|
387
|
+
)
|
|
388
|
+
""")
|
|
389
|
+
conn.commit()
|
|
390
|
+
logger.debug("uma_access_requests table created successfully")
|
|
391
|
+
return
|
|
392
|
+
cursor = conn.execute(f"PRAGMA table_info({UMA_ACCESS_REQUESTS_TABLE})")
|
|
393
|
+
columns = [row[1] for row in cursor.fetchall()]
|
|
394
|
+
for col, ddl in (
|
|
395
|
+
("requesting_user_email", "TEXT"),
|
|
396
|
+
("access_channel", "TEXT"),
|
|
397
|
+
("access_context", "TEXT"),
|
|
398
|
+
):
|
|
399
|
+
if col not in columns:
|
|
400
|
+
conn.execute(f"ALTER TABLE {UMA_ACCESS_REQUESTS_TABLE} ADD COLUMN {col} {ddl}")
|
|
401
|
+
conn.commit()
|
|
402
|
+
except Exception as exc:
|
|
403
|
+
logger.warning("Failed to ensure uma_access_requests table exists: %s", exc)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def record_uma_request(
|
|
407
|
+
conn: Optional[sqlite3.Connection],
|
|
408
|
+
owner_user_id: str,
|
|
409
|
+
resource_id: str,
|
|
410
|
+
request_type: str,
|
|
411
|
+
endpoint: str,
|
|
412
|
+
requesting_user_id: Optional[str] = None,
|
|
413
|
+
app_id: Optional[str] = None,
|
|
414
|
+
*,
|
|
415
|
+
requesting_user_email: Optional[str] = None,
|
|
416
|
+
access_channel: Optional[str] = None,
|
|
417
|
+
access_context: Optional[str] = None,
|
|
418
|
+
) -> None:
|
|
419
|
+
"""
|
|
420
|
+
Record one UMA access request in the engine's DB (read or write from connected apps).
|
|
421
|
+
Mirror of CP's Supabase recording; engine owns this data for request counts.
|
|
422
|
+
"""
|
|
423
|
+
if not conn or not owner_user_id or not resource_id or not request_type or not endpoint:
|
|
424
|
+
return
|
|
425
|
+
try:
|
|
426
|
+
_ensure_uma_access_requests_table(conn)
|
|
427
|
+
row_id = str(uuid.uuid4())
|
|
428
|
+
created_at = datetime.now(timezone.utc).isoformat()
|
|
429
|
+
ctx = access_context or derive_uma_access_context(owner_user_id, requesting_user_id)
|
|
430
|
+
ch = (access_channel or "").strip() or None
|
|
431
|
+
email = (requesting_user_email or "").strip() or None
|
|
432
|
+
conn.execute(
|
|
433
|
+
f"""INSERT INTO {UMA_ACCESS_REQUESTS_TABLE}
|
|
434
|
+
(id, owner_user_id, requesting_user_id, requesting_user_email, app_id, resource_id, request_type, endpoint, access_channel, access_context, created_at)
|
|
435
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
436
|
+
(
|
|
437
|
+
row_id,
|
|
438
|
+
owner_user_id,
|
|
439
|
+
(requesting_user_id or "").strip() or None,
|
|
440
|
+
email,
|
|
441
|
+
app_id or None,
|
|
442
|
+
resource_id,
|
|
443
|
+
request_type,
|
|
444
|
+
endpoint,
|
|
445
|
+
ch,
|
|
446
|
+
ctx,
|
|
447
|
+
created_at,
|
|
448
|
+
),
|
|
449
|
+
)
|
|
450
|
+
conn.commit()
|
|
451
|
+
except Exception as exc:
|
|
452
|
+
logger.debug("Failed to record UMA request (non-critical): %s", exc)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def get_uma_request_counts(
|
|
456
|
+
conn: Optional[sqlite3.Connection],
|
|
457
|
+
owner_user_id: str,
|
|
458
|
+
since_days: Optional[int] = 90,
|
|
459
|
+
) -> Dict[str, Any]:
|
|
460
|
+
"""Return UMA request counts from engine DB. Extends CP shape with attribution + top requesters."""
|
|
461
|
+
from datetime import timedelta
|
|
462
|
+
out: Dict[str, Any] = {
|
|
463
|
+
"total_read_requests": 0,
|
|
464
|
+
"total_write_requests": 0,
|
|
465
|
+
"by_app": [],
|
|
466
|
+
"by_requesting_user": [],
|
|
467
|
+
"access_attribution": {
|
|
468
|
+
"window_days": since_days or 0,
|
|
469
|
+
"owner_self_reads": 0,
|
|
470
|
+
"owner_self_writes": 0,
|
|
471
|
+
"grantee_reads": 0,
|
|
472
|
+
"grantee_writes": 0,
|
|
473
|
+
"unknown_reads": 0,
|
|
474
|
+
"unknown_writes": 0,
|
|
475
|
+
},
|
|
476
|
+
}
|
|
477
|
+
if not conn or not owner_user_id:
|
|
478
|
+
return out
|
|
479
|
+
try:
|
|
480
|
+
_ensure_uma_access_requests_table(conn)
|
|
481
|
+
for req_type in ("read", "write"):
|
|
482
|
+
cursor = conn.execute(
|
|
483
|
+
f"SELECT COUNT(*) FROM {UMA_ACCESS_REQUESTS_TABLE} WHERE owner_user_id = ? AND request_type = ?",
|
|
484
|
+
(owner_user_id, req_type),
|
|
485
|
+
)
|
|
486
|
+
row = cursor.fetchone()
|
|
487
|
+
count = row[0] if row else 0
|
|
488
|
+
if req_type == "read":
|
|
489
|
+
out["total_read_requests"] = count
|
|
490
|
+
else:
|
|
491
|
+
out["total_write_requests"] = count
|
|
492
|
+
if since_days and since_days > 0:
|
|
493
|
+
since_ts = (datetime.now(timezone.utc) - timedelta(days=since_days)).isoformat()
|
|
494
|
+
cursor = conn.execute(
|
|
495
|
+
f"SELECT app_id, request_type FROM {UMA_ACCESS_REQUESTS_TABLE} WHERE owner_user_id = ? AND created_at >= ?",
|
|
496
|
+
(owner_user_id, since_ts),
|
|
497
|
+
)
|
|
498
|
+
by_app: Dict[str, Dict[str, int]] = {}
|
|
499
|
+
for row in cursor.fetchall():
|
|
500
|
+
app_id_val = (row[0] or "") if len(row) > 0 else ""
|
|
501
|
+
rt = (row[1] or "") if len(row) > 1 else ""
|
|
502
|
+
if app_id_val not in by_app:
|
|
503
|
+
by_app[app_id_val] = {"read_requests": 0, "write_requests": 0}
|
|
504
|
+
if rt == "read":
|
|
505
|
+
by_app[app_id_val]["read_requests"] += 1
|
|
506
|
+
elif rt == "write":
|
|
507
|
+
by_app[app_id_val]["write_requests"] += 1
|
|
508
|
+
out["by_app"] = [
|
|
509
|
+
{"app_id": aid, "app_name": None, "read_requests": d["read_requests"], "write_requests": d["write_requests"]}
|
|
510
|
+
for aid, d in sorted(by_app.items())
|
|
511
|
+
]
|
|
512
|
+
|
|
513
|
+
cursor = conn.execute(
|
|
514
|
+
f"""SELECT requesting_user_id, requesting_user_email, request_type, access_context
|
|
515
|
+
FROM {UMA_ACCESS_REQUESTS_TABLE}
|
|
516
|
+
WHERE owner_user_id = ? AND created_at >= ?""",
|
|
517
|
+
(owner_user_id, since_ts),
|
|
518
|
+
)
|
|
519
|
+
by_req: Dict[str, Dict[str, Any]] = {}
|
|
520
|
+
attr = out["access_attribution"]
|
|
521
|
+
attr["window_days"] = int(since_days)
|
|
522
|
+
for row in cursor.fetchall():
|
|
523
|
+
rid = (row[0] or "").strip() if len(row) > 0 else ""
|
|
524
|
+
remail = (row[1] or "").strip() if len(row) > 1 else ""
|
|
525
|
+
rt = (row[2] or "").strip().lower() if len(row) > 2 else ""
|
|
526
|
+
actx = (row[3] or "").strip().lower() if len(row) > 3 else ""
|
|
527
|
+
if not actx:
|
|
528
|
+
actx = derive_uma_access_context(owner_user_id, rid or None)
|
|
529
|
+
is_read = rt == "read"
|
|
530
|
+
is_write = rt == "write"
|
|
531
|
+
if actx == "owner_self":
|
|
532
|
+
if is_read:
|
|
533
|
+
attr["owner_self_reads"] += 1
|
|
534
|
+
elif is_write:
|
|
535
|
+
attr["owner_self_writes"] += 1
|
|
536
|
+
elif actx == "grantee":
|
|
537
|
+
if is_read:
|
|
538
|
+
attr["grantee_reads"] += 1
|
|
539
|
+
elif is_write:
|
|
540
|
+
attr["grantee_writes"] += 1
|
|
541
|
+
else:
|
|
542
|
+
if is_read:
|
|
543
|
+
attr["unknown_reads"] += 1
|
|
544
|
+
elif is_write:
|
|
545
|
+
attr["unknown_writes"] += 1
|
|
546
|
+
key = rid if rid else (f"unknown:{remail}" if remail else "unknown")
|
|
547
|
+
if key not in by_req:
|
|
548
|
+
by_req[key] = {
|
|
549
|
+
"requesting_user_id": rid or None,
|
|
550
|
+
"requesting_user_email": remail or None,
|
|
551
|
+
"read_requests": 0,
|
|
552
|
+
"write_requests": 0,
|
|
553
|
+
}
|
|
554
|
+
if is_read:
|
|
555
|
+
by_req[key]["read_requests"] += 1
|
|
556
|
+
elif is_write:
|
|
557
|
+
by_req[key]["write_requests"] += 1
|
|
558
|
+
ranked = []
|
|
559
|
+
for _k, d in by_req.items():
|
|
560
|
+
tr = int(d["read_requests"]) + int(d["write_requests"])
|
|
561
|
+
ranked.append({**d, "total_requests": tr})
|
|
562
|
+
ranked.sort(key=lambda x: -x["total_requests"])
|
|
563
|
+
out["by_requesting_user"] = ranked[:50]
|
|
564
|
+
except Exception as exc:
|
|
565
|
+
logger.debug("get_uma_request_counts failed: %s", exc)
|
|
566
|
+
return out
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def get_mcp_request_counts(
|
|
570
|
+
conn: Optional[sqlite3.Connection],
|
|
571
|
+
since_days: Optional[int] = 90,
|
|
572
|
+
) -> Dict[str, Any]:
|
|
573
|
+
"""Return MCP request counts from engine DB: by_source (identity + count), by_tool, by_access_context, total."""
|
|
574
|
+
from datetime import timedelta
|
|
575
|
+
out: Dict[str, Any] = {
|
|
576
|
+
"by_source": [],
|
|
577
|
+
"by_tool": [],
|
|
578
|
+
"by_access_context": [],
|
|
579
|
+
"total": 0,
|
|
580
|
+
}
|
|
581
|
+
if not conn:
|
|
582
|
+
return out
|
|
583
|
+
try:
|
|
584
|
+
_ensure_mcp_request_log_table(conn)
|
|
585
|
+
since_ts = (datetime.now(timezone.utc) - timedelta(days=since_days)).isoformat() if (since_days and since_days > 0) else None
|
|
586
|
+
if since_ts:
|
|
587
|
+
cursor = conn.execute(
|
|
588
|
+
f"SELECT source, requester_id, access_context FROM {MCP_REQUEST_LOG_TABLE} WHERE requested_at >= ?",
|
|
589
|
+
(since_ts,),
|
|
590
|
+
)
|
|
591
|
+
else:
|
|
592
|
+
cursor = conn.execute(f"SELECT source, requester_id, access_context FROM {MCP_REQUEST_LOG_TABLE}")
|
|
593
|
+
rows = cursor.fetchall()
|
|
594
|
+
by_key: Dict[tuple, int] = {}
|
|
595
|
+
by_ctx: Dict[str, int] = {}
|
|
596
|
+
for row in rows:
|
|
597
|
+
src = (row[0] or "") if len(row) > 0 else ""
|
|
598
|
+
rid = (row[1] or "") if len(row) > 1 else ""
|
|
599
|
+
actx = (row[2] or "").strip() if len(row) > 2 else ""
|
|
600
|
+
if not actx:
|
|
601
|
+
actx = "unknown"
|
|
602
|
+
key = (src, rid)
|
|
603
|
+
by_key[key] = by_key.get(key, 0) + 1
|
|
604
|
+
by_ctx[actx] = by_ctx.get(actx, 0) + 1
|
|
605
|
+
out["by_source"] = [
|
|
606
|
+
{"source": src, "requester_id": rid or None, "count": c}
|
|
607
|
+
for (src, rid), c in sorted(by_key.items(), key=lambda x: -x[1])
|
|
608
|
+
]
|
|
609
|
+
out["by_access_context"] = [
|
|
610
|
+
{"access_context": k, "count": v} for k, v in sorted(by_ctx.items(), key=lambda x: -x[1])
|
|
611
|
+
]
|
|
612
|
+
if since_ts:
|
|
613
|
+
cursor = conn.execute(
|
|
614
|
+
f"SELECT tool_name FROM {MCP_REQUEST_LOG_TABLE} WHERE requested_at >= ?",
|
|
615
|
+
(since_ts,),
|
|
616
|
+
)
|
|
617
|
+
else:
|
|
618
|
+
cursor = conn.execute(f"SELECT tool_name FROM {MCP_REQUEST_LOG_TABLE}")
|
|
619
|
+
tool_rows = cursor.fetchall()
|
|
620
|
+
by_tool: Dict[str, int] = {}
|
|
621
|
+
for row in tool_rows:
|
|
622
|
+
t = (row[0] or "") if row else ""
|
|
623
|
+
by_tool[t] = by_tool.get(t, 0) + 1
|
|
624
|
+
out["by_tool"] = [{"tool_name": t, "count": c} for t, c in sorted(by_tool.items(), key=lambda x: -x[1])]
|
|
625
|
+
out["total"] = sum(by_tool.values())
|
|
626
|
+
except Exception as exc:
|
|
627
|
+
logger.debug("get_mcp_request_counts failed: %s", exc)
|
|
628
|
+
return out
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def get_engine_mode() -> str:
|
|
632
|
+
mode = (settings.engine_mode or "full").strip().lower()
|
|
633
|
+
if mode not in {"full", "sync"}:
|
|
634
|
+
return "full"
|
|
635
|
+
if mode == "sync":
|
|
636
|
+
return "sync"
|
|
637
|
+
return "full" if settings.enable_llm else "sync"
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def get_engine_class() -> str:
|
|
641
|
+
return "sync_engine" if get_engine_mode() == "sync" else "full_engine"
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def get_total_memory_bytes() -> Optional[int]:
|
|
645
|
+
try:
|
|
646
|
+
if hasattr(os, "sysconf"):
|
|
647
|
+
page_size = os.sysconf("SC_PAGE_SIZE")
|
|
648
|
+
pages = os.sysconf("SC_PHYS_PAGES")
|
|
649
|
+
if isinstance(page_size, int) and isinstance(pages, int):
|
|
650
|
+
return page_size * pages
|
|
651
|
+
except Exception:
|
|
652
|
+
return None
|
|
653
|
+
return None
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def get_system_info() -> Dict[str, Any]:
|
|
657
|
+
disk_total = None
|
|
658
|
+
disk_free = None
|
|
659
|
+
try:
|
|
660
|
+
# Get disk usage for the root filesystem (whole device)
|
|
661
|
+
root_path = Path("/") if platform.system() != "Windows" else Path("C:\\")
|
|
662
|
+
usage = shutil.disk_usage(root_path)
|
|
663
|
+
disk_total = usage.total
|
|
664
|
+
disk_free = usage.free
|
|
665
|
+
except Exception as exc:
|
|
666
|
+
logger.warning("Failed to get disk usage: %s", exc)
|
|
667
|
+
disk_total = None
|
|
668
|
+
disk_free = None
|
|
669
|
+
return {
|
|
670
|
+
"hostname": platform.node() or None,
|
|
671
|
+
"os": platform.system() or None,
|
|
672
|
+
"os_version": platform.version() or None,
|
|
673
|
+
"platform": platform.platform() or None,
|
|
674
|
+
"arch": platform.machine() or None,
|
|
675
|
+
"cpu_count": os.cpu_count(),
|
|
676
|
+
"memory_total_bytes": get_total_memory_bytes(),
|
|
677
|
+
"storage_total_bytes": disk_total,
|
|
678
|
+
"storage_free_bytes": disk_free,
|
|
679
|
+
"python_version": sys.version.split()[0],
|
|
680
|
+
"gpu_name": None,
|
|
681
|
+
"gpu_memory_bytes": None,
|
|
682
|
+
}
|