Files
gastown/internal/crew/manager.go
Steve Yegge 05f692cc2f feat: add crew directory structure and manager
Add crew/ directory support to rig structure for user-managed
persistent workspaces. Crew workers are separate from polecats
(AI-managed) and can have optional custom BEADS_DIR configuration.

- Add internal/crew package with Worker type and Manager
- Update rig types to include Crew slice and CrewCount in summary
- Update rig manager to scan for crew workers
- Add crew/ to AgentDirs for rig initialization

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 20:31:18 -08:00

271 lines
5.8 KiB
Go

package crew
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/rig"
)
// Common errors
var (
ErrWorkerExists = errors.New("crew worker already exists")
ErrWorkerNotFound = errors.New("crew worker not found")
ErrHasChanges = errors.New("crew worker has uncommitted changes")
)
// Manager handles crew worker lifecycle.
type Manager struct {
rig *rig.Rig
git *git.Git
}
// NewManager creates a new crew manager.
func NewManager(r *rig.Rig, g *git.Git) *Manager {
return &Manager{
rig: r,
git: g,
}
}
// workerDir returns the directory for a crew worker.
func (m *Manager) workerDir(name string) string {
return filepath.Join(m.rig.Path, "crew", name)
}
// stateFile returns the state file path for a crew worker.
func (m *Manager) stateFile(name string) string {
return filepath.Join(m.workerDir(name), "state.json")
}
// exists checks if a crew worker exists.
func (m *Manager) exists(name string) bool {
_, err := os.Stat(m.workerDir(name))
return err == nil
}
// Add creates a new crew worker with a clone of the rig.
func (m *Manager) Add(name string) (*Worker, error) {
if m.exists(name) {
return nil, ErrWorkerExists
}
workerPath := m.workerDir(name)
// Create crew directory if needed
crewDir := filepath.Join(m.rig.Path, "crew")
if err := os.MkdirAll(crewDir, 0755); err != nil {
return nil, fmt.Errorf("creating crew dir: %w", err)
}
// Clone the rig repo
if err := m.git.Clone(m.rig.GitURL, workerPath); err != nil {
return nil, fmt.Errorf("cloning rig: %w", err)
}
// Create crew worker state
now := time.Now()
worker := &Worker{
Name: name,
Rig: m.rig.Name,
State: StateActive,
ClonePath: workerPath,
CreatedAt: now,
UpdatedAt: now,
}
// Save state
if err := m.saveState(worker); err != nil {
os.RemoveAll(workerPath)
return nil, fmt.Errorf("saving state: %w", err)
}
return worker, nil
}
// AddWithConfig creates a new crew worker with custom configuration.
func (m *Manager) AddWithConfig(name string, beadsDir string) (*Worker, error) {
worker, err := m.Add(name)
if err != nil {
return nil, err
}
// Update with custom config
if beadsDir != "" {
worker.BeadsDir = beadsDir
if err := m.saveState(worker); err != nil {
return nil, fmt.Errorf("saving config: %w", err)
}
}
return worker, nil
}
// Remove deletes a crew worker.
func (m *Manager) Remove(name string) error {
if !m.exists(name) {
return ErrWorkerNotFound
}
workerPath := m.workerDir(name)
workerGit := git.NewGit(workerPath)
// Check for uncommitted changes
hasChanges, err := workerGit.HasUncommittedChanges()
if err == nil && hasChanges {
return ErrHasChanges
}
// Remove directory
if err := os.RemoveAll(workerPath); err != nil {
return fmt.Errorf("removing crew worker dir: %w", err)
}
return nil
}
// RemoveForce deletes a crew worker even with uncommitted changes.
func (m *Manager) RemoveForce(name string) error {
if !m.exists(name) {
return ErrWorkerNotFound
}
workerPath := m.workerDir(name)
if err := os.RemoveAll(workerPath); err != nil {
return fmt.Errorf("removing crew worker dir: %w", err)
}
return nil
}
// List returns all crew workers in the rig.
func (m *Manager) List() ([]*Worker, error) {
crewDir := filepath.Join(m.rig.Path, "crew")
entries, err := os.ReadDir(crewDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("reading crew dir: %w", err)
}
var workers []*Worker
for _, entry := range entries {
if !entry.IsDir() {
continue
}
worker, err := m.Get(entry.Name())
if err != nil {
continue // Skip invalid workers
}
workers = append(workers, worker)
}
return workers, nil
}
// Get returns a specific crew worker by name.
func (m *Manager) Get(name string) (*Worker, error) {
if !m.exists(name) {
return nil, ErrWorkerNotFound
}
return m.loadState(name)
}
// SetState updates a crew worker's state.
func (m *Manager) SetState(name string, state State) error {
worker, err := m.Get(name)
if err != nil {
return err
}
worker.State = state
worker.UpdatedAt = time.Now()
return m.saveState(worker)
}
// SetBeadsDir updates the custom beads directory for a crew worker.
func (m *Manager) SetBeadsDir(name, beadsDir string) error {
worker, err := m.Get(name)
if err != nil {
return err
}
worker.BeadsDir = beadsDir
worker.UpdatedAt = time.Now()
return m.saveState(worker)
}
// saveState persists crew worker state to disk.
func (m *Manager) saveState(worker *Worker) error {
data, err := json.MarshalIndent(worker, "", " ")
if err != nil {
return fmt.Errorf("marshaling state: %w", err)
}
stateFile := m.stateFile(worker.Name)
if err := os.WriteFile(stateFile, data, 0644); err != nil {
return fmt.Errorf("writing state: %w", err)
}
return nil
}
// loadState reads crew worker state from disk.
func (m *Manager) loadState(name string) (*Worker, error) {
stateFile := m.stateFile(name)
data, err := os.ReadFile(stateFile)
if err != nil {
if os.IsNotExist(err) {
// Return minimal worker if state file missing
return &Worker{
Name: name,
Rig: m.rig.Name,
State: StateActive,
ClonePath: m.workerDir(name),
}, nil
}
return nil, fmt.Errorf("reading state: %w", err)
}
var worker Worker
if err := json.Unmarshal(data, &worker); err != nil {
return nil, fmt.Errorf("parsing state: %w", err)
}
return &worker, nil
}
// Names returns just the names of all crew workers.
func (m *Manager) Names() ([]string, error) {
crewDir := filepath.Join(m.rig.Path, "crew")
entries, err := os.ReadDir(crewDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("reading crew dir: %w", err)
}
var names []string
for _, entry := range entries {
if entry.IsDir() {
names = append(names, entry.Name())
}
}
return names, nil
}