265 lines
6.1 KiB
Go
265 lines
6.1 KiB
Go
// 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) }
|