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>
271 lines
5.8 KiB
Go
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
|
|
}
|