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>
This commit is contained in:
270
internal/crew/manager.go
Normal file
270
internal/crew/manager.go
Normal file
@@ -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
|
||||
}
|
||||
202
internal/crew/manager_test.go
Normal file
202
internal/crew/manager_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
72
internal/crew/types.go
Normal file
72
internal/crew/types.go
Normal file
@@ -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"
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user