Add task sidebar workflow

This commit is contained in:
2026-06-24 12:28:56 +01:00
parent 45263d59f8
commit d133839194
33 changed files with 1890 additions and 134 deletions
+264
View File
@@ -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) }