contextwall 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.
- context_firewall/__init__.py +3 -0
- context_firewall/analytics/__init__.py +5 -0
- context_firewall/analytics/engine.py +234 -0
- context_firewall/api/__init__.py +5 -0
- context_firewall/api/app.py +1020 -0
- context_firewall/api/models.py +54 -0
- context_firewall/classifier/__init__.py +5 -0
- context_firewall/classifier/classifier.py +165 -0
- context_firewall/cli/__init__.py +5 -0
- context_firewall/cli/main.py +513 -0
- context_firewall/compliance/__init__.py +1 -0
- context_firewall/compliance/baa.py +57 -0
- context_firewall/compliance/chain.py +84 -0
- context_firewall/compliance/control_mappings.py +92 -0
- context_firewall/compliance/export.py +280 -0
- context_firewall/compliance/keys.py +94 -0
- context_firewall/config.py +234 -0
- context_firewall/control_plane/__init__.py +0 -0
- context_firewall/control_plane/client.py +107 -0
- context_firewall/control_plane/models.py +72 -0
- context_firewall/control_plane/pusher.py +377 -0
- context_firewall/daemon/__init__.py +5 -0
- context_firewall/daemon/main.py +461 -0
- context_firewall/db/__init__.py +6 -0
- context_firewall/db/connection.py +30 -0
- context_firewall/db/migrations.py +408 -0
- context_firewall/entropy/__init__.py +5 -0
- context_firewall/entropy/engine.py +271 -0
- context_firewall/graph/__init__.py +5 -0
- context_firewall/graph/engine.py +162 -0
- context_firewall/lint/__init__.py +0 -0
- context_firewall/lint/engine.py +334 -0
- context_firewall/mcp/__init__.py +5 -0
- context_firewall/mcp/server.py +237 -0
- context_firewall/metrics.py +100 -0
- context_firewall/models.py +130 -0
- context_firewall/policy/__init__.py +10 -0
- context_firewall/policy/detectors/__init__.py +0 -0
- context_firewall/policy/detectors/injection.py +480 -0
- context_firewall/policy/dsl/__init__.py +0 -0
- context_firewall/policy/dsl/evaluator.py +208 -0
- context_firewall/policy/dsl/loader.py +150 -0
- context_firewall/policy/dsl/types.py +112 -0
- context_firewall/policy/engine.py +532 -0
- context_firewall/provenance/__init__.py +5 -0
- context_firewall/provenance/engine.py +658 -0
- context_firewall/provenance/models.py +91 -0
- context_firewall/proxy/__init__.py +1 -0
- context_firewall/proxy/router.py +519 -0
- context_firewall/proxy/scanner.py +214 -0
- context_firewall/proxy/tokens.py +156 -0
- context_firewall/runtime/__init__.py +5 -0
- context_firewall/runtime/engine.py +303 -0
- context_firewall/source/__init__.py +1 -0
- context_firewall/source/registry.py +299 -0
- context_firewall/source/types.py +12 -0
- context_firewall/synthesizer/__init__.py +5 -0
- context_firewall/synthesizer/synthesizer.py +255 -0
- context_firewall/trust/__init__.py +5 -0
- context_firewall/trust/engine.py +139 -0
- context_firewall/trust/signals.py +184 -0
- contextwall-0.1.0.dist-info/METADATA +431 -0
- contextwall-0.1.0.dist-info/RECORD +66 -0
- contextwall-0.1.0.dist-info/WHEEL +4 -0
- contextwall-0.1.0.dist-info/entry_points.txt +3 -0
- contextwall-0.1.0.dist-info/licenses/LICENSE +33 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
"""ContextWall Daemon - single-process asyncio event loop embedding all engines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import signal
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import uvicorn
|
|
15
|
+
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
16
|
+
|
|
17
|
+
from context_firewall.config import Config, load_config
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
logging.basicConfig(
|
|
22
|
+
level=logging.INFO,
|
|
23
|
+
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def _seed_git_metadata(db_path: str, repository_root: str) -> None:
|
|
28
|
+
"""
|
|
29
|
+
Populate git_metadata table from git history for the indexed repository.
|
|
30
|
+
|
|
31
|
+
This runs on startup so trust scoring has real freshness and stability
|
|
32
|
+
signals instead of defaulting everything to 0.5 (neutral).
|
|
33
|
+
"""
|
|
34
|
+
import aiosqlite
|
|
35
|
+
|
|
36
|
+
if not repository_root or not Path(repository_root).exists():
|
|
37
|
+
logger.info("git metadata seed skipped: no repository root")
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
# git log: output format is "ISO_DATE FILEPATH" for each file changed per commit
|
|
42
|
+
result = subprocess.run(
|
|
43
|
+
[
|
|
44
|
+
"git", "log",
|
|
45
|
+
"--name-only",
|
|
46
|
+
"--format=COMMIT %ai",
|
|
47
|
+
"--no-merges",
|
|
48
|
+
"--diff-filter=ACMR", # Added, Copied, Modified, Renamed
|
|
49
|
+
"-500", # last 500 commits
|
|
50
|
+
],
|
|
51
|
+
cwd=repository_root,
|
|
52
|
+
capture_output=True,
|
|
53
|
+
text=True,
|
|
54
|
+
timeout=30,
|
|
55
|
+
)
|
|
56
|
+
if result.returncode != 0:
|
|
57
|
+
logger.info("git log unavailable: %s", result.stderr[:100])
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
# Parse: track last commit date and commit count per file in last 30 days
|
|
61
|
+
file_last_commit: dict[str, str] = {}
|
|
62
|
+
file_commit_count_30d: dict[str, int] = {}
|
|
63
|
+
now = datetime.now(timezone.utc)
|
|
64
|
+
|
|
65
|
+
current_date: str | None = None
|
|
66
|
+
for line in result.stdout.splitlines():
|
|
67
|
+
line = line.strip()
|
|
68
|
+
if not line:
|
|
69
|
+
continue
|
|
70
|
+
if line.startswith("COMMIT "):
|
|
71
|
+
current_date = line[7:].strip()
|
|
72
|
+
elif current_date and not line.startswith("commit "):
|
|
73
|
+
filepath = line
|
|
74
|
+
if filepath not in file_last_commit:
|
|
75
|
+
file_last_commit[filepath] = current_date
|
|
76
|
+
# Count commits in last 30 days
|
|
77
|
+
try:
|
|
78
|
+
commit_dt = datetime.fromisoformat(current_date)
|
|
79
|
+
if commit_dt.tzinfo is None:
|
|
80
|
+
commit_dt = commit_dt.replace(tzinfo=timezone.utc)
|
|
81
|
+
age_days = (now - commit_dt).days
|
|
82
|
+
if age_days <= 30:
|
|
83
|
+
file_commit_count_30d[filepath] = file_commit_count_30d.get(filepath, 0) + 1
|
|
84
|
+
except ValueError:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
if not file_last_commit:
|
|
88
|
+
logger.info("git metadata: no commits found")
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
indexed_at = now.isoformat()
|
|
92
|
+
async with aiosqlite.connect(db_path) as db:
|
|
93
|
+
await db.executemany(
|
|
94
|
+
"""
|
|
95
|
+
INSERT OR REPLACE INTO git_metadata
|
|
96
|
+
(file_path, last_commit_at, commit_count_30d, author_count, indexed_at)
|
|
97
|
+
VALUES (?, ?, ?, ?, ?)
|
|
98
|
+
""",
|
|
99
|
+
[
|
|
100
|
+
(
|
|
101
|
+
fp,
|
|
102
|
+
file_last_commit[fp],
|
|
103
|
+
file_commit_count_30d.get(fp, 0),
|
|
104
|
+
1, # author_count: would need git log --format=%ae to compute exactly
|
|
105
|
+
indexed_at,
|
|
106
|
+
)
|
|
107
|
+
for fp in file_last_commit
|
|
108
|
+
],
|
|
109
|
+
)
|
|
110
|
+
await db.commit()
|
|
111
|
+
|
|
112
|
+
logger.info(
|
|
113
|
+
"git metadata seeded",
|
|
114
|
+
extra={"files": len(file_last_commit), "repo": repository_root},
|
|
115
|
+
)
|
|
116
|
+
except subprocess.TimeoutExpired:
|
|
117
|
+
logger.warning("git metadata seed timed out")
|
|
118
|
+
except Exception as e:
|
|
119
|
+
logger.warning("git metadata seed failed (non-fatal)", extra={"error": str(e)})
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def run_daemon(config: Config, host: str = "0.0.0.0") -> None:
|
|
123
|
+
db_path = config.storage.db_path
|
|
124
|
+
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
|
|
126
|
+
# ── Startup validation ────────────────────────────────────────────────────
|
|
127
|
+
if config.rest_api.auth.enabled:
|
|
128
|
+
tokens = config.rest_api.auth.tokens
|
|
129
|
+
if not tokens:
|
|
130
|
+
logger.error(
|
|
131
|
+
"STARTUP BLOCKED: auth.enabled=true but no tokens configured. "
|
|
132
|
+
"Add at least one token to ctxfw.yaml or set CRE_API_TOKEN."
|
|
133
|
+
)
|
|
134
|
+
sys.exit(1)
|
|
135
|
+
_WEAK = {"", "changeme", "demo-token", "secret", "password", "token", "${CRE_API_TOKEN}"}
|
|
136
|
+
weak = [t for t in tokens if t.get("token", "") in _WEAK]
|
|
137
|
+
if weak:
|
|
138
|
+
logger.error(
|
|
139
|
+
"STARTUP BLOCKED: auth token is set to a known-weak default ('%s'). "
|
|
140
|
+
"Set a real random secret in ctxfw.yaml or via the CRE_API_TOKEN env var. "
|
|
141
|
+
"Generate one with: python3 -c \"import secrets; print(secrets.token_urlsafe(32))\"",
|
|
142
|
+
weak[0].get("token"),
|
|
143
|
+
)
|
|
144
|
+
sys.exit(1)
|
|
145
|
+
|
|
146
|
+
if not config.compliance_hmac_key:
|
|
147
|
+
logger.warning(
|
|
148
|
+
"compliance_hmac_key not set - compliance bundles will use a dev key and are NOT "
|
|
149
|
+
"tamper-evident. Set CRE_COMPLIANCE_HMAC_KEY or compliance_hmac_key in ctxfw.yaml. "
|
|
150
|
+
"Generate one with: python3 -c \"import secrets; print(secrets.token_urlsafe(32))\""
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# ── DB path configuration ─────────────────────────────────────────────────
|
|
154
|
+
from context_firewall.db.connection import configure_db_path
|
|
155
|
+
configure_db_path(db_path)
|
|
156
|
+
|
|
157
|
+
# ── Schema migrations ──────────────────────────────────────────────────────
|
|
158
|
+
from context_firewall.db.migrations import run_migrations
|
|
159
|
+
await run_migrations(db_path)
|
|
160
|
+
logger.info("SQLite migrations complete", extra={"db_path": db_path})
|
|
161
|
+
|
|
162
|
+
# ── Instantiate engines ───────────────────────────────────────────────────
|
|
163
|
+
from context_firewall.classifier.classifier import classify_task, build_pipeline_context
|
|
164
|
+
from context_firewall.graph.engine import RepositoryGraphEngine
|
|
165
|
+
from context_firewall.trust.engine import TrustScoringEngine
|
|
166
|
+
from context_firewall.entropy.engine import ContextEntropyEngine
|
|
167
|
+
from context_firewall.runtime.engine import RuntimeCorrelationEngine
|
|
168
|
+
from context_firewall.policy.engine import PolicyEngine
|
|
169
|
+
from context_firewall.provenance.engine import ProvenanceEngine
|
|
170
|
+
from context_firewall.synthesizer.synthesizer import ContextSynthesizer
|
|
171
|
+
from context_firewall.analytics.engine import AnalyticsEngine
|
|
172
|
+
from context_firewall.lint.engine import LintEngine
|
|
173
|
+
|
|
174
|
+
# Wrap classify_task to match engine protocol
|
|
175
|
+
_classify_task_fn = classify_task # capture before class body redefines the name
|
|
176
|
+
|
|
177
|
+
class _ClassifierShim:
|
|
178
|
+
name = "intent_classifier"
|
|
179
|
+
critical = False
|
|
180
|
+
classify_task = staticmethod(_classify_task_fn)
|
|
181
|
+
|
|
182
|
+
async def init(self, cfg: Config) -> None:
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
def health_check(self):
|
|
186
|
+
from context_firewall.models import SubsystemHealth
|
|
187
|
+
return SubsystemHealth(name=self.name, healthy=True)
|
|
188
|
+
|
|
189
|
+
async def shutdown(self) -> None:
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
# ── Apply detection thresholds from config ────────────────────────────────
|
|
193
|
+
from context_firewall.policy.detectors.injection import configure_thresholds
|
|
194
|
+
configure_thresholds(
|
|
195
|
+
block=config.detection.injection_block_threshold,
|
|
196
|
+
warn=config.detection.injection_warn_threshold,
|
|
197
|
+
)
|
|
198
|
+
logger.info(
|
|
199
|
+
"Injection thresholds: block=%.2f warn=%.2f",
|
|
200
|
+
config.detection.injection_block_threshold,
|
|
201
|
+
config.detection.injection_warn_threshold,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
from context_firewall.source.registry import SourceRegistry
|
|
205
|
+
from context_firewall.source.types import SourceTrustTier
|
|
206
|
+
source_registry = SourceRegistry(
|
|
207
|
+
db_path,
|
|
208
|
+
classification_frameworks=config.compliance.classification_frameworks,
|
|
209
|
+
)
|
|
210
|
+
await source_registry.init()
|
|
211
|
+
|
|
212
|
+
# Register declarative sources from config
|
|
213
|
+
if config.sources:
|
|
214
|
+
for src in config.sources:
|
|
215
|
+
await source_registry.register(
|
|
216
|
+
src.id,
|
|
217
|
+
src.type,
|
|
218
|
+
SourceTrustTier(src.trust_tier),
|
|
219
|
+
owner=src.owner,
|
|
220
|
+
region=src.region,
|
|
221
|
+
data_classification=src.data_classification,
|
|
222
|
+
config=src.config or {},
|
|
223
|
+
)
|
|
224
|
+
logger.info("Registered %d source(s) from config", len(config.sources))
|
|
225
|
+
|
|
226
|
+
repo_root = config.repository_root
|
|
227
|
+
if repo_root:
|
|
228
|
+
count = await source_registry.auto_register_repo_sources([repo_root])
|
|
229
|
+
if count:
|
|
230
|
+
logger.info("Auto-registered %d repo source(s) as internal", count)
|
|
231
|
+
logger.info("SourceRegistry initialized")
|
|
232
|
+
|
|
233
|
+
graph = RepositoryGraphEngine()
|
|
234
|
+
trust = TrustScoringEngine()
|
|
235
|
+
entropy = ContextEntropyEngine()
|
|
236
|
+
runtime = RuntimeCorrelationEngine()
|
|
237
|
+
policy = PolicyEngine()
|
|
238
|
+
provenance = ProvenanceEngine()
|
|
239
|
+
synthesizer = ContextSynthesizer()
|
|
240
|
+
analytics = AnalyticsEngine()
|
|
241
|
+
lint = LintEngine()
|
|
242
|
+
classifier_shim = _ClassifierShim()
|
|
243
|
+
|
|
244
|
+
startup_order = [
|
|
245
|
+
graph,
|
|
246
|
+
trust,
|
|
247
|
+
entropy,
|
|
248
|
+
runtime,
|
|
249
|
+
policy,
|
|
250
|
+
provenance,
|
|
251
|
+
synthesizer,
|
|
252
|
+
analytics,
|
|
253
|
+
lint,
|
|
254
|
+
classifier_shim,
|
|
255
|
+
]
|
|
256
|
+
initialized = []
|
|
257
|
+
|
|
258
|
+
for engine in startup_order:
|
|
259
|
+
try:
|
|
260
|
+
if engine.name == "policy_engine":
|
|
261
|
+
await engine.init(config, provenance)
|
|
262
|
+
elif engine.name == "context_synthesizer":
|
|
263
|
+
await engine.init(config, provenance)
|
|
264
|
+
else:
|
|
265
|
+
await engine.init(config)
|
|
266
|
+
initialized.append(engine)
|
|
267
|
+
# Check actual health after init - some engines (e.g. graph) init successfully
|
|
268
|
+
# even when their backing store isn't ready, and only report degraded via health_check.
|
|
269
|
+
if hasattr(engine, "health_check"):
|
|
270
|
+
h = engine.health_check()
|
|
271
|
+
if h.healthy:
|
|
272
|
+
logger.info("%s ready", engine.name)
|
|
273
|
+
else:
|
|
274
|
+
logger.warning("%s degraded: %s", engine.name, h.message or "check configuration")
|
|
275
|
+
else:
|
|
276
|
+
logger.info("%s ready", engine.name)
|
|
277
|
+
except Exception as e:
|
|
278
|
+
if engine.critical:
|
|
279
|
+
logger.error("Critical engine %s failed; shutting down: %s", engine.name, e)
|
|
280
|
+
for done in reversed(initialized):
|
|
281
|
+
try:
|
|
282
|
+
await done.shutdown()
|
|
283
|
+
except Exception:
|
|
284
|
+
pass
|
|
285
|
+
sys.exit(1)
|
|
286
|
+
else:
|
|
287
|
+
logger.warning("Non-critical engine %s failed (continuing): %s", engine.name, e)
|
|
288
|
+
|
|
289
|
+
# ── Startup health summary ─────────────────────────────────────────────────
|
|
290
|
+
degraded = []
|
|
291
|
+
for eng in initialized:
|
|
292
|
+
if hasattr(eng, "health_check"):
|
|
293
|
+
h = eng.health_check()
|
|
294
|
+
if not h.healthy:
|
|
295
|
+
degraded.append((eng.name, h.message or "degraded"))
|
|
296
|
+
|
|
297
|
+
if degraded:
|
|
298
|
+
logger.warning("━" * 60)
|
|
299
|
+
logger.warning("ContextWall started in REDUCED CAPACITY - %d subsystem(s) degraded:", len(degraded))
|
|
300
|
+
for name, msg in degraded:
|
|
301
|
+
logger.warning(" ✗ %-38s %s", name, msg)
|
|
302
|
+
logger.warning("Proxy enforcement and policy engine are fully operational.")
|
|
303
|
+
logger.warning("━" * 60)
|
|
304
|
+
else:
|
|
305
|
+
logger.info("All subsystems healthy")
|
|
306
|
+
|
|
307
|
+
# ── Seed git metadata so trust scoring has real signals on day one ─────────
|
|
308
|
+
if repo_root:
|
|
309
|
+
asyncio.ensure_future(_seed_git_metadata(db_path, repo_root))
|
|
310
|
+
|
|
311
|
+
engines = {
|
|
312
|
+
"classifier": classifier_shim,
|
|
313
|
+
"graph": graph,
|
|
314
|
+
"trust": trust,
|
|
315
|
+
"entropy": entropy,
|
|
316
|
+
"runtime": runtime,
|
|
317
|
+
"policy": policy,
|
|
318
|
+
"provenance": provenance,
|
|
319
|
+
"synthesizer": synthesizer,
|
|
320
|
+
"analytics": analytics,
|
|
321
|
+
"lint": lint,
|
|
322
|
+
"source_registry": source_registry,
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
# ── Control plane pusher (optional, non-critical) ─────────────────────────
|
|
326
|
+
from context_firewall.control_plane.pusher import ControlPlanePusher
|
|
327
|
+
|
|
328
|
+
def _health_snapshot() -> dict:
|
|
329
|
+
snap = {}
|
|
330
|
+
for eng in initialized:
|
|
331
|
+
if hasattr(eng, "health_check"):
|
|
332
|
+
h = eng.health_check()
|
|
333
|
+
snap[eng.name] = {
|
|
334
|
+
"status": "healthy" if h.healthy else "degraded",
|
|
335
|
+
"latency_ms": getattr(h, "latency_ms", None),
|
|
336
|
+
"message": getattr(h, "message", None),
|
|
337
|
+
}
|
|
338
|
+
return snap
|
|
339
|
+
|
|
340
|
+
cp_pusher = ControlPlanePusher(
|
|
341
|
+
cp_config=config.control_plane,
|
|
342
|
+
config=config,
|
|
343
|
+
health_provider=_health_snapshot,
|
|
344
|
+
policy_engine=policy,
|
|
345
|
+
)
|
|
346
|
+
await cp_pusher.start()
|
|
347
|
+
|
|
348
|
+
# ── FastAPI app ───────────────────────────────────────────────────────────
|
|
349
|
+
from context_firewall.api.app import create_app
|
|
350
|
+
app = create_app(config, engines)
|
|
351
|
+
|
|
352
|
+
# ── Offline job scheduler ────────────────────────────────────────────────
|
|
353
|
+
scheduler = AsyncIOScheduler()
|
|
354
|
+
_setup_jobs(scheduler, config, engines)
|
|
355
|
+
scheduler.start()
|
|
356
|
+
logger.info("APScheduler started")
|
|
357
|
+
|
|
358
|
+
# ── PID file ──────────────────────────────────────────────────────────────
|
|
359
|
+
pid_file = Path(config.daemon.pid_file)
|
|
360
|
+
pid_file.parent.mkdir(parents=True, exist_ok=True)
|
|
361
|
+
pid_file.write_text(str(os.getpid()))
|
|
362
|
+
|
|
363
|
+
# ── Signal handlers ───────────────────────────────────────────────────────
|
|
364
|
+
loop = asyncio.get_event_loop()
|
|
365
|
+
|
|
366
|
+
async def _shutdown():
|
|
367
|
+
logger.info("Graceful shutdown initiated")
|
|
368
|
+
scheduler.shutdown(wait=False)
|
|
369
|
+
await cp_pusher.stop()
|
|
370
|
+
for eng in reversed(initialized):
|
|
371
|
+
try:
|
|
372
|
+
await eng.shutdown()
|
|
373
|
+
except Exception:
|
|
374
|
+
pass
|
|
375
|
+
pid_file.unlink(missing_ok=True)
|
|
376
|
+
loop.stop()
|
|
377
|
+
|
|
378
|
+
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
379
|
+
loop.add_signal_handler(sig, lambda: asyncio.ensure_future(_shutdown()))
|
|
380
|
+
|
|
381
|
+
# ── uvicorn ───────────────────────────────────────────────────────────────
|
|
382
|
+
uconfig = uvicorn.Config(
|
|
383
|
+
app,
|
|
384
|
+
host=host,
|
|
385
|
+
port=config.rest_api.port,
|
|
386
|
+
log_level="warning",
|
|
387
|
+
loop="asyncio",
|
|
388
|
+
)
|
|
389
|
+
server = uvicorn.Server(uconfig)
|
|
390
|
+
logger.info(f"ContextWall daemon listening on {host}:{config.rest_api.port}")
|
|
391
|
+
await server.serve()
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _setup_jobs(scheduler: AsyncIOScheduler, config: Config, engines: dict) -> None:
|
|
395
|
+
analytics = engines.get("analytics")
|
|
396
|
+
entropy = engines.get("entropy")
|
|
397
|
+
lint = engines.get("lint")
|
|
398
|
+
|
|
399
|
+
if analytics:
|
|
400
|
+
scheduler.add_job(
|
|
401
|
+
analytics.compute_and_store_snapshots,
|
|
402
|
+
"cron",
|
|
403
|
+
**_parse_cron(config.daemon.jobs.get("entropy_computation", None)),
|
|
404
|
+
id="analytics_snapshots",
|
|
405
|
+
replace_existing=True,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
if entropy:
|
|
409
|
+
async def _run_entropy():
|
|
410
|
+
await entropy.run_analysis(config.repository_root)
|
|
411
|
+
|
|
412
|
+
scheduler.add_job(
|
|
413
|
+
_run_entropy,
|
|
414
|
+
"cron",
|
|
415
|
+
**_parse_cron(config.daemon.jobs.get("entropy_computation", None)),
|
|
416
|
+
id="entropy_computation",
|
|
417
|
+
replace_existing=True,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
if lint:
|
|
421
|
+
async def _run_lint():
|
|
422
|
+
await lint.run(window_days=30)
|
|
423
|
+
|
|
424
|
+
scheduler.add_job(
|
|
425
|
+
_run_lint,
|
|
426
|
+
"cron",
|
|
427
|
+
# Run once daily at 03:00 UTC - light query load, results ready for morning review
|
|
428
|
+
hour="3",
|
|
429
|
+
minute="0",
|
|
430
|
+
id="lint_audit",
|
|
431
|
+
replace_existing=True,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _parse_cron(job_cfg) -> dict:
|
|
436
|
+
"""Convert cron string like '0 */6 * * *' to APScheduler kwargs."""
|
|
437
|
+
if job_cfg is None:
|
|
438
|
+
return {"minute": "0", "hour": "*/6"}
|
|
439
|
+
cron_str = getattr(job_cfg, "schedule", "0 */6 * * *")
|
|
440
|
+
parts = cron_str.split()
|
|
441
|
+
if len(parts) == 5:
|
|
442
|
+
minute, hour, day, month, day_of_week = parts
|
|
443
|
+
return {
|
|
444
|
+
"minute": minute,
|
|
445
|
+
"hour": hour,
|
|
446
|
+
"day": day,
|
|
447
|
+
"month": month,
|
|
448
|
+
"day_of_week": day_of_week,
|
|
449
|
+
}
|
|
450
|
+
return {"minute": "0", "hour": "*/6"}
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def main():
|
|
454
|
+
import os
|
|
455
|
+
config_path = os.environ.get("CTXFW_CONFIG", "ctxfw.yaml")
|
|
456
|
+
config = load_config(config_path)
|
|
457
|
+
asyncio.run(run_daemon(config))
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
if __name__ == "__main__":
|
|
461
|
+
main()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Shared aiosqlite connection manager."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import aiosqlite
|
|
6
|
+
|
|
7
|
+
_db: aiosqlite.Connection | None = None
|
|
8
|
+
_db_path: str = ".ctxfw/cre.db"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def get_db() -> aiosqlite.Connection:
|
|
12
|
+
global _db
|
|
13
|
+
if _db is None:
|
|
14
|
+
_db = await aiosqlite.connect(_db_path)
|
|
15
|
+
_db.row_factory = aiosqlite.Row
|
|
16
|
+
await _db.execute("PRAGMA journal_mode=WAL")
|
|
17
|
+
await _db.execute("PRAGMA foreign_keys=ON")
|
|
18
|
+
return _db
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def close_db() -> None:
|
|
22
|
+
global _db
|
|
23
|
+
if _db is not None:
|
|
24
|
+
await _db.close()
|
|
25
|
+
_db = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def configure_db_path(path: str) -> None:
|
|
29
|
+
global _db_path
|
|
30
|
+
_db_path = path
|