diff --git a/internal/crew/manager.go b/internal/crew/manager.go new file mode 100644 index 00000000..7e7aab84 --- /dev/null +++ b/internal/crew/manager.go @@ -0,0 +1,270 @@ +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 +} diff --git a/internal/crew/manager_test.go b/internal/crew/manager_test.go new file mode 100644 index 00000000..9eea4c88 --- /dev/null +++ b/internal/crew/manager_test.go @@ -0,0 +1,202 @@ +package crew + +import ( + "os" + "path/filepath" + "testing" + + "github.com/steveyegge/gastown/internal/git" + "github.com/steveyegge/gastown/internal/rig" +) + +func TestManager_workerDir(t *testing.T) { + r := &rig.Rig{ + Name: "test-rig", + Path: "/tmp/test-rig", + } + m := NewManager(r, nil) + + got := m.workerDir("alice") + want := "/tmp/test-rig/crew/alice" + + if got != want { + t.Errorf("workerDir() = %q, want %q", got, want) + } +} + +func TestManager_stateFile(t *testing.T) { + r := &rig.Rig{ + Name: "test-rig", + Path: "/tmp/test-rig", + } + m := NewManager(r, nil) + + got := m.stateFile("bob") + want := "/tmp/test-rig/crew/bob/state.json" + + if got != want { + t.Errorf("stateFile() = %q, want %q", got, want) + } +} + +func TestManager_exists(t *testing.T) { + // Create temp directory structure + tmpDir := t.TempDir() + crewDir := filepath.Join(tmpDir, "crew", "existing-worker") + if err := os.MkdirAll(crewDir, 0755); err != nil { + t.Fatal(err) + } + + r := &rig.Rig{ + Name: "test-rig", + Path: tmpDir, + } + m := NewManager(r, nil) + + tests := []struct { + name string + worker string + want bool + }{ + {"existing worker", "existing-worker", true}, + {"non-existing worker", "non-existing", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := m.exists(tt.worker) + if got != tt.want { + t.Errorf("exists(%q) = %v, want %v", tt.worker, got, tt.want) + } + }) + } +} + +func TestManager_List_Empty(t *testing.T) { + tmpDir := t.TempDir() + + r := &rig.Rig{ + Name: "test-rig", + Path: tmpDir, + } + m := NewManager(r, git.NewGit(tmpDir)) + + workers, err := m.List() + if err != nil { + t.Fatalf("List() error = %v", err) + } + + if len(workers) != 0 { + t.Errorf("List() returned %d workers, want 0", len(workers)) + } +} + +func TestManager_List_WithWorkers(t *testing.T) { + tmpDir := t.TempDir() + + // Create some fake worker directories + workers := []string{"alice", "bob", "charlie"} + for _, name := range workers { + workerDir := filepath.Join(tmpDir, "crew", name) + if err := os.MkdirAll(workerDir, 0755); err != nil { + t.Fatal(err) + } + } + + r := &rig.Rig{ + Name: "test-rig", + Path: tmpDir, + } + m := NewManager(r, git.NewGit(tmpDir)) + + gotWorkers, err := m.List() + if err != nil { + t.Fatalf("List() error = %v", err) + } + + if len(gotWorkers) != len(workers) { + t.Errorf("List() returned %d workers, want %d", len(gotWorkers), len(workers)) + } +} + +func TestManager_Names(t *testing.T) { + tmpDir := t.TempDir() + + // Create some fake worker directories + expected := []string{"alice", "bob"} + for _, name := range expected { + workerDir := filepath.Join(tmpDir, "crew", name) + if err := os.MkdirAll(workerDir, 0755); err != nil { + t.Fatal(err) + } + } + + r := &rig.Rig{ + Name: "test-rig", + Path: tmpDir, + } + m := NewManager(r, git.NewGit(tmpDir)) + + names, err := m.Names() + if err != nil { + t.Fatalf("Names() error = %v", err) + } + + if len(names) != len(expected) { + t.Errorf("Names() returned %d names, want %d", len(names), len(expected)) + } +} + +func TestWorker_EffectiveBeadsDir(t *testing.T) { + tests := []struct { + name string + beadsDir string + rigPath string + want string + }{ + { + name: "custom beads dir", + beadsDir: "/custom/beads", + rigPath: "/tmp/rig", + want: "/custom/beads", + }, + { + name: "default beads dir", + beadsDir: "", + rigPath: "/tmp/rig", + want: "/tmp/rig/.beads", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := &Worker{ + BeadsDir: tt.beadsDir, + } + got := w.EffectiveBeadsDir(tt.rigPath) + if got != tt.want { + t.Errorf("EffectiveBeadsDir() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestWorker_Summary(t *testing.T) { + w := &Worker{ + Name: "alice", + State: StateActive, + Branch: "feature/test", + } + + got := w.Summary() + + if got.Name != w.Name { + t.Errorf("Summary().Name = %q, want %q", got.Name, w.Name) + } + if got.State != w.State { + t.Errorf("Summary().State = %q, want %q", got.State, w.State) + } + if got.Branch != w.Branch { + t.Errorf("Summary().Branch = %q, want %q", got.Branch, w.Branch) + } +} diff --git a/internal/crew/types.go b/internal/crew/types.go new file mode 100644 index 00000000..b0bec6bd --- /dev/null +++ b/internal/crew/types.go @@ -0,0 +1,72 @@ +// Package crew provides crew worker management. +// Crew workers are user-managed persistent workspaces within a rig, +// as opposed to polecats which are AI-managed workers. +package crew + +import "time" + +// State represents the current state of a crew worker. +type State string + +const ( + // StateActive means the crew workspace is active. + StateActive State = "active" + + // StateInactive means the crew workspace is inactive. + StateInactive State = "inactive" +) + +// Worker represents a crew member's workspace in a rig. +// Unlike polecats, crew workers are managed by the Overseer (human), +// not by AI agents. +type Worker struct { + // Name is the crew worker identifier. + Name string `json:"name"` + + // Rig is the rig this worker belongs to. + Rig string `json:"rig"` + + // State is the current state. + State State `json:"state"` + + // ClonePath is the path to the worker's clone. + ClonePath string `json:"clone_path"` + + // Branch is the current git branch (if any). + Branch string `json:"branch,omitempty"` + + // BeadsDir is an optional custom beads directory. + // If empty, defaults to the rig's .beads/ directory. + BeadsDir string `json:"beads_dir,omitempty"` + + // CreatedAt is when the worker was created. + CreatedAt time.Time `json:"created_at"` + + // UpdatedAt is when the worker was last updated. + UpdatedAt time.Time `json:"updated_at"` +} + +// Summary provides a concise view of crew worker status. +type Summary struct { + Name string `json:"name"` + State State `json:"state"` + Branch string `json:"branch,omitempty"` +} + +// Summary returns a Summary for this worker. +func (w *Worker) Summary() Summary { + return Summary{ + Name: w.Name, + State: w.State, + Branch: w.Branch, + } +} + +// EffectiveBeadsDir returns the beads directory to use. +// Returns the custom BeadsDir if set, otherwise returns the rig default path. +func (w *Worker) EffectiveBeadsDir(rigPath string) string { + if w.BeadsDir != "" { + return w.BeadsDir + } + return rigPath + "/.beads" +} diff --git a/internal/rig/manager.go b/internal/rig/manager.go index 8fe79bc9..3e336343 100644 --- a/internal/rig/manager.go +++ b/internal/rig/manager.go @@ -94,6 +94,16 @@ func (m *Manager) loadRig(name string, entry config.RigEntry) (*Rig, error) { } } + // Scan for crew workers + crewDir := filepath.Join(rigPath, "crew") + if entries, err := os.ReadDir(crewDir); err == nil { + for _, e := range entries { + if e.IsDir() { + rig.Crew = append(rig.Crew, e.Name()) + } + } + } + // Check for witness witnessPath := filepath.Join(rigPath, "witness", "rig") if _, err := os.Stat(witnessPath); err == nil { diff --git a/internal/rig/types.go b/internal/rig/types.go index 897dd266..ad74f6e3 100644 --- a/internal/rig/types.go +++ b/internal/rig/types.go @@ -22,6 +22,10 @@ type Rig struct { // Polecats is the list of polecat names in this rig. Polecats []string `json:"polecats,omitempty"` + // Crew is the list of crew worker names in this rig. + // Crew workers are user-managed persistent workspaces. + Crew []string `json:"crew,omitempty"` + // HasWitness indicates if the rig has a witness agent. HasWitness bool `json:"has_witness"` @@ -35,6 +39,7 @@ type Rig struct { // AgentDirs are the standard agent directories in a rig. var AgentDirs = []string{ "polecats", + "crew", "refinery/rig", "witness/rig", "mayor/rig", @@ -42,18 +47,20 @@ var AgentDirs = []string{ // RigSummary provides a concise overview of a rig. type RigSummary struct { - Name string `json:"name"` + Name string `json:"name"` PolecatCount int `json:"polecat_count"` - HasWitness bool `json:"has_witness"` - HasRefinery bool `json:"has_refinery"` + CrewCount int `json:"crew_count"` + HasWitness bool `json:"has_witness"` + HasRefinery bool `json:"has_refinery"` } // Summary returns a RigSummary for this rig. func (r *Rig) Summary() RigSummary { return RigSummary{ - Name: r.Name, + Name: r.Name, PolecatCount: len(r.Polecats), - HasWitness: r.HasWitness, - HasRefinery: r.HasRefinery, + CrewCount: len(r.Crew), + HasWitness: r.HasWitness, + HasRefinery: r.HasRefinery, } }