devtorch-core 3.0.1__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.
- devtorch_core/__init__.py +158 -0
- devtorch_core/aggphi_textual.py +275 -0
- devtorch_core/alerts/__init__.py +23 -0
- devtorch_core/alerts/base.py +46 -0
- devtorch_core/alerts/config.py +60 -0
- devtorch_core/alerts/dispatcher.py +110 -0
- devtorch_core/alerts/jira.py +96 -0
- devtorch_core/alerts/linear.py +72 -0
- devtorch_core/alerts/pagerduty.py +66 -0
- devtorch_core/alerts/slack.py +81 -0
- devtorch_core/alerts/teams.py +70 -0
- devtorch_core/audit/__init__.py +43 -0
- devtorch_core/audit/exporter.py +297 -0
- devtorch_core/audit/privacy.py +101 -0
- devtorch_core/audit/scrubber.py +149 -0
- devtorch_core/audit/service.py +67 -0
- devtorch_core/audit/signing.py +127 -0
- devtorch_core/broadcast/__init__.py +4 -0
- devtorch_core/broadcast/broadcaster.py +100 -0
- devtorch_core/broadcast/watcher.py +71 -0
- devtorch_core/capability.py +639 -0
- devtorch_core/cloud/__init__.py +1 -0
- devtorch_core/cloud/client_config.py +472 -0
- devtorch_core/cloud/client_configs/.claude-opencode-fallback.json +8 -0
- devtorch_core/cloud/client_configs/.claude-stdio.json +13 -0
- devtorch_core/cloud/client_configs/.cursor-mcp.json +13 -0
- devtorch_core/cloud/client_configs/.opencode-bridge.json +13 -0
- devtorch_core/cloud/client_configs/.opencode.json +15 -0
- devtorch_core/cloud/client_configs/.vscode-mcp.json +13 -0
- devtorch_core/cloud/devtorch-mcp-bridge.js +357 -0
- devtorch_core/cloud/mcp_client.py +229 -0
- devtorch_core/cloud/setup.py +144 -0
- devtorch_core/cloud/sync.py +143 -0
- devtorch_core/cloud/sync_bundle.py +603 -0
- devtorch_core/cloud/sync_conflicts.py +159 -0
- devtorch_core/cloud/sync_state.py +159 -0
- devtorch_core/cloud/team_sync.py +283 -0
- devtorch_core/codex/__init__.py +9 -0
- devtorch_core/codex/__main__.py +97 -0
- devtorch_core/codex/capture.py +208 -0
- devtorch_core/codex/proxy.py +412 -0
- devtorch_core/concept_catalog.py +209 -0
- devtorch_core/consolidation/__init__.py +3 -0
- devtorch_core/consolidation/synthesizer.py +87 -0
- devtorch_core/consolidation/workflow.py +175 -0
- devtorch_core/daemon/__init__.py +27 -0
- devtorch_core/daemon/supervisor.py +293 -0
- devtorch_core/daemon/watcher.py +244 -0
- devtorch_core/dashboard_api.py +2012 -0
- devtorch_core/deltaf.py +97 -0
- devtorch_core/disclosure.py +50 -0
- devtorch_core/divergence/__init__.py +3 -0
- devtorch_core/divergence/detector.py +166 -0
- devtorch_core/gateway/__init__.py +32 -0
- devtorch_core/gateway/key_manager.py +124 -0
- devtorch_core/gateway/metrics_webhook.py +252 -0
- devtorch_core/gateway/policy.py +262 -0
- devtorch_core/gateway/server.py +727 -0
- devtorch_core/gateway/sso.py +233 -0
- devtorch_core/gcc.py +1246 -0
- devtorch_core/github/__init__.py +35 -0
- devtorch_core/github/app.py +240 -0
- devtorch_core/github/comment_builder.py +113 -0
- devtorch_core/github/pat.py +76 -0
- devtorch_core/github/pr_parser.py +82 -0
- devtorch_core/github/pr_reporter.py +555 -0
- devtorch_core/gitlab/__init__.py +177 -0
- devtorch_core/hitl/__init__.py +4 -0
- devtorch_core/hitl/channels.py +129 -0
- devtorch_core/hitl/orchestrator.py +95 -0
- devtorch_core/hooks/__init__.py +17 -0
- devtorch_core/hooks/claude_code.py +228 -0
- devtorch_core/hooks/git_capture.py +341 -0
- devtorch_core/hooks/git_commit.py +182 -0
- devtorch_core/hooks/installer.py +733 -0
- devtorch_core/hooks/pre_commit.py +157 -0
- devtorch_core/hooks/runner.py +344 -0
- devtorch_core/identity/__init__.py +4 -0
- devtorch_core/identity/agent.py +86 -0
- devtorch_core/identity/providers.py +85 -0
- devtorch_core/invariants.py +182 -0
- devtorch_core/mcp/__init__.py +10 -0
- devtorch_core/mcp/auth.py +177 -0
- devtorch_core/mcp/server.py +1049 -0
- devtorch_core/metrics/__init__.py +35 -0
- devtorch_core/metrics/aggregate.py +215 -0
- devtorch_core/metrics/calibrate.py +198 -0
- devtorch_core/metrics/calibration.py +125 -0
- devtorch_core/metrics/credibility.py +288 -0
- devtorch_core/metrics/delivery_time.py +70 -0
- devtorch_core/metrics/dhs.py +126 -0
- devtorch_core/metrics/mcs.py +96 -0
- devtorch_core/metrics/roi.py +88 -0
- devtorch_core/metrics/session_writer.py +81 -0
- devtorch_core/metrics/shadow_ai.py +117 -0
- devtorch_core/metrics/sprint_writer.py +243 -0
- devtorch_core/observability/__init__.py +78 -0
- devtorch_core/observability/datadog.py +157 -0
- devtorch_core/observability/formatter.py +119 -0
- devtorch_core/observability/report.py +264 -0
- devtorch_core/observability/servicenow.py +147 -0
- devtorch_core/observability/splunk.py +218 -0
- devtorch_core/observability/webhook.py +227 -0
- devtorch_core/parser/__init__.py +30 -0
- devtorch_core/parser/blocks.py +216 -0
- devtorch_core/parser/inference.py +159 -0
- devtorch_core/parser/thinking.py +112 -0
- devtorch_core/projects.py +169 -0
- devtorch_core/prompt_artifact.py +76 -0
- devtorch_core/proxy/__init__.py +9 -0
- devtorch_core/proxy/routes/__init__.py +1 -0
- devtorch_core/proxy/routes/anthropic.py +264 -0
- devtorch_core/proxy/routes/azure_openai.py +336 -0
- devtorch_core/proxy/routes/gemini.py +331 -0
- devtorch_core/proxy/routes/groq.py +284 -0
- devtorch_core/proxy/routes/ollama.py +279 -0
- devtorch_core/proxy/routes/openai.py +287 -0
- devtorch_core/proxy/server.py +356 -0
- devtorch_core/query/__init__.py +15 -0
- devtorch_core/query/grep.py +181 -0
- devtorch_core/query/hybrid.py +86 -0
- devtorch_core/query/semantic.py +157 -0
- devtorch_core/rdp.py +105 -0
- devtorch_core/reasoning/__init__.py +4 -0
- devtorch_core/reasoning/entry.py +31 -0
- devtorch_core/reasoning/store.py +122 -0
- devtorch_core/reasoning_plus/__init__.py +70 -0
- devtorch_core/reasoning_plus/augmenter.py +326 -0
- devtorch_core/reasoning_plus/capture.py +51 -0
- devtorch_core/reasoning_plus/config.py +256 -0
- devtorch_core/reasoning_plus/context.py +262 -0
- devtorch_core/reasoning_plus/learning/__init__.py +72 -0
- devtorch_core/reasoning_plus/learning/analytics.py +141 -0
- devtorch_core/reasoning_plus/learning/api.py +313 -0
- devtorch_core/reasoning_plus/learning/chain.py +285 -0
- devtorch_core/reasoning_plus/learning/composer.py +74 -0
- devtorch_core/reasoning_plus/learning/cross_project.py +234 -0
- devtorch_core/reasoning_plus/learning/embeddings.py +209 -0
- devtorch_core/reasoning_plus/learning/extractor.py +207 -0
- devtorch_core/reasoning_plus/learning/models.py +116 -0
- devtorch_core/reasoning_plus/learning/provenance.py +126 -0
- devtorch_core/reasoning_plus/learning/recorder.py +81 -0
- devtorch_core/reasoning_plus/learning/relevance.py +122 -0
- devtorch_core/reasoning_plus/learning/state.py +86 -0
- devtorch_core/reasoning_plus/learning/store.py +160 -0
- devtorch_core/reasoning_plus/learning/theta_learning_bridge.py +94 -0
- devtorch_core/reasoning_plus/prompt.py +90 -0
- devtorch_core/rep.py +134 -0
- devtorch_core/rep_network/__init__.py +25 -0
- devtorch_core/rep_network/merge.py +70 -0
- devtorch_core/rep_network/node.py +137 -0
- devtorch_core/rep_network/server.py +140 -0
- devtorch_core/rep_network/sync.py +207 -0
- devtorch_core/sensitivity.py +182 -0
- devtorch_core/serve.py +258 -0
- devtorch_core/session/__init__.py +39 -0
- devtorch_core/session/disagreement.py +188 -0
- devtorch_core/session/models.py +114 -0
- devtorch_core/session/orchestrator.py +182 -0
- devtorch_core/session/planner.py +169 -0
- devtorch_core/session/simulator.py +132 -0
- devtorch_core/signing.py +290 -0
- devtorch_core/sis.py +197 -0
- devtorch_core/storage.py +308 -0
- devtorch_core/templates/__init__.py +6 -0
- devtorch_core/templates/engine.py +122 -0
- devtorch_core/templates/go.py +18 -0
- devtorch_core/templates/infra.py +19 -0
- devtorch_core/templates/library/__init__.py +18 -0
- devtorch_core/templates/library/api_design.md +27 -0
- devtorch_core/templates/library/bug_fix.md +27 -0
- devtorch_core/templates/library/decision_record.md +27 -0
- devtorch_core/templates/library/engine.py +228 -0
- devtorch_core/templates/library/security_review.md +30 -0
- devtorch_core/templates/python.py +19 -0
- devtorch_core/templates/react.py +18 -0
- devtorch_core/templates/typescript.py +18 -0
- devtorch_core/theta.py +221 -0
- devtorch_core/theta_synthesis.py +268 -0
- devtorch_core/topics.py +320 -0
- devtorch_core/variance.py +219 -0
- devtorch_core/wrapper/__init__.py +52 -0
- devtorch_core/wrapper/anthropic.py +487 -0
- devtorch_core/wrapper/base.py +562 -0
- devtorch_core/wrapper/bedrock.py +342 -0
- devtorch_core/wrapper/gemini.py +422 -0
- devtorch_core/wrapper/ollama.py +527 -0
- devtorch_core/wrapper/openai.py +461 -0
- devtorch_core-3.0.1.dist-info/METADATA +867 -0
- devtorch_core-3.0.1.dist-info/RECORD +193 -0
- devtorch_core-3.0.1.dist-info/WHEEL +5 -0
- devtorch_core-3.0.1.dist-info/entry_points.txt +2 -0
- devtorch_core-3.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Optional asymmetric signing of exported audit reports.
|
|
2
|
+
|
|
3
|
+
The existing ``devtorch_core.signing`` module signs individual event-log lines
|
|
4
|
+
with HMAC-SHA256. This module is for exported JSON/PDF audit reports: it uses
|
|
5
|
+
asymmetric Ed25519 so anyone with the public key can verify the report without
|
|
6
|
+
accessing the private signing key.
|
|
7
|
+
|
|
8
|
+
``cryptography`` is an optional dependency. If it is unavailable, signing
|
|
9
|
+
functions raise ``AuditSignatureError``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import json
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Dict, Optional
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AuditSignatureError(RuntimeError):
|
|
21
|
+
"""Raised when signing or verification cannot proceed."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ExportSigner:
|
|
25
|
+
"""Ed25519 signer for exported audit JSON."""
|
|
26
|
+
|
|
27
|
+
ALGORITHM = "ed25519"
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self._cryptography = self._load_cryptography()
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def _load_cryptography():
|
|
34
|
+
try:
|
|
35
|
+
import cryptography # noqa: F401
|
|
36
|
+
from cryptography.hazmat.primitives.asymmetric import ed25519
|
|
37
|
+
from cryptography.hazmat.primitives import serialization
|
|
38
|
+
return (ed25519, serialization)
|
|
39
|
+
except ImportError as exc:
|
|
40
|
+
raise AuditSignatureError(
|
|
41
|
+
"cryptography is required for export signing; install devtorch-core[audit]"
|
|
42
|
+
) from exc
|
|
43
|
+
|
|
44
|
+
def generate_keypair(self) -> tuple[bytes, bytes]:
|
|
45
|
+
"""Return (private_key_pem, public_key_pem) as bytes."""
|
|
46
|
+
ed25519, serialization = self._cryptography
|
|
47
|
+
private_key = ed25519.Ed25519PrivateKey.generate()
|
|
48
|
+
private_pem = private_key.private_bytes(
|
|
49
|
+
encoding=serialization.Encoding.PEM,
|
|
50
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
51
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
52
|
+
)
|
|
53
|
+
public_pem = private_key.public_key().public_bytes(
|
|
54
|
+
encoding=serialization.Encoding.PEM,
|
|
55
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
56
|
+
)
|
|
57
|
+
return private_pem, public_pem
|
|
58
|
+
|
|
59
|
+
def save_key(self, path: Path, key_pem: bytes) -> None:
|
|
60
|
+
"""Persist a PEM key with restrictive permissions."""
|
|
61
|
+
path.write_bytes(key_pem)
|
|
62
|
+
try:
|
|
63
|
+
path.chmod(0o600)
|
|
64
|
+
except (OSError, NotImplementedError):
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
def load_private_key(self, path: Path):
|
|
68
|
+
"""Load an Ed25519 private key from a PEM file."""
|
|
69
|
+
_, serialization = self._cryptography
|
|
70
|
+
data = path.read_bytes()
|
|
71
|
+
return serialization.load_pem_private_key(data, password=None)
|
|
72
|
+
|
|
73
|
+
def load_public_key(self, path: Path):
|
|
74
|
+
"""Load an Ed25519 public key from a PEM file."""
|
|
75
|
+
_, serialization = self._cryptography
|
|
76
|
+
data = path.read_bytes()
|
|
77
|
+
return serialization.load_pem_public_key(data)
|
|
78
|
+
|
|
79
|
+
def sign(self, data: bytes, private_key) -> bytes:
|
|
80
|
+
"""Sign bytes and return a base64-encoded signature."""
|
|
81
|
+
signature = private_key.sign(data)
|
|
82
|
+
return base64.b64encode(signature)
|
|
83
|
+
|
|
84
|
+
def verify(self, data: bytes, signature_b64: bytes, public_key) -> bool:
|
|
85
|
+
"""Verify a base64-encoded signature against data."""
|
|
86
|
+
try:
|
|
87
|
+
signature = base64.b64decode(signature_b64)
|
|
88
|
+
public_key.verify(signature, data)
|
|
89
|
+
return True
|
|
90
|
+
except Exception:
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
def sign_json(self, data: Dict[str, Any], private_key) -> Dict[str, Any]:
|
|
94
|
+
"""Return a signed envelope wrapping the JSON data."""
|
|
95
|
+
payload_bytes = json.dumps(data, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
96
|
+
signature = self.sign(payload_bytes, private_key)
|
|
97
|
+
return {
|
|
98
|
+
"algorithm": self.ALGORITHM,
|
|
99
|
+
"payload": data,
|
|
100
|
+
"signature": signature.decode("utf-8"),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
def verify_json(self, envelope: Dict[str, Any], public_key) -> bool:
|
|
104
|
+
"""Verify a signed envelope."""
|
|
105
|
+
if envelope.get("algorithm") != self.ALGORITHM:
|
|
106
|
+
return False
|
|
107
|
+
payload = envelope.get("payload")
|
|
108
|
+
if payload is None:
|
|
109
|
+
return False
|
|
110
|
+
signature_b64 = envelope.get("signature", "").encode("utf-8")
|
|
111
|
+
payload_bytes = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
112
|
+
return self.verify(payload_bytes, signature_b64, public_key)
|
|
113
|
+
|
|
114
|
+
def sign_file(self, input_path: Path, output_path: Path, private_key_path: Path) -> None:
|
|
115
|
+
"""Sign a JSON file and write a signed envelope to ``output_path``."""
|
|
116
|
+
data = json.loads(input_path.read_text(encoding="utf-8"))
|
|
117
|
+
private_key = self.load_private_key(private_key_path)
|
|
118
|
+
envelope = self.sign_json(data, private_key)
|
|
119
|
+
output_path.write_text(json.dumps(envelope, indent=2), encoding="utf-8")
|
|
120
|
+
|
|
121
|
+
def verify_file(self, envelope_path: Path, public_key_path: Path) -> bool:
|
|
122
|
+
"""Verify a signed envelope file."""
|
|
123
|
+
envelope = json.loads(envelope_path.read_text(encoding="utf-8"))
|
|
124
|
+
public_key = self.load_public_key(public_key_path)
|
|
125
|
+
return self.verify_json(envelope, public_key)
|
|
126
|
+
|
|
127
|
+
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local SSE broadcaster for DevTorch coordination events.
|
|
3
|
+
|
|
4
|
+
Agents subscribe to GET /events/stream and receive coordination-relevant
|
|
5
|
+
events as they are appended to .GCC/events.log.jsonl.
|
|
6
|
+
|
|
7
|
+
Only broadcasts events whose event_type contains one of:
|
|
8
|
+
DIVERGENCE, CONSOLIDATION, HITL, CONSENSUS
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import threading
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import List, Optional, Set
|
|
17
|
+
|
|
18
|
+
COORDINATION_TYPES: Set[str] = {"DIVERGENCE", "CONSOLIDATION", "HITL", "CONSENSUS"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _is_coordination_event(event_type: str) -> bool:
|
|
22
|
+
"""Return True if any coordination keyword appears in the event_type string."""
|
|
23
|
+
upper = event_type.upper()
|
|
24
|
+
return any(kw in upper for kw in COORDINATION_TYPES)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EventBroadcaster:
|
|
28
|
+
"""Thread-safe in-process event broadcaster backed by asyncio queues."""
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self._queues: List[asyncio.Queue] = []
|
|
32
|
+
self._lock = threading.Lock()
|
|
33
|
+
|
|
34
|
+
def subscribe(self) -> asyncio.Queue:
|
|
35
|
+
# asyncio.Queue.put_nowait() and get_nowait() are safe to call from threads
|
|
36
|
+
# under CPython's GIL (deque operations are atomic). The async await q.get()
|
|
37
|
+
# path in create_broadcast_app is only reached from within the FastAPI event loop.
|
|
38
|
+
q: asyncio.Queue = asyncio.Queue()
|
|
39
|
+
with self._lock:
|
|
40
|
+
self._queues.append(q)
|
|
41
|
+
return q
|
|
42
|
+
|
|
43
|
+
def unsubscribe(self, q: asyncio.Queue) -> None:
|
|
44
|
+
with self._lock:
|
|
45
|
+
try:
|
|
46
|
+
self._queues.remove(q)
|
|
47
|
+
except ValueError:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
def publish(self, event: dict) -> None:
|
|
51
|
+
"""Publish event to all subscriber queues (non-blocking)."""
|
|
52
|
+
event_type = event.get("event_type", "")
|
|
53
|
+
if not _is_coordination_event(event_type):
|
|
54
|
+
return
|
|
55
|
+
with self._lock:
|
|
56
|
+
targets = list(self._queues)
|
|
57
|
+
for q in targets:
|
|
58
|
+
try:
|
|
59
|
+
q.put_nowait(event)
|
|
60
|
+
except asyncio.QueueFull:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def create_broadcast_app(broadcaster: EventBroadcaster):
|
|
65
|
+
"""
|
|
66
|
+
Build a FastAPI app exposing GET /events/stream (SSE).
|
|
67
|
+
Returns None if FastAPI is not installed.
|
|
68
|
+
"""
|
|
69
|
+
try:
|
|
70
|
+
from fastapi import FastAPI
|
|
71
|
+
from fastapi.responses import StreamingResponse
|
|
72
|
+
except ImportError:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
app = FastAPI(title="DevTorch Event Broadcaster", version="0.1.0")
|
|
76
|
+
|
|
77
|
+
@app.get("/events/stream")
|
|
78
|
+
async def event_stream():
|
|
79
|
+
q = broadcaster.subscribe()
|
|
80
|
+
|
|
81
|
+
async def generate():
|
|
82
|
+
try:
|
|
83
|
+
# Send keepalive immediately
|
|
84
|
+
yield "data: {\"type\": \"connected\"}\n\n"
|
|
85
|
+
while True:
|
|
86
|
+
try:
|
|
87
|
+
event = await asyncio.wait_for(q.get(), timeout=30.0)
|
|
88
|
+
yield f"data: {json.dumps(event)}\n\n"
|
|
89
|
+
except asyncio.TimeoutError:
|
|
90
|
+
yield ": keepalive\n\n"
|
|
91
|
+
finally:
|
|
92
|
+
broadcaster.unsubscribe(q)
|
|
93
|
+
|
|
94
|
+
return StreamingResponse(generate(), media_type="text/event-stream")
|
|
95
|
+
|
|
96
|
+
@app.get("/events/health")
|
|
97
|
+
def health():
|
|
98
|
+
return {"status": "ok", "subscribers": len(broadcaster._queues)}
|
|
99
|
+
|
|
100
|
+
return app
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File watcher that monitors .GCC/events.log.jsonl for new lines
|
|
3
|
+
and publishes coordination events to an EventBroadcaster.
|
|
4
|
+
|
|
5
|
+
Uses watchdog if available; falls back to polling.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from .broadcaster import EventBroadcaster
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GCCWatcher:
|
|
19
|
+
"""
|
|
20
|
+
Watches .GCC/events.log.jsonl for new lines and publishes
|
|
21
|
+
coordination-relevant events to the broadcaster.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, gcc_dir: Path, broadcaster: EventBroadcaster,
|
|
25
|
+
poll_interval: float = 0.5) -> None:
|
|
26
|
+
self._log_path = gcc_dir / "events.log.jsonl"
|
|
27
|
+
self._broadcaster = broadcaster
|
|
28
|
+
self._poll_interval = poll_interval
|
|
29
|
+
self._offset: int = 0
|
|
30
|
+
self._thread: Optional[threading.Thread] = None
|
|
31
|
+
self._stop_event = threading.Event()
|
|
32
|
+
|
|
33
|
+
def _read_new_lines(self) -> None:
|
|
34
|
+
"""Read any new lines appended since last check and publish them."""
|
|
35
|
+
if not self._log_path.exists():
|
|
36
|
+
return
|
|
37
|
+
try:
|
|
38
|
+
with self._log_path.open("r", encoding="utf-8") as f:
|
|
39
|
+
f.seek(self._offset)
|
|
40
|
+
for line in f:
|
|
41
|
+
line = line.strip()
|
|
42
|
+
if not line:
|
|
43
|
+
continue
|
|
44
|
+
try:
|
|
45
|
+
event = json.loads(line)
|
|
46
|
+
self._broadcaster.publish(event)
|
|
47
|
+
except json.JSONDecodeError:
|
|
48
|
+
pass
|
|
49
|
+
self._offset = f.tell()
|
|
50
|
+
except OSError:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
def _poll_loop(self) -> None:
|
|
54
|
+
while not self._stop_event.is_set():
|
|
55
|
+
self._read_new_lines()
|
|
56
|
+
self._stop_event.wait(self._poll_interval)
|
|
57
|
+
|
|
58
|
+
def start(self) -> None:
|
|
59
|
+
"""Start the watcher background thread."""
|
|
60
|
+
if self._log_path.exists():
|
|
61
|
+
self._offset = self._log_path.stat().st_size
|
|
62
|
+
self._stop_event.clear()
|
|
63
|
+
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
|
|
64
|
+
self._thread.start()
|
|
65
|
+
|
|
66
|
+
def stop(self) -> None:
|
|
67
|
+
"""Stop the watcher background thread."""
|
|
68
|
+
self._stop_event.set()
|
|
69
|
+
if self._thread is not None:
|
|
70
|
+
self._thread.join(timeout=2.0)
|
|
71
|
+
self._thread = None
|