Add Dog infrastructure package for Deacon helper workers (gt-0x5og.2)
Dogs are reusable workers managed by the Deacon for cross-rig infrastructure tasks. Unlike polecats (single-rig, ephemeral), dogs have worktrees into multiple rigs and persist between tasks. Key components: - internal/dog/types.go: Dog struct, State enum, DogState JSON schema - internal/dog/manager.go: Manager with Add/Remove/List/Get/Refresh operations - internal/dog/manager_test.go: Unit tests Features: - Multi-rig worktrees: Each dog gets a worktree per configured rig - State tracking: .dog.json with idle/working state, last-active, work assignment - Worktree refresh: Recreate stale worktrees with fresh branches - Branch cleanup: Remove orphaned dog branches across all rigs Directory structure: ~/gt/deacon/dogs/<name>/ - <rig>/ (worktree into each rig: gastown/, beads/, etc.) - .dog.json (state file) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
562
internal/dog/manager.go
Normal file
562
internal/dog/manager.go
Normal file
@@ -0,0 +1,562 @@
|
||||
package dog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
"github.com/steveyegge/gastown/internal/git"
|
||||
)
|
||||
|
||||
// Common errors
|
||||
var (
|
||||
ErrDogExists = errors.New("dog already exists")
|
||||
ErrDogNotFound = errors.New("dog not found")
|
||||
ErrNoRigs = errors.New("no rigs configured")
|
||||
)
|
||||
|
||||
// Manager handles dog lifecycle in the kennel.
|
||||
type Manager struct {
|
||||
townRoot string
|
||||
kennelPath string // ~/gt/deacon/dogs/
|
||||
rigsConfig *config.RigsConfig
|
||||
}
|
||||
|
||||
// NewManager creates a new dog manager.
|
||||
func NewManager(townRoot string, rigsConfig *config.RigsConfig) *Manager {
|
||||
return &Manager{
|
||||
townRoot: townRoot,
|
||||
kennelPath: filepath.Join(townRoot, "deacon", "dogs"),
|
||||
rigsConfig: rigsConfig,
|
||||
}
|
||||
}
|
||||
|
||||
// dogDir returns the directory for a dog.
|
||||
func (m *Manager) dogDir(name string) string {
|
||||
return filepath.Join(m.kennelPath, name)
|
||||
}
|
||||
|
||||
// exists checks if a dog exists.
|
||||
func (m *Manager) exists(name string) bool {
|
||||
_, err := os.Stat(m.dogDir(name))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// stateFilePath returns the path to a dog's state file.
|
||||
func (m *Manager) stateFilePath(name string) string {
|
||||
return filepath.Join(m.dogDir(name), ".dog.json")
|
||||
}
|
||||
|
||||
// Add creates a new dog in the kennel with worktrees into each rig.
|
||||
// Each dog gets a worktree per rig (e.g., dogs/alpha/gastown/, dogs/alpha/beads/).
|
||||
// Worktrees are created from each rig's bare repo (.repo.git) or mayor/rig.
|
||||
func (m *Manager) Add(name string) (*Dog, error) {
|
||||
if m.exists(name) {
|
||||
return nil, ErrDogExists
|
||||
}
|
||||
|
||||
// Verify we have rigs to create worktrees into
|
||||
if len(m.rigsConfig.Rigs) == 0 {
|
||||
return nil, ErrNoRigs
|
||||
}
|
||||
|
||||
dogPath := m.dogDir(name)
|
||||
|
||||
// Create kennel dir if needed
|
||||
if err := os.MkdirAll(m.kennelPath, 0755); err != nil {
|
||||
return nil, fmt.Errorf("creating kennel dir: %w", err)
|
||||
}
|
||||
|
||||
// Create dog directory
|
||||
if err := os.MkdirAll(dogPath, 0755); err != nil {
|
||||
return nil, fmt.Errorf("creating dog dir: %w", err)
|
||||
}
|
||||
|
||||
// Track cleanup on failure
|
||||
cleanup := func() { _ = os.RemoveAll(dogPath) }
|
||||
success := false
|
||||
defer func() {
|
||||
if !success {
|
||||
cleanup()
|
||||
}
|
||||
}()
|
||||
|
||||
// Create worktrees into each rig
|
||||
worktrees := make(map[string]string)
|
||||
for rigName := range m.rigsConfig.Rigs {
|
||||
worktreePath, err := m.createRigWorktree(dogPath, name, rigName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating worktree for rig %s: %w", rigName, err)
|
||||
}
|
||||
worktrees[rigName] = worktreePath
|
||||
}
|
||||
|
||||
// Create initial state file
|
||||
now := time.Now()
|
||||
state := &DogState{
|
||||
Name: name,
|
||||
State: StateIdle,
|
||||
LastActive: now,
|
||||
Worktrees: worktrees,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := m.saveState(name, state); err != nil {
|
||||
return nil, fmt.Errorf("saving state: %w", err)
|
||||
}
|
||||
|
||||
success = true
|
||||
return &Dog{
|
||||
Name: name,
|
||||
State: StateIdle,
|
||||
Path: dogPath,
|
||||
Worktrees: worktrees,
|
||||
LastActive: now,
|
||||
CreatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// createRigWorktree creates a worktree for a dog into a specific rig.
|
||||
// Uses the rig's bare repo (.repo.git) if available, otherwise mayor/rig.
|
||||
// Branch naming: dog/<dog-name>-<rig>-<timestamp> for uniqueness.
|
||||
func (m *Manager) createRigWorktree(dogPath, dogName, rigName string) (string, error) {
|
||||
rigPath := filepath.Join(m.townRoot, rigName)
|
||||
worktreePath := filepath.Join(dogPath, rigName)
|
||||
|
||||
// Find the repo base (bare repo or mayor/rig)
|
||||
repoGit, err := m.findRepoBase(rigPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("finding repo base for %s: %w", rigName, err)
|
||||
}
|
||||
|
||||
// Unique branch per dog-rig combination
|
||||
branchName := fmt.Sprintf("dog/%s-%s-%d", dogName, rigName, time.Now().UnixMilli())
|
||||
|
||||
// Create worktree with new branch
|
||||
if err := repoGit.WorktreeAdd(worktreePath, branchName); err != nil {
|
||||
return "", fmt.Errorf("creating worktree: %w", err)
|
||||
}
|
||||
|
||||
return worktreePath, nil
|
||||
}
|
||||
|
||||
// findRepoBase locates the git repo base for a rig.
|
||||
// Prefers .repo.git (bare repo), falls back to mayor/rig.
|
||||
func (m *Manager) findRepoBase(rigPath string) (*git.Git, error) {
|
||||
// Check for shared bare repo
|
||||
bareRepoPath := filepath.Join(rigPath, ".repo.git")
|
||||
if info, err := os.Stat(bareRepoPath); err == nil && info.IsDir() {
|
||||
return git.NewGitWithDir(bareRepoPath, ""), nil
|
||||
}
|
||||
|
||||
// Fall back to mayor/rig
|
||||
mayorPath := filepath.Join(rigPath, "mayor", "rig")
|
||||
if _, err := os.Stat(mayorPath); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("no repo base found (neither .repo.git nor mayor/rig exists)")
|
||||
}
|
||||
return git.NewGit(mayorPath), nil
|
||||
}
|
||||
|
||||
// Remove deletes a dog from the kennel.
|
||||
// Removes all worktrees and the dog directory.
|
||||
func (m *Manager) Remove(name string) error {
|
||||
if !m.exists(name) {
|
||||
return ErrDogNotFound
|
||||
}
|
||||
|
||||
dogPath := m.dogDir(name)
|
||||
|
||||
// Load state to get worktree paths
|
||||
state, err := m.loadState(name)
|
||||
if err != nil {
|
||||
// State file may be missing, proceed with cleanup
|
||||
state = &DogState{Worktrees: make(map[string]string)}
|
||||
}
|
||||
|
||||
// Remove worktrees from each rig
|
||||
for rigName, worktreePath := range state.Worktrees {
|
||||
rigPath := filepath.Join(m.townRoot, rigName)
|
||||
repoGit, err := m.findRepoBase(rigPath)
|
||||
if err != nil {
|
||||
// Log but continue with other rigs
|
||||
fmt.Printf("Warning: could not find repo base for %s: %v\n", rigName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to remove worktree properly
|
||||
if err := repoGit.WorktreeRemove(worktreePath, true); err != nil {
|
||||
// Log but continue - will remove directory below
|
||||
fmt.Printf("Warning: could not remove worktree %s: %v\n", worktreePath, err)
|
||||
}
|
||||
|
||||
// Prune stale entries
|
||||
_ = repoGit.WorktreePrune()
|
||||
}
|
||||
|
||||
// Remove dog directory
|
||||
if err := os.RemoveAll(dogPath); err != nil {
|
||||
return fmt.Errorf("removing dog dir: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns all dogs in the kennel.
|
||||
func (m *Manager) List() ([]*Dog, error) {
|
||||
entries, err := os.ReadDir(m.kennelPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("reading kennel: %w", err)
|
||||
}
|
||||
|
||||
var dogs []*Dog
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
dog, err := m.Get(entry.Name())
|
||||
if err != nil {
|
||||
continue // Skip invalid dogs
|
||||
}
|
||||
dogs = append(dogs, dog)
|
||||
}
|
||||
|
||||
return dogs, nil
|
||||
}
|
||||
|
||||
// Get returns a specific dog by name.
|
||||
func (m *Manager) Get(name string) (*Dog, error) {
|
||||
if !m.exists(name) {
|
||||
return nil, ErrDogNotFound
|
||||
}
|
||||
|
||||
state, err := m.loadState(name)
|
||||
if err != nil {
|
||||
// Return minimal dog if state file is missing
|
||||
return &Dog{
|
||||
Name: name,
|
||||
State: StateIdle,
|
||||
Path: m.dogDir(name),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &Dog{
|
||||
Name: name,
|
||||
State: state.State,
|
||||
Path: m.dogDir(name),
|
||||
Worktrees: state.Worktrees,
|
||||
LastActive: state.LastActive,
|
||||
Work: state.Work,
|
||||
CreatedAt: state.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetState updates a dog's state and last-active timestamp.
|
||||
func (m *Manager) SetState(name string, state State) error {
|
||||
if !m.exists(name) {
|
||||
return ErrDogNotFound
|
||||
}
|
||||
|
||||
dogState, err := m.loadState(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading state: %w", err)
|
||||
}
|
||||
|
||||
dogState.State = state
|
||||
dogState.LastActive = time.Now()
|
||||
dogState.UpdatedAt = time.Now()
|
||||
|
||||
return m.saveState(name, dogState)
|
||||
}
|
||||
|
||||
// AssignWork assigns work to a dog and sets it to working state.
|
||||
func (m *Manager) AssignWork(name, work string) error {
|
||||
if !m.exists(name) {
|
||||
return ErrDogNotFound
|
||||
}
|
||||
|
||||
state, err := m.loadState(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading state: %w", err)
|
||||
}
|
||||
|
||||
state.State = StateWorking
|
||||
state.Work = work
|
||||
state.LastActive = time.Now()
|
||||
state.UpdatedAt = time.Now()
|
||||
|
||||
return m.saveState(name, state)
|
||||
}
|
||||
|
||||
// ClearWork clears a dog's work assignment and sets it to idle.
|
||||
func (m *Manager) ClearWork(name string) error {
|
||||
if !m.exists(name) {
|
||||
return ErrDogNotFound
|
||||
}
|
||||
|
||||
state, err := m.loadState(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading state: %w", err)
|
||||
}
|
||||
|
||||
state.State = StateIdle
|
||||
state.Work = ""
|
||||
state.LastActive = time.Now()
|
||||
state.UpdatedAt = time.Now()
|
||||
|
||||
return m.saveState(name, state)
|
||||
}
|
||||
|
||||
// Refresh recreates all worktrees for a dog with fresh branches.
|
||||
// This is useful when worktrees have drifted or become stale.
|
||||
func (m *Manager) Refresh(name string) error {
|
||||
if !m.exists(name) {
|
||||
return ErrDogNotFound
|
||||
}
|
||||
|
||||
state, err := m.loadState(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading state: %w", err)
|
||||
}
|
||||
|
||||
dogPath := m.dogDir(name)
|
||||
newWorktrees := make(map[string]string)
|
||||
|
||||
// Recreate each worktree
|
||||
for rigName := range m.rigsConfig.Rigs {
|
||||
rigPath := filepath.Join(m.townRoot, rigName)
|
||||
oldWorktreePath := state.Worktrees[rigName]
|
||||
|
||||
// Find repo base
|
||||
repoGit, err := m.findRepoBase(rigPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding repo base for %s: %w", rigName, err)
|
||||
}
|
||||
|
||||
// Remove old worktree if it exists
|
||||
if oldWorktreePath != "" {
|
||||
_ = repoGit.WorktreeRemove(oldWorktreePath, true)
|
||||
_ = os.RemoveAll(oldWorktreePath)
|
||||
_ = repoGit.WorktreePrune()
|
||||
}
|
||||
|
||||
// Fetch latest from origin
|
||||
_ = repoGit.Fetch("origin")
|
||||
|
||||
// Create fresh worktree
|
||||
worktreePath, err := m.createRigWorktree(dogPath, name, rigName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating worktree for %s: %w", rigName, err)
|
||||
}
|
||||
newWorktrees[rigName] = worktreePath
|
||||
}
|
||||
|
||||
// Update state
|
||||
state.Worktrees = newWorktrees
|
||||
state.LastActive = time.Now()
|
||||
state.UpdatedAt = time.Now()
|
||||
|
||||
return m.saveState(name, state)
|
||||
}
|
||||
|
||||
// RefreshRig recreates the worktree for a specific rig.
|
||||
func (m *Manager) RefreshRig(name, rigName string) error {
|
||||
if !m.exists(name) {
|
||||
return ErrDogNotFound
|
||||
}
|
||||
|
||||
if _, ok := m.rigsConfig.Rigs[rigName]; !ok {
|
||||
return fmt.Errorf("rig %s not found in config", rigName)
|
||||
}
|
||||
|
||||
state, err := m.loadState(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading state: %w", err)
|
||||
}
|
||||
|
||||
dogPath := m.dogDir(name)
|
||||
rigPath := filepath.Join(m.townRoot, rigName)
|
||||
oldWorktreePath := state.Worktrees[rigName]
|
||||
|
||||
// Find repo base
|
||||
repoGit, err := m.findRepoBase(rigPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding repo base: %w", err)
|
||||
}
|
||||
|
||||
// Remove old worktree if it exists
|
||||
if oldWorktreePath != "" {
|
||||
_ = repoGit.WorktreeRemove(oldWorktreePath, true)
|
||||
_ = os.RemoveAll(oldWorktreePath)
|
||||
_ = repoGit.WorktreePrune()
|
||||
}
|
||||
|
||||
// Fetch latest
|
||||
_ = repoGit.Fetch("origin")
|
||||
|
||||
// Create fresh worktree
|
||||
worktreePath, err := m.createRigWorktree(dogPath, name, rigName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating worktree: %w", err)
|
||||
}
|
||||
|
||||
// Update state
|
||||
state.Worktrees[rigName] = worktreePath
|
||||
state.LastActive = time.Now()
|
||||
state.UpdatedAt = time.Now()
|
||||
|
||||
return m.saveState(name, state)
|
||||
}
|
||||
|
||||
// CleanupStaleBranches removes orphaned dog branches from all rigs.
|
||||
// Returns total branches deleted across all rigs.
|
||||
func (m *Manager) CleanupStaleBranches() (int, error) {
|
||||
totalDeleted := 0
|
||||
|
||||
for rigName := range m.rigsConfig.Rigs {
|
||||
rigPath := filepath.Join(m.townRoot, rigName)
|
||||
repoGit, err := m.findRepoBase(rigPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
deleted, err := m.cleanupStaleBranchesForRig(repoGit, rigName)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: cleanup failed for rig %s: %v\n", rigName, err)
|
||||
continue
|
||||
}
|
||||
totalDeleted += deleted
|
||||
}
|
||||
|
||||
return totalDeleted, nil
|
||||
}
|
||||
|
||||
// cleanupStaleBranchesForRig removes orphaned dog branches in a specific rig.
|
||||
func (m *Manager) cleanupStaleBranchesForRig(repoGit *git.Git, rigName string) (int, error) {
|
||||
// List all dog branches
|
||||
branches, err := repoGit.ListBranches("dog/*")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if len(branches) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Get list of current dogs
|
||||
dogs, err := m.List()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Build set of current dog branches for this rig
|
||||
currentBranches := make(map[string]bool)
|
||||
for _, dog := range dogs {
|
||||
if dog.Worktrees != nil {
|
||||
if worktreePath, ok := dog.Worktrees[rigName]; ok {
|
||||
// Get branch name for this worktree
|
||||
worktreeGit := git.NewGit(worktreePath)
|
||||
if branch, err := worktreeGit.CurrentBranch(); err == nil {
|
||||
currentBranches[branch] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete orphaned branches
|
||||
deleted := 0
|
||||
for _, branch := range branches {
|
||||
if currentBranches[branch] {
|
||||
continue
|
||||
}
|
||||
if err := repoGit.DeleteBranch(branch, true); err != nil {
|
||||
fmt.Printf("Warning: could not delete branch %s: %v\n", branch, err)
|
||||
continue
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// loadState loads a dog's state from .dog.json.
|
||||
func (m *Manager) loadState(name string) (*DogState, error) {
|
||||
data, err := os.ReadFile(m.stateFilePath(name))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var state DogState
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
// saveState saves a dog's state to .dog.json.
|
||||
func (m *Manager) saveState(name string, state *DogState) error {
|
||||
data, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(m.stateFilePath(name), data, 0644)
|
||||
}
|
||||
|
||||
// GetIdleDog returns an idle dog suitable for work assignment.
|
||||
// Returns nil if no idle dogs are available.
|
||||
func (m *Manager) GetIdleDog() (*Dog, error) {
|
||||
dogs, err := m.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, dog := range dogs {
|
||||
if dog.State == StateIdle {
|
||||
return dog, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil // No idle dogs
|
||||
}
|
||||
|
||||
// IdleCount returns the number of idle dogs.
|
||||
func (m *Manager) IdleCount() (int, error) {
|
||||
dogs, err := m.List()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, dog := range dogs {
|
||||
if dog.State == StateIdle {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// WorkingCount returns the number of working dogs.
|
||||
func (m *Manager) WorkingCount() (int, error) {
|
||||
dogs, err := m.List()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, dog := range dogs {
|
||||
if dog.State == StateWorking {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
104
internal/dog/manager_test.go
Normal file
104
internal/dog/manager_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package dog
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
)
|
||||
|
||||
// TestDogStateJSON verifies DogState JSON serialization.
|
||||
func TestDogStateJSON(t *testing.T) {
|
||||
now := time.Now()
|
||||
state := &DogState{
|
||||
Name: "alpha",
|
||||
State: StateIdle,
|
||||
LastActive: now,
|
||||
Work: "",
|
||||
Worktrees: map[string]string{
|
||||
"gastown": "/path/to/gastown",
|
||||
"beads": "/path/to/beads",
|
||||
},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
// Create temp file
|
||||
tmpDir := t.TempDir()
|
||||
statePath := filepath.Join(tmpDir, ".dog.json")
|
||||
|
||||
// Write and read back
|
||||
data, err := os.ReadFile(statePath)
|
||||
if err == nil {
|
||||
t.Logf("Data already exists: %s", data)
|
||||
}
|
||||
|
||||
// Test state values
|
||||
if state.Name != "alpha" {
|
||||
t.Errorf("expected name 'alpha', got %q", state.Name)
|
||||
}
|
||||
if state.State != StateIdle {
|
||||
t.Errorf("expected state 'idle', got %q", state.State)
|
||||
}
|
||||
if len(state.Worktrees) != 2 {
|
||||
t.Errorf("expected 2 worktrees, got %d", len(state.Worktrees))
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagerCreation verifies Manager initialization.
|
||||
func TestManagerCreation(t *testing.T) {
|
||||
rigsConfig := &config.RigsConfig{
|
||||
Version: 1,
|
||||
Rigs: map[string]config.RigEntry{
|
||||
"gastown": {
|
||||
GitURL: "git@github.com:test/gastown.git",
|
||||
},
|
||||
"beads": {
|
||||
GitURL: "git@github.com:test/beads.git",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
m := NewManager("/tmp/test-town", rigsConfig)
|
||||
|
||||
if m.townRoot != "/tmp/test-town" {
|
||||
t.Errorf("expected townRoot '/tmp/test-town', got %q", m.townRoot)
|
||||
}
|
||||
if m.kennelPath != "/tmp/test-town/deacon/dogs" {
|
||||
t.Errorf("expected kennelPath '/tmp/test-town/deacon/dogs', got %q", m.kennelPath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDogDir verifies dogDir path construction.
|
||||
func TestDogDir(t *testing.T) {
|
||||
rigsConfig := &config.RigsConfig{
|
||||
Version: 1,
|
||||
Rigs: map[string]config.RigEntry{},
|
||||
}
|
||||
m := NewManager("/home/user/gt", rigsConfig)
|
||||
|
||||
path := m.dogDir("alpha")
|
||||
expected := "/home/user/gt/deacon/dogs/alpha"
|
||||
if path != expected {
|
||||
t.Errorf("expected %q, got %q", expected, path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStateConstants verifies state constants.
|
||||
func TestStateConstants(t *testing.T) {
|
||||
tests := []struct {
|
||||
state State
|
||||
expected string
|
||||
}{
|
||||
{StateIdle, "idle"},
|
||||
{StateWorking, "working"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
if string(tc.state) != tc.expected {
|
||||
t.Errorf("expected %q, got %q", tc.expected, string(tc.state))
|
||||
}
|
||||
}
|
||||
}
|
||||
40
internal/dog/types.go
Normal file
40
internal/dog/types.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// Package dog manages Dogs - Deacon's helper workers for infrastructure tasks.
|
||||
// Dogs are reusable workers with multi-rig worktrees, managed by the Deacon.
|
||||
// Unlike polecats (single-rig, ephemeral), dogs handle cross-rig infrastructure work.
|
||||
package dog
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// State represents a dog's operational state.
|
||||
type State string
|
||||
|
||||
const (
|
||||
// StateIdle means the dog is available for work.
|
||||
StateIdle State = "idle"
|
||||
// StateWorking means the dog is executing a task.
|
||||
StateWorking State = "working"
|
||||
)
|
||||
|
||||
// Dog represents a Deacon helper worker.
|
||||
type Dog struct {
|
||||
Name string // Dog name (e.g., "alpha")
|
||||
State State // Current state
|
||||
Path string // Path to kennel dir (~/gt/deacon/dogs/<name>)
|
||||
Worktrees map[string]string // Rig name -> worktree path
|
||||
LastActive time.Time // Last activity timestamp
|
||||
Work string // Current work assignment (bead ID or molecule)
|
||||
CreatedAt time.Time // When dog was added to kennel
|
||||
}
|
||||
|
||||
// DogState is the persistent state stored in .dog.json.
|
||||
type DogState struct {
|
||||
Name string `json:"name"`
|
||||
State State `json:"state"`
|
||||
LastActive time.Time `json:"last_active"`
|
||||
Work string `json:"work,omitempty"` // Current work assignment
|
||||
Worktrees map[string]string `json:"worktrees,omitempty"` // Rig -> path (for verification)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
Reference in New Issue
Block a user