Add task sidebar workflow
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
// 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) }
|
||||
@@ -0,0 +1,113 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreateListAndReload(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("XDG_DATA_HOME", dir)
|
||||
|
||||
s1, err := Open("projkey")
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
created, err := s1.Create(" Ship task sidebar ")
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if created.ID == "" || created.Title != "Ship task sidebar" || created.CreatedAt == "" || created.UpdatedAt == "" {
|
||||
t.Fatalf("created task incomplete: %+v", created)
|
||||
}
|
||||
|
||||
s2, err := Open("projkey")
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
got := s2.List()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("tasks len = %d, want 1 (%v)", len(got), got)
|
||||
}
|
||||
if got[0].ID != created.ID || got[0].Title != "Ship task sidebar" {
|
||||
t.Fatalf("reload mismatch: got %+v want %+v", got[0], created)
|
||||
}
|
||||
if _, err := os.Stat(s2.Path()); err != nil {
|
||||
t.Fatalf("stat tasks.json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRequiresTitle(t *testing.T) {
|
||||
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
||||
s, err := Open("projkey")
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if _, err := s.Create(" "); err == nil {
|
||||
t.Fatalf("create with empty title should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameTask(t *testing.T) {
|
||||
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
||||
s, err := Open("projkey")
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
created, err := s.Create("Old")
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
renamed, err := s.Rename(created.ID, "New")
|
||||
if err != nil {
|
||||
t.Fatalf("rename: %v", err)
|
||||
}
|
||||
if renamed.Title != "New" {
|
||||
t.Fatalf("title = %q, want New", renamed.Title)
|
||||
}
|
||||
got, ok := s.Get(created.ID)
|
||||
if !ok || got.Title != "New" {
|
||||
t.Fatalf("get after rename = %+v, %v", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterWorktreeDedupesByPath(t *testing.T) {
|
||||
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
||||
s, err := Open("projkey")
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
task, err := s.Create("Worktree task")
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "repo", "..", "repo")
|
||||
updated, err := s.RegisterWorktree(task.ID, Worktree{Path: path, Branch: "one", CreatedByProcessID: "p1"})
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
if len(updated.Worktrees) != 1 {
|
||||
t.Fatalf("worktrees len = %d, want 1", len(updated.Worktrees))
|
||||
}
|
||||
clean := filepath.Clean(path)
|
||||
if updated.Worktrees[0].Path != clean {
|
||||
t.Fatalf("path = %q, want %q", updated.Worktrees[0].Path, clean)
|
||||
}
|
||||
updated, err = s.RegisterWorktree(task.ID, Worktree{Path: clean, Branch: "two"})
|
||||
if err != nil {
|
||||
t.Fatalf("register duplicate: %v", err)
|
||||
}
|
||||
if len(updated.Worktrees) != 1 {
|
||||
t.Fatalf("duplicate should replace, got %+v", updated.Worktrees)
|
||||
}
|
||||
if got := updated.Worktrees[0]; got.Branch != "two" || got.CreatedByProcessID != "p1" {
|
||||
t.Fatalf("duplicate replace mismatch: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRequiresProjectKey(t *testing.T) {
|
||||
if _, err := Open(""); err == nil {
|
||||
t.Fatalf("open with empty project key should fail")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user