Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
4a41f56
feat: implement plugin service installer
bekmuradov Aug 15, 2025
63bb40b
Rename to python manager
bekmuradov Aug 20, 2025
fa88406
Create docker manager
bekmuradov Aug 20, 2025
6118fdc
Refactor - export convert_to_download_url
bekmuradov Aug 20, 2025
47b1c6b
Create plugin service manager
bekmuradov Aug 20, 2025
8605025
Create PluginServiceRuntime model
bekmuradov Aug 20, 2025
19c4ed0
Create get_all_service_runtimes method
bekmuradov Aug 20, 2025
26c2ce4
Update plugin services installer import path
bekmuradov Aug 20, 2025
9e029f1
Create start and stop plugin services on startup / shutdown
bekmuradov Aug 20, 2025
9f7e313
Add start / stop plugin services
bekmuradov Aug 20, 2025
e22a975
Add required_services_runtime to plugin table
bekmuradov Aug 20, 2025
8302296
Add user_id and plugin_slug to plugin_service_runtime
bekmuradov Aug 20, 2025
123229f
Remove unused files
bekmuradov Aug 20, 2025
02f5f2b
Remove logs
bekmuradov Aug 20, 2025
0710b55
Create PluginServiceRuntimeDTO schema
bekmuradov Aug 20, 2025
ed6b57c
Add to_dict and from_dict methods to PluginServiceRuntime
bekmuradov Aug 20, 2025
a629685
Make get_all_service_runtimes return typed PluginServiceRuntimeDTO list
bekmuradov Aug 20, 2025
d1420dd
Add type support for service runtime (replace dict to PluginServiceRu…
bekmuradov Aug 20, 2025
4b72e34
Merge branch 'main' into feature/plugin-services-runtime
bekmuradov Aug 21, 2025
c876e1b
Create migration merge heads 219da9748f46 and 4f726504a718
bekmuradov Aug 21, 2025
b2042e8
Update plugin service imports and methods
bekmuradov Aug 22, 2025
d8caaa6
Update ROADMAP with completed and new tasks 8/25
DJJones66 Aug 25, 2025
6d93522
Ignore persistent folder
bekmuradov Aug 25, 2025
deb27e1
Add from_github_data and from_dict_or_dto methods
bekmuradov Aug 25, 2025
5599ea1
Create install_and_run_required_services and fix _extract_archive
bekmuradov Aug 25, 2025
f28f379
Use install_and_run_required_services
bekmuradov Aug 25, 2025
eb6c2ac
Merge branch 'feature/plugin-services-runtime' of https://github.com/…
bekmuradov Aug 25, 2025
118b943
Merge branch 'main' into feature/plugin-services-runtime
bekmuradov Aug 25, 2025
f757aea
Merge branch 'main' into feature/plugin-services-runtime
bekmuradov Aug 26, 2025
f5379db
Delete old scripts
bekmuradov Aug 26, 2025
5faa8ff
feat(database): add plugin service runtime management
bekmuradov Aug 26, 2025
d8cf4e7
fix(security): replace deprecated datetime.utcnow() with datetime.now…
bekmuradov Aug 26, 2025
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ backend/alembic/versions/
*.log
backend/logs/

backend/services_runtime/
backend/persistent/

# Environment variables
.env
.env.local
Expand Down
12 changes: 7 additions & 5 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,12 @@ Our roadmap is broken into clearly defined versions, each building toward a stab
## Version 0.6.0 – Open Beta
> Goal: AI System with core functionality for developers

- [ ] All plugins moved to the Life Cycle Manager
- [ ] Ollama plugin updated to include server manager
- [ ] User initializer - Plugin install from remote
- [ ] User initializer - Restructure navigation
- [ ] Prompt Library
- [x] All plugins moved to the Life Cycle Manager
- [x] Ollama plugin updated to include server manager
- [x] User initializer - Plugin install from remote
- [x] User initializer - Restructure navigation
- [ ] Improve Registration
- [ ] Ollama AI Provider
- [ ] One-Click Installer - (Windows first)

---
Expand All @@ -73,6 +74,7 @@ Our roadmap is broken into clearly defined versions, each building toward a stab

- [ ] Unified Dynamic Page Renderer - Bounce
- [ ] Unified Dynamic Page Renderer - Finetune
- [ ] Prompt Library

---

Expand Down
8 changes: 4 additions & 4 deletions backend/app/core/security.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime, timedelta as datetime_timedelta
from datetime import datetime, timedelta as datetime_timedelta, UTC
from typing import Optional
from jose import jwt, JWTError
import logging
Expand Down Expand Up @@ -41,16 +41,16 @@ def create_access_token(data: dict, expires_delta: Optional[datetime_timedelta]
"""Create a new access token."""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
expire = datetime.now(UTC) + expires_delta
else:
expire = datetime.utcnow() + datetime_timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
expire = datetime.now(UTC) + datetime_timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)

# Convert datetime to Unix timestamp for JWT
to_encode.update({"exp": expire.timestamp()})

# Let JWT library handle iat automatically if not provided
if "iat" not in to_encode:
to_encode.update({"iat": datetime.utcnow().timestamp()})
to_encode.update({"iat": datetime.now(UTC).timestamp()})

encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
Expand Down
Empty file added backend/app/dto/__init__.py
Empty file.
65 changes: 65 additions & 0 deletions backend/app/dto/plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from pydantic import BaseModel
from typing import List, Optional, Union
from datetime import datetime
import uuid

# This schema is used for returning data from the repository.
# It ensures that JSON fields are correctly converted to Python types.
class PluginServiceRuntimeDTO(BaseModel):
"""
A Pydantic model to represent a PluginServiceRuntime object,
with required_env_vars as a list of strings.
"""
id: str
plugin_id: str
plugin_slug: str
name: str
source_url: Optional[str] = None
type: Optional[str] = None
install_command: Optional[str] = None
start_command: Optional[str] = None
healthcheck_url: Optional[str] = None
required_env_vars: List[str] = []
status: Optional[str] = None
user_id: str
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None

@classmethod
def from_github_data(cls, service_dict: dict, plugin_id: str, plugin_slug: str, user_id: str) -> 'PluginServiceRuntimeDTO':
"""
Create a PluginServiceRuntimeDTO from raw GitHub service data (dict).
This handles first-time installation where database fields don't exist yet.
"""
return cls(
id=str(uuid.uuid4()), # Generate new UUID for first install
plugin_id=plugin_id,
plugin_slug=plugin_slug,
user_id=user_id,
name=service_dict.get('name'),
source_url=service_dict.get('source_url'),
type=service_dict.get('type', 'python'),
install_command=service_dict.get('install_command'),
start_command=service_dict.get('start_command'),
healthcheck_url=service_dict.get('healthcheck_url'),
required_env_vars=service_dict.get('required_env_vars', []),
status='installing',
created_at=datetime.now(),
updated_at=datetime.now()
)

@classmethod
def from_dict_or_dto(cls, data: Union[dict, 'PluginServiceRuntimeDTO'], plugin_id: str = None, plugin_slug: str = None, user_id: str = None) -> 'PluginServiceRuntimeDTO':
"""
Flexible factory method that handles both dict (GitHub) and DTO (database) inputs.
"""
if isinstance(data, cls):
return data # Already a DTO, return as-is
elif isinstance(data, dict):
# Dict from GitHub, convert using factory method
if not all([plugin_id, plugin_slug, user_id]):
raise ValueError("plugin_id, plugin_slug, and user_id are required when converting from dict")
return cls.from_github_data(data, plugin_id, plugin_slug, user_id)
else:
raise TypeError(f"Expected dict or {cls.__name__}, got {type(data)}")

16 changes: 14 additions & 2 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from app.api.v1.api import api_router
from app.core.config import settings
from app.routers.plugins import plugin_manager
from app.plugins.service_installler.start_stop_plugin_services import start_plugin_services_on_startup, stop_all_plugin_services_on_shutdown
import logging
import time
import structlog
Expand All @@ -29,8 +30,21 @@ async def startup_event():
logger.info("Initializing application settings...")
from app.init_settings import init_ollama_settings
await init_ollama_settings()
# Start plugin services
await start_plugin_services_on_startup()
logger.info("Settings initialization completed")


# Add shutdown event to gracefully stop services
@app.on_event("shutdown")
async def shutdown_event():
"""Gracefully stop all plugin services on application shutdown."""
logger.info("Shutting down application and stopping plugin services...")
# Stop all plugin services gracefully
await stop_all_plugin_services_on_shutdown()
logger.info("Application shutdown completed.")


# Add middleware to log all requests
logger = structlog.get_logger()

Expand Down Expand Up @@ -94,5 +108,3 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE

# Include API routers
app.include_router(api_router)


83 changes: 82 additions & 1 deletion backend/app/models/plugin.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from sqlalchemy import Column, String, Integer, Boolean, ForeignKey, Text, JSON, UniqueConstraint, TIMESTAMP
from sqlalchemy import Column, String, Integer, Boolean, ForeignKey, Text, JSON, UniqueConstraint, TIMESTAMP, DateTime
import sqlalchemy
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from datetime import datetime, UTC
import json

from app.models.base import Base


class Plugin(Base):
"""SQLAlchemy model for plugins."""

Expand Down Expand Up @@ -44,6 +47,7 @@ class Plugin(Base):
config_fields = Column(Text) # Stored as JSON string
messages = Column(Text) # Stored as JSON string
dependencies = Column(Text) # Stored as JSON string
required_services_runtime = Column(Text, nullable=True)

# Timestamps
created_at = Column(String, default=func.now())
Expand All @@ -60,6 +64,7 @@ class Plugin(Base):

# Relationships
modules = relationship("Module", back_populates="plugin", cascade="all, delete-orphan")
service_runtimes = relationship("PluginServiceRuntime", back_populates="plugin", cascade="all, delete-orphan")

def to_dict(self):
"""Convert model to dictionary."""
Expand Down Expand Up @@ -118,6 +123,11 @@ def to_dict(self):
else:
result["permissions"] = []

if self.required_services_runtime:
result["requiredServicesRuntime"] = json.loads(self.required_services_runtime)
else:
result["requiredServicesRuntime"] = []

return result

@classmethod
Expand Down Expand Up @@ -162,10 +172,81 @@ def from_dict(cls, data):
# Remove modules from data as they are handled separately
if "modules" in db_data:
db_data.pop("modules")

# Handle service runtimes (only store names in plugin table)
if "requiredServicesRuntime" in db_data and db_data["requiredServicesRuntime"] is not None:
db_data["required_services_runtime"] = json.dumps([
r["name"] for r in db_data["requiredServicesRuntime"]
])
db_data.pop("requiredServicesRuntime")

return cls(**db_data)


class PluginServiceRuntime(Base):
"""SQLAlchemy model for plugin service runtimes."""

__tablename__ = "plugin_service_runtime"

id = Column(String, primary_key=True, index=True)
plugin_id = Column(String, ForeignKey("plugin.id"), nullable=False, index=True)
plugin_slug = Column(String, nullable=False, index=True)

name = Column(String, nullable=False)
source_url = Column(String)
type = Column(String)
install_command = Column(Text)
start_command = Column(Text)
healthcheck_url = Column(String)
required_env_vars = Column(Text) # store as JSON string
status = Column(String, default="pending")

created_at = Column(DateTime, default=datetime.now(UTC))
updated_at = Column(DateTime, default=datetime.now(UTC), onupdate=datetime.now(UTC))

user_id = Column(String(32), ForeignKey("users.id", name="fk_plugin_service_runtime_user_id"), nullable=False)
user = relationship("User")

# Relationship back to plugin
plugin = relationship("Plugin", back_populates="service_runtimes")

def to_dict(self):
"""
Convert the model instance to a dictionary, handling JSON fields.
"""
return {
"id": self.id,
"plugin_id": self.plugin_id,
"plugin_slug": self.plugin_slug,
"name": self.name,
"source_url": self.source_url,
"type": self.type,
"install_command": self.install_command,
"start_command": self.start_command,
"healthcheck_url": self.healthcheck_url,
"required_env_vars": json.loads(self.required_env_vars) if self.required_env_vars else [],
"status": self.status,
"user_id": self.user_id,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}

@classmethod
def from_dict(cls, data: dict):
"""
Create a new instance from a dictionary, serializing JSON fields.
"""
db_data = data.copy()

if "required_env_vars" in db_data and db_data["required_env_vars"] is not None:
db_data["required_env_vars"] = json.dumps(db_data["required_env_vars"])

# Handle conversion from camelCase to snake_case if necessary
# For simplicity, we are assuming keys in the incoming dict match model attributes

return cls(**db_data)


class Module(Base):
"""SQLAlchemy model for plugin modules."""

Expand Down
23 changes: 22 additions & 1 deletion backend/app/plugins/remote_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
from typing import Dict, Any, Optional, List, Tuple
from urllib.parse import urlparse
import structlog
from .service_installler.plugin_service_manager import install_and_run_required_services
from .service_installler.service_runtime_extractor import extract_required_services_runtime

logger = structlog.get_logger()

Expand Down Expand Up @@ -237,6 +239,17 @@ async def install_from_url(self, repo_url: str, user_id: str, version: str = "la
if install_result['success']:
logger.info(f"Plugin installation successful: {install_result}")

service_runtime: list = validation_result.get("service_runtime", [])
logger.info(f"\n\n>>>>>>>>SERVICE RUNTIME\n\n: {service_runtime}\n\n>>>>>>>>>>")
if service_runtime:
plugin_slug = validation_result["plugin_info"].get("plugin_slug")
await install_and_run_required_services(
service_runtime,
plugin_slug,
install_result['plugin_id'],
user_id
)

# Store installation metadata
try:
await self._store_installation_metadata(
Expand Down Expand Up @@ -718,6 +731,7 @@ async def _validate_plugin_structure(self, plugin_dir: Path) -> Dict[str, Any]:

# Try to load plugin metadata
plugin_info = {}
service_runtime = []

# Check package.json
package_json_path = plugin_dir / 'package.json'
Expand Down Expand Up @@ -828,6 +842,12 @@ async def _validate_plugin_structure(self, plugin_dir: Path) -> Dict[str, Any]:
extracted_slug = slug_match.group(1)
plugin_info['plugin_slug'] = extracted_slug
logger.info(f"Extracted plugin_slug from source: {extracted_slug}")

# Extract services using the dedicated function
services = extract_required_services_runtime(content, plugin_info.get('plugin_slug'))
if services:
plugin_info['required_services_runtime'] = services
service_runtime.extend(services)
except Exception as extract_error:
logger.warning(f"Could not extract plugin_slug from source: {extract_error}")

Expand All @@ -839,7 +859,8 @@ async def _validate_plugin_structure(self, plugin_dir: Path) -> Dict[str, Any]:

return {
'valid': True,
'plugin_info': plugin_info
'plugin_info': plugin_info,
'service_runtime': service_runtime
}

except Exception as e:
Expand Down
24 changes: 22 additions & 2 deletions backend/app/plugins/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
from sqlalchemy.ext.asyncio import AsyncSession
import structlog

from app.models.plugin import Plugin, Module
from app.models.plugin import Plugin, Module, PluginServiceRuntime
from app.dto.plugin import PluginServiceRuntimeDTO

logger = structlog.get_logger()

Expand Down Expand Up @@ -85,7 +86,7 @@ async def get_all_plugins_with_modules(self, user_id: str = None) -> List[Dict[s
plugin_dicts = []
for plugin in plugins:
plugin_dict = plugin.to_dict()

# Get modules for this plugin
modules_query = select(Module).where(Module.plugin_id == plugin.id)

Expand All @@ -104,6 +105,25 @@ async def get_all_plugins_with_modules(self, user_id: str = None) -> List[Dict[s
except Exception as e:
logger.error("Error getting plugins with modules", error=str(e))
raise

async def get_all_service_runtimes(self) -> List[PluginServiceRuntimeDTO]:
"""
Get all plugin service runtimes for startup and return them as DTOs.
"""
try:
query = select(PluginServiceRuntime).where(
PluginServiceRuntime.status.in_(["pending", "stopped", "running"])
)

result = await self.db.execute(query)
services = result.scalars().all()

# Convert SQLAlchemy models to Pydantic DTOs for a typed return
return [PluginServiceRuntimeDTO(**service.to_dict()) for service in services]

except Exception as e:
logger.error("Error getting service runtimes", error=str(e))
raise

async def get_plugin(self, plugin_id: str) -> Optional[Dict[str, Any]]:
"""Get a specific plugin by ID."""
Expand Down
Loading