Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
286 changes: 125 additions & 161 deletions core/business_manager.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
"""Business (project) manager with hot-reload and CRUD operations."""

import os
import time
import logging
Expand All @@ -11,7 +9,6 @@

logger = logging.getLogger(__name__)


class BusinessManager:
"""Manages project YAML files with hot-reload capability.

Expand All @@ -37,7 +34,7 @@ def __init__(self, projects_dir: str = "projects/"):
self.reload()

@property
def projects(self) -> List[Dict]:
def projects(self) -> List[Dict]:
"""Thread-safe access to current projects list."""
with self._lock:
return list(self._projects)
Expand Down Expand Up @@ -76,166 +73,133 @@ def reload(self):
if removed:
logger.info(f"Projects removed: {removed}")
if not added and not removed and old_names:
logger.info(f"Projects reloaded: {[p['project']['name'] for p in projects]}")
logger.info(f"Projects reloaded: {[p["project"]["name"] for p in projects]}")
elif not old_names:
logger.info(f"Loaded {len(projects)} projects: {[p['project']['name'] for p in projects]}")

# Notify callbacks
for cb in self._on_reload_callbacks:
try:
cb(projects)
except Exception as e:
logger.error(f"Reload callback error: {e}")

# ── File Watcher ──────────────────────────────────────────────────

def start_watching(self, interval: float = 5.0):
"""Start file watcher thread (daemon, polls every interval seconds)."""
if self._watching:
return
self._watching = True
self._watcher_thread = threading.Thread(
target=self._watch_loop, args=(interval,), daemon=True
)
self._watcher_thread.start()
logger.info(f"Project file watcher started (interval={interval}s)")

def stop_watching(self):
"""Stop file watcher thread."""
self._watching = False

def _watch_loop(self, interval: float):
"""Poll for file changes in projects/ directory."""
while self._watching:
time.sleep(interval)
try:
current_mtimes = {}
for f in self.projects_dir.glob("*.yaml"):
current_mtimes[str(f)] = f.stat().st_mtime

if current_mtimes != self._file_mtimes:
logger.info("Project files changed, reloading...")
logger.info(f"Projects reloaded: {[p["project"]["name"] for p in projects]}")

# Reload environment variables from .env file
self._load_env_vars()

def _load_env_vars(self):
"""Reload environment variables from .env file."""
env_file = Path(".env")
if env_file.exists():
with open(env_file, 'r') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
key, value = line.split('=', 1)
os.environ[key] = value

# ── CRUD Operations ───────────────────────────────────────

def create_project(self, project_data: Dict) -> bool:
"""Create a new project file."""
try:
with self._lock:
project_file = self.projects_dir / f"{project_data["name"]}.yaml"
with open(project_file, 'w') as fh:
yaml.dump(project_data, fh)
self.reload()
return True
except Exception as e:
logger.error(f"Error creating project file: {e}")
return False

def update_project(self, project_name: str, updated_data: Dict) -> bool:
"""Update an existing project file."""
try:
with self._lock:
project_file = self.projects_dir / f"{project_name}.yaml"
if project_file.exists():
with open(project_file, 'r') as fh:
current_data = yaml.safe_load(fh)
updated_data.update(current_data)
with open(project_file, 'w') as fh:
yaml.dump(updated_data, fh)
self.reload()
except Exception as e:
logger.error(f"File watcher error: {e}")

def on_reload(self, callback: Callable):
"""Register a callback for when projects are reloaded.

Callback receives: callback(projects: List[Dict])
"""
self._on_reload_callbacks.append(callback)

# ── CRUD Operations ───────────────────────────────────────────────

def add_project(
self,
name: str,
url: str,
description: str,
project_type: str = "SaaS",
**kwargs,
) -> str:
"""Create a new project YAML file.

