| 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 |
| } |