// Package task stores manual project-local tasks and the worktrees agents // register while working on those tasks. package task import ( "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" "sync" "time" ) // Task is one manual project-local task. type Task struct { ID string `json:"id"` Title string `json:"title"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` Worktrees []Worktree `json:"worktrees,omitempty"` } // Worktree is a git worktree path registered by a task-bound agent. type Worktree struct { Path string `json:"path"` Branch string `json:"branch,omitempty"` CreatedByProcessID string `json:"created_by_process_id,omitempty"` RegisteredAt string `json:"registered_at"` } // Store is one project's tasks file. Safe for concurrent use. type Store struct { path string mu sync.Mutex tasks map[string]Task order []string } // Open loads or creates the task store for projectKey. func Open(projectKey string) (*Store, error) { if projectKey == "" { return nil, errors.New("task.Open: empty project key") } base, err := dataDir() if err != nil { return nil, err } dir := filepath.Join(base, "projects", projectKey) if err := os.MkdirAll(dir, 0o700); err != nil { return nil, fmt.Errorf("task: mkdir %s: %w", dir, err) } path := filepath.Join(dir, "tasks.json") s := &Store{path: path, tasks: make(map[string]Task)} if err := s.loadLocked(); err != nil { return nil, err } return s, nil } func dataDir() (string, error) { if h := os.Getenv("XDG_DATA_HOME"); h != "" { return filepath.Join(h, "patterm"), nil } home, err := os.UserHomeDir() if err != nil { return "", err } return filepath.Join(home, ".local", "share", "patterm"), nil } // Path returns the on-disk file path. Used by tests and diagnostics. func (s *Store) Path() string { return s.path } // List returns tasks in creation order. func (s *Store) List() []Task { s.mu.Lock() defer s.mu.Unlock() out := make([]Task, 0, len(s.order)) for _, id := range s.order { if t, ok := s.tasks[id]; ok { out = append(out, cloneTask(t)) } } return out } // Get returns one task by id. func (s *Store) Get(id string) (Task, bool) { s.mu.Lock() defer s.mu.Unlock() t, ok := s.tasks[id] if !ok { return Task{}, false } return cloneTask(t), true } // Create inserts a new task with title. func (s *Store) Create(title string) (Task, error) { title = strings.TrimSpace(title) if title == "" { return Task{}, errors.New("task.Create: empty title") } s.mu.Lock() defer s.mu.Unlock() now := timestamp() t := Task{ID: s.mintIDLocked(), Title: title, CreatedAt: now, UpdatedAt: now} s.tasks[t.ID] = t s.order = append(s.order, t.ID) if err := s.saveLocked(); err != nil { return Task{}, err } return cloneTask(t), nil } // Rename updates a task title. func (s *Store) Rename(id, title string) (Task, error) { title = strings.TrimSpace(title) if title == "" { return Task{}, errors.New("task.Rename: empty title") } s.mu.Lock() defer s.mu.Unlock() t, ok := s.tasks[id] if !ok { return Task{}, fmt.Errorf("task.Rename: no such task %q", id) } t.Title = title t.UpdatedAt = timestamp() s.tasks[id] = t if err := s.saveLocked(); err != nil { return Task{}, err } return cloneTask(t), nil } // RegisterWorktree records or updates a worktree for a task. Paths are // de-duplicated by cleaned absolute path. func (s *Store) RegisterWorktree(taskID string, wt Worktree) (Task, error) { path := strings.TrimSpace(wt.Path) if path == "" { return Task{}, errors.New("task.RegisterWorktree: empty path") } if !filepath.IsAbs(path) { abs, err := filepath.Abs(path) if err != nil { return Task{}, err } path = abs } wt.Path = filepath.Clean(path) wt.Branch = strings.TrimSpace(wt.Branch) if wt.RegisteredAt == "" { wt.RegisteredAt = timestamp() } s.mu.Lock() defer s.mu.Unlock() t, ok := s.tasks[taskID] if !ok { return Task{}, fmt.Errorf("task.RegisterWorktree: no such task %q", taskID) } replaced := false for i, existing := range t.Worktrees { if filepath.Clean(existing.Path) == wt.Path { if wt.CreatedByProcessID == "" { wt.CreatedByProcessID = existing.CreatedByProcessID } t.Worktrees[i] = wt replaced = true break } } if !replaced { t.Worktrees = append(t.Worktrees, wt) } t.UpdatedAt = timestamp() s.tasks[taskID] = t if err := s.saveLocked(); err != nil { return Task{}, err } return cloneTask(t), nil } type fileShape struct { Tasks []Task `json:"tasks"` } func (s *Store) loadLocked() error { b, err := os.ReadFile(s.path) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil } return fmt.Errorf("task: read %s: %w", s.path, err) } if len(b) == 0 { return nil } var f fileShape if err := json.Unmarshal(b, &f); err != nil { return fmt.Errorf("task: parse %s: %w", s.path, err) } for _, t := range f.Tasks { if t.ID == "" || strings.TrimSpace(t.Title) == "" { continue } t.Title = strings.TrimSpace(t.Title) if _, exists := s.tasks[t.ID]; !exists { s.order = append(s.order, t.ID) } s.tasks[t.ID] = cloneTask(t) } return nil } func (s *Store) saveLocked() error { out := make([]Task, 0, len(s.tasks)) for _, id := range s.order { if t, ok := s.tasks[id]; ok { out = append(out, cloneTask(t)) } } body, err := json.MarshalIndent(fileShape{Tasks: out}, "", " ") if err != nil { return err } body = append(body, '\n') tmp := s.path + ".tmp" if err := os.WriteFile(tmp, body, 0o600); err != nil { return fmt.Errorf("task: write %s: %w", tmp, err) } if err := os.Rename(tmp, s.path); err != nil { return fmt.Errorf("task: rename %s: %w", s.path, err) } return nil } func (s *Store) mintIDLocked() string { for { var b [4]byte if _, err := rand.Read(b[:]); err != nil { return fmt.Sprintf("task_%d", time.Now().UnixNano()) } id := "task_" + hex.EncodeToString(b[:]) if _, exists := s.tasks[id]; !exists { return id } } } func cloneTask(t Task) Task { if t.Worktrees != nil { t.Worktrees = append([]Worktree(nil), t.Worktrees...) } return t } func timestamp() string { return time.Now().UTC().Format(time.RFC3339) }