Returns the filepath of the created file.
Raises ValueError if file already exists.
"""
slug = name.lower().replace(" ", "_").replace("-", "_")
filepath = self.projects_dir / f"{slug}.yaml"

if filepath.exists():
raise ValueError(f"Project file already exists: {filepath}")

data = {
"project": {
"name": name,
"url": url,
"type": project_type,
"description": description,
"tagline": kwargs.get("tagline", ""),
"weight": kwargs.get("weight", 1.0),
"enabled": True,
"selling_points": kwargs.get("selling_points", []),
"target_audiences": kwargs.get("target_audiences", []),
"business_profile": {
"socials": {
"twitter": "",
"website": url,
},
"features": [],
"pricing": {
"model": "unknown",
"free_tier": "",
"paid_plans": [],
},
"faqs": [],
"competitors": [],
"rules": {
"never_say": [],
"always_accurate": [
f"Product name is exactly '{name}'",
f"URL is {url}",
],
},
},
},
"reddit": {
"target_subreddits": {"primary": [], "secondary": []},
"keywords": [],
"min_post_score": 3,
"max_post_age_hours": 24,
},
"twitter": {
"keywords": [],
"hashtags": [],
},
"tone": {
"style": "helpful_casual",
"language": "en",
"formality": "casual",
},
}

with open(filepath, "w") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
return True
else:
logger.error(f"Project file not found: {project_name}")
return False
except Exception as e:
logger.error(f"Error updating project file: {e}")
return False

def delete_project(self, project_name: str) -> bool:
"""Delete a project file."""
try:
with self._lock:
project_file = self.projects_dir / f"{project_name}.yaml"
if project_file.exists():
project_file.unlink()
self.reload()
return True
else:
logger.error(f"Project file not found: {project_name}")
return False
except Exception as e:
logger.error(f"Error deleting project file: {e}")
return False

self.reload()
return str(filepath)
# ── Callbacks ───────────────────────────────────────────────

def delete_project(self, name: str) -> bool:
"""Delete a project by name. Returns True if found and deleted."""
for f in self.projects_dir.glob("*.yaml"):
try:
with open(f) as fh:
data = yaml.safe_load(fh) or {}
if data.get("project", {}).get("name", "").lower() == name.lower():
f.unlink()
def add_on_reload_callback(self, callback: Callable):
"""Add a callback to be called on reload."""
with self._lock:
self._on_reload_callbacks.append(callback)

def remove_on_reload_callback(self, callback: Callable):
"""Remove a callback from being called on reload."""
with self._lock:
self._on_reload_callbacks.remove(callback)

# ── Modulo Operations ─────────────────────────────────────

def _mod_operation(self, operation: str, data: Dict) -> bool:
"""Perform a modulo operation on project data."""
try:
with self._lock:
if operation == "create":
return self.create_project(data)
elif operation == "update":
return self.update_project(data["name"], data)
elif operation == "delete":
return self.delete_project(data["name"])
else:
logger.error(f"Invalid modulo operation: {operation}")
return False
except Exception as e:
logger.error(f"Error performing modulo operation: {e}")
return False

# ── Reload on Modulo Operations ────────────────────────────

def reload_on_mod_operation(self, operation: str, data: Dict) -> bool:
"""Reload project data after performing a modulo operation."""
try:
with self._lock:
if self._mod_operation(operation, data):
self.reload()
return True
except Exception:
continue
return False

def get_project(self, name: str) -> Optional[Dict]:
"""Get a project by name (case-insensitive)."""
for p in self.projects:
if p.get("project", {}).get("name", "").lower() == name.lower():
return p
return None

def list_projects(self) -> List[str]:
"""List all project names."""
return [p["project"]["name"] for p in self.projects]

def get_project_filepath(self, name: str) -> Optional[str]:
"""Get the YAML file path for a project."""
for f in self.projects_dir.glob("*.yaml"):
try:
with open(f) as fh:
data = yaml.safe_load(fh) or {}
if data.get("project", {}).get("name", "").lower() == name.lower():
return str(f)
except Exception:
continue
return None
else:
return False
except Exception as e:
logger.error(f"Error reloading on modulo operation: {e}")
return False

# ── Reload on Modulo Operations with Callbacks ───────────────

def reload_on_mod_operation_with_callbacks(self, operation: str, data: Dict) -> bool:
"""Reload project data after performing a modulo operation and call callbacks."""
try:
with self._lock:
if self.reload_on_mod_operation(operation, data):
for callback in self._on_reload_callbacks:
callback()
return True
else:
return False
except Exception as e:
logger.error(f"Error reloading on modulo operation with callbacks: {e}")
return False