blob: 14538206dd5a680c080ad233e55423cdef3de77a [file] [log] [blame]
package server
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"github.com/iomodo/staff/agent"
"github.com/iomodo/staff/git"
"github.com/iomodo/staff/llm"
"github.com/iomodo/staff/tm/git_tm"
"github.com/joho/godotenv"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
// Server type defines application global state
type Server struct {
logger *slog.Logger
agents []*agent.Agent
}
// NewServer creates new Server
func NewServer(logger *slog.Logger) (*Server, error) {
_ = godotenv.Load()
a := &Server{
logger: logger,
}
pwd, _ := os.Getwd()
a.logger.Info("Current working directory", slog.String("path", pwd))
return a, nil
}
// Start method starts an app
func (a *Server) Start() error {
a.logger.Info("Server is starting...")
// Get environment variables
remoteRepoURL := os.Getenv("REMOTE_REPO_URL")
workingDir := os.Getenv("WORKING_DIR")
if remoteRepoURL == "" {
return fmt.Errorf("REMOTE_REPO_URL environment variable is required")
}
if workingDir == "" {
return fmt.Errorf("WORKING_DIR environment variable is required")
}
a.logger.Info("Environment variables loaded",
slog.String("remoteRepoURL", remoteRepoURL),
slog.String("workingDir", workingDir))
// Check if working directory is empty
isEmpty, err := a.isDirectoryEmpty(workingDir)
if err != nil {
return fmt.Errorf("failed to check if directory is empty: %w", err)
}
if isEmpty {
a.logger.Info("Working directory is empty, initializing new repository")
if err := a.initializeNewRepository(workingDir, remoteRepoURL); err != nil {
return err
}
} else {
a.logger.Info("Working directory is not empty, syncing with remote")
if err := a.syncWithRemote(workingDir, remoteRepoURL); err != nil {
return err
}
}
// Create shared task manager
gitRepo := git.DefaultGit(workingDir)
tasksDir := filepath.Join(workingDir, "operations", "tasks")
taskManager := git_tm.NewGitTaskManager(gitRepo, tasksDir)
// Load and start agents
agentsDir := filepath.Join(workingDir, "operations", "agents")
entries, err := os.ReadDir(agentsDir)
if err != nil {
return fmt.Errorf("failed to read agents directory: %w", err)
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
agentName := entry.Name()
systemPath := filepath.Join(agentsDir, agentName, "system.md")
content, err := os.ReadFile(systemPath)
if err != nil {
a.logger.Error("Failed to read system prompt", slog.String("agent", agentName), slog.String("error", err.Error()))
continue
}
systemPrompt := string(content)
// LLM configuration
baseURL := os.Getenv("OPENAI_BASE_URL")
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
llmConfig := llm.Config{
Provider: llm.ProviderOpenAI,
APIKey: os.Getenv("OPENAI_API_KEY"),
BaseURL: baseURL,
Timeout: 30 * time.Second,
}
config := agent.AgentConfig{
Name: agentName,
Role: cases.Title(language.English).String(agentName),
GitUsername: fmt.Sprintf("Staff %s", cases.Title(language.English).String(agentName)),
GitEmail: fmt.Sprintf("%s@staff.com", strings.ToLower(agentName)),
WorkingDir: workingDir,
LLMProvider: llm.ProviderOpenAI,
LLMModel: "gpt-4o",
LLMConfig: llmConfig,
SystemPrompt: systemPrompt,
TaskManager: taskManager,
GitRepoPath: workingDir,
GitRemote: "origin",
GitBranch: "main",
}
ag, err := agent.NewAgent(config)
if err != nil {
a.logger.Error("Failed to create agent", slog.String("agent", agentName), slog.String("error", err.Error()))
continue
}
a.agents = append(a.agents, ag)
go func(ag *agent.Agent, name string) {
a.logger.Info("Starting agent", slog.String("name", name))
if err := ag.Run(); err != nil {
a.logger.Error("Agent failed", slog.String("name", name), slog.String("error", err.Error()))
}
}(ag, agentName)
}
return nil
}
// Shutdown method shuts server down
func (a *Server) Shutdown() {
a.logger.Info("Stopping Server...")
for _, ag := range a.agents {
ag.Stop()
}
a.logger.Info("All agents stopped")
a.logger.Info("Server stopped")
}
// isDirectoryEmpty checks if a directory is empty
func (a *Server) isDirectoryEmpty(dir string) (bool, error) {
// Create directory if it doesn't exist
if err := os.MkdirAll(dir, 0755); err != nil {
return false, fmt.Errorf("failed to create directory: %w", err)
}
entries, err := os.ReadDir(dir)
if err != nil {
return false, fmt.Errorf("failed to read directory: %w", err)
}
// Directory is empty if it has no entries or only has .git directory
if len(entries) == 0 {
return true, nil
}
// Check if directory only contains .git (which might be a git repo)
if len(entries) == 1 && entries[0].Name() == ".git" {
return true, nil
}
return false, nil
}
// initializeNewRepository creates a new git repository with prepopulated data
func (a *Server) initializeNewRepository(workingDir, remoteRepoURL string) error {
ctx := context.Background()
// Initialize git repository
gitRepo := git.DefaultGit(workingDir)
a.logger.Info("Initializing git repository", slog.String("path", workingDir))
if err := gitRepo.Init(ctx, workingDir); err != nil {
return fmt.Errorf("failed to initialize git repository: %w", err)
}
// Set up git user configuration
userConfig := git.UserConfig{
Name: "Staff System",
Email: "system@staff.com",
}
if err := gitRepo.SetUserConfig(ctx, userConfig); err != nil {
return fmt.Errorf("failed to set git user config: %w", err)
}
// Add remote origin
if err := gitRepo.AddRemote(ctx, "origin", remoteRepoURL); err != nil {
return fmt.Errorf("failed to add remote origin: %w", err)
}
// Create prepopulated directory structure and files
if err := a.createPrepopulatedStructure(workingDir); err != nil {
return fmt.Errorf("failed to create prepopulated structure: %w", err)
}
// Add all files to git
if err := gitRepo.AddAll(ctx); err != nil {
return fmt.Errorf("failed to add files to git: %w", err)
}
// Commit the initial structure
if err := gitRepo.Commit(ctx, "Initial commit: Add prepopulated project structure", git.CommitOptions{}); err != nil {
return fmt.Errorf("failed to commit initial structure: %w", err)
}
// Push to remote
if err := gitRepo.Push(ctx, "origin", "main", git.PushOptions{SetUpstream: true}); err != nil {
return fmt.Errorf("failed to push to remote: %w", err)
}
a.logger.Info("Successfully initialized repository and pushed to remote")
return nil
}
// syncWithRemote synchronizes local repository with remote
func (a *Server) syncWithRemote(workingDir, remoteRepoURL string) error {
ctx := context.Background()
// Check if it's a git repository
gitRepo := git.DefaultGit(workingDir)
isRepo, err := gitRepo.IsRepository(ctx, workingDir)
if err != nil {
return fmt.Errorf("failed to check if directory is git repository: %w", err)
}
if !isRepo {
return fmt.Errorf("working directory is not a git repository")
}
// Get current status
status, err := gitRepo.Status(ctx)
if err != nil {
return fmt.Errorf("failed to get git status: %w", err)
}
// Get current branch name
currentBranch, err := gitRepo.GetCurrentBranch(ctx)
if err != nil {
return fmt.Errorf("failed to get current branch: %w", err)
}
a.logger.Info("Current git status",
slog.String("branch", currentBranch),
slog.Bool("isClean", status.IsClean),
slog.Int("stagedFiles", len(status.Staged)),
slog.Int("unstagedFiles", len(status.Unstaged)),
slog.Int("untrackedFiles", len(status.Untracked)))
// Check if remote origin exists, if not add it
remotes, err := gitRepo.ListRemotes(ctx)
if err != nil {
return fmt.Errorf("failed to list remotes: %w", err)
}
originExists := false
for _, remote := range remotes {
if remote.Name == "origin" {
originExists = true
break
}
}
if !originExists {
a.logger.Info("Adding remote origin")
if err := gitRepo.AddRemote(ctx, "origin", remoteRepoURL); err != nil {
return fmt.Errorf("failed to add remote origin: %w", err)
}
}
// Fetch latest changes from remote
a.logger.Info("Fetching latest changes from remote")
if err := gitRepo.Fetch(ctx, "origin", git.FetchOptions{}); err != nil {
return fmt.Errorf("failed to fetch from remote: %w", err)
}
// If there are local changes, commit and push them
if !status.IsClean {
a.logger.Info("Local changes detected, committing and pushing")
if err := gitRepo.AddAll(ctx); err != nil {
return fmt.Errorf("failed to add local changes: %w", err)
}
if err := gitRepo.Commit(ctx, "Auto-sync: Local changes", git.CommitOptions{}); err != nil {
return fmt.Errorf("failed to commit local changes: %w", err)
}
if err := gitRepo.Push(ctx, "origin", currentBranch, git.PushOptions{}); err != nil {
return fmt.Errorf("failed to push local changes: %w", err)
}
}
// Pull latest changes from remote
a.logger.Info("Pulling latest changes from remote")
if err := gitRepo.Pull(ctx, "", ""); err != nil {
return fmt.Errorf("failed to pull from remote: %w", err)
}
a.logger.Info("Successfully synchronized with remote repository")
return nil
}
// createPrepopulatedStructure creates the required directory structure and files
func (a *Server) createPrepopulatedStructure(workingDir string) error {
// Create directories
dirs := []string{
filepath.Join(workingDir, "operations", "agents", "ceo"),
filepath.Join(workingDir, "operations", "agents", "pm"),
filepath.Join(workingDir, "operations", "tasks"),
filepath.Join(workingDir, "server"),
filepath.Join(workingDir, "webapp"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", dir, err)
}
}
// Read agent system files from the current project
ceoSystemPath := filepath.Join("operations", "agents", "ceo", "system.md")
pmSystemPath := filepath.Join("operations", "agents", "pm", "system.md")
taskExamplePath := filepath.Join("operations", "tasks", "example-task-file.md")
// Read CEO system file
ceoContent, err := os.ReadFile(ceoSystemPath)
if err != nil {
return fmt.Errorf("failed to read CEO system file: %w", err)
}
// Read PM system file
pmContent, err := os.ReadFile(pmSystemPath)
if err != nil {
return fmt.Errorf("failed to read PM system file: %w", err)
}
// Read task example file
taskExampleContent, err := os.ReadFile(taskExamplePath)
if err != nil {
return fmt.Errorf("failed to read task example file: %w", err)
}
// Create prepopulated files
files := map[string][]byte{
filepath.Join(workingDir, "operations", "agents", "ceo", "system.md"): ceoContent,
filepath.Join(workingDir, "operations", "agents", "pm", "system.md"): pmContent,
filepath.Join(workingDir, "operations", "tasks", "example-task-file.md"): taskExampleContent,
}
for filePath, content := range files {
if err := os.WriteFile(filePath, content, 0644); err != nil {
return fmt.Errorf("failed to create file %s: %w", filePath, err)
}
}
a.logger.Info("Created prepopulated directory structure and files")
return nil
}