forked from molecule-ai/molecule-core
Renames: - platform/ → workspace-server/ (Go module path stays as "platform" for external dep compat — will update after plugin module republish) - workspace-template/ → workspace/ Removed (moved to separate repos or deleted): - PLAN.md — internal roadmap (move to private project board) - HANDOFF.md, AGENTS.md — one-time internal session docs - .claude/ — gitignored entirely (local agent config) - infra/cloudflare-worker/ → Molecule-AI/molecule-tenant-proxy - org-templates/molecule-dev/ → standalone template repo - .mcp-eval/ → molecule-mcp-server repo - test-results/ — ephemeral, gitignored Security scrubbing: - Cloudflare account/zone/KV IDs → placeholders - Real EC2 IPs → <EC2_IP> in all docs - CF token prefix, Neon project ID, Fly app names → redacted - Langfuse dev credentials → parameterized - Personal runner username/machine name → generic Community files: - CONTRIBUTING.md — build, test, branch conventions - CODE_OF_CONDUCT.md — Contributor Covenant 2.1 All Dockerfiles, CI workflows, docker-compose, railway.toml, render.yaml, README, CLAUDE.md updated for new directory names. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
121 lines
3.9 KiB
Python
121 lines
3.9 KiB
Python
"""File watcher for hot-reloading skills and config changes.
|
|
|
|
Monitors the config directory for file changes and triggers
|
|
agent rebuild + Agent Card update broadcast.
|
|
"""
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEBOUNCE_SECONDS = 2.0
|
|
POLL_INTERVAL = 3.0 # seconds between filesystem checks
|
|
|
|
|
|
class ConfigWatcher:
|
|
"""Watches the config directory for changes and triggers reload callbacks."""
|
|
|
|
def __init__(
|
|
self,
|
|
config_path: str,
|
|
platform_url: str,
|
|
workspace_id: str,
|
|
on_reload=None,
|
|
):
|
|
self.config_path = config_path
|
|
self.platform_url = platform_url
|
|
self.workspace_id = workspace_id
|
|
self.on_reload = on_reload
|
|
self._file_hashes: dict[str, str] = {}
|
|
self._running = False
|
|
|
|
def _hash_file(self, path: str) -> str:
|
|
try:
|
|
# H1: SHA-256 replaces MD5 for file-integrity change detection.
|
|
# MD5 is collision-prone; using SHA-256 prevents a crafted config
|
|
# file from producing the same hash as a benign one, which would
|
|
# silently suppress the hot-reload callback.
|
|
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
|
except (OSError, IOError):
|
|
return ""
|
|
|
|
def _scan_hashes(self) -> dict[str, str]:
|
|
"""Scan all files in config directory and return hash map."""
|
|
hashes = {}
|
|
for root, _, files in os.walk(self.config_path):
|
|
for fname in files:
|
|
if fname.startswith("."):
|
|
continue
|
|
fpath = os.path.join(root, fname)
|
|
rel = os.path.relpath(fpath, self.config_path)
|
|
hashes[rel] = self._hash_file(fpath)
|
|
return hashes
|
|
|
|
def _detect_changes(self) -> list[str]:
|
|
"""Compare current state with cached hashes, return changed files."""
|
|
current = self._scan_hashes()
|
|
changed = []
|
|
|
|
for path, h in current.items():
|
|
if path not in self._file_hashes or self._file_hashes[path] != h:
|
|
changed.append(path)
|
|
|
|
for path in self._file_hashes:
|
|
if path not in current:
|
|
changed.append(path)
|
|
|
|
self._file_hashes = current
|
|
return changed
|
|
|
|
async def _notify_platform(self, agent_card: dict):
|
|
"""Push updated Agent Card to the platform."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
await client.post(
|
|
f"{self.platform_url}/registry/update-card",
|
|
json={
|
|
"workspace_id": self.workspace_id,
|
|
"agent_card": agent_card,
|
|
},
|
|
)
|
|
logger.info("Agent Card updated via platform")
|
|
except Exception as e:
|
|
logger.warning("Failed to update Agent Card: %s", e)
|
|
|
|
async def start(self):
|
|
"""Start watching for changes in a background loop."""
|
|
self._running = True
|
|
self._file_hashes = self._scan_hashes()
|
|
logger.info("Config watcher started for %s", self.config_path)
|
|
|
|
while self._running:
|
|
await asyncio.sleep(POLL_INTERVAL)
|
|
|
|
changed = self._detect_changes()
|
|
if not changed:
|
|
continue
|
|
|
|
logger.info("Config changes detected: %s", changed)
|
|
|
|
# Debounce — wait for writes to settle
|
|
await asyncio.sleep(DEBOUNCE_SECONDS)
|
|
|
|
# Re-scan after debounce (more changes may have occurred)
|
|
self._detect_changes()
|
|
|
|
# Trigger reload callback
|
|
if self.on_reload:
|
|
try:
|
|
await self.on_reload()
|
|
except Exception as e:
|
|
logger.error("Reload callback failed: %s", e)
|
|
|
|
def stop(self):
|
|
self._running = False
|