feat: workspace creation with seed files and collision handling
Create function produces full workspace layout (task.yaml, CLAUDE.md, notes.md, context/, output/, logs/). Seed files only written if missing. Collision suffixing tested. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/warrenronsiek/ctask/internal/seed"
|
||||
)
|
||||
|
||||
// CreateOpts holds parameters for workspace creation.
|
||||
type CreateOpts struct {
|
||||
Root string
|
||||
Title string
|
||||
Category string
|
||||
Mode string
|
||||
Agent string
|
||||
}
|
||||
|
||||
// CreateResult holds the result of workspace creation.
|
||||
type CreateResult struct {
|
||||
Path string
|
||||
Meta *TaskMeta
|
||||
}
|
||||
|
||||
// Create creates a new task workspace with seed files and metadata.
|
||||
func Create(opts CreateOpts) (*CreateResult, error) {
|
||||
title := opts.Title
|
||||
if title == "" {
|
||||
title = AutoTitle()
|
||||
}
|
||||
slug := Slugify(title)
|
||||
if slug == "" {
|
||||
slug = "task"
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
date := now.Format("2006-01-02")
|
||||
id := now.Format("20060102-150405")
|
||||
|
||||
categoryDir := filepath.Join(opts.Root, opts.Category)
|
||||
if err := os.MkdirAll(categoryDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("creating category dir: %w", err)
|
||||
}
|
||||
|
||||
wsDir := ResolveDir(categoryDir, date, slug)
|
||||
|
||||
// Extract actual slug from resolved dir name (may have -2, -3 suffix)
|
||||
dirBase := filepath.Base(wsDir)
|
||||
actualSlug := dirBase[len(date)+1:] // skip "YYYY-MM-DD_"
|
||||
|
||||
if err := os.MkdirAll(wsDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("creating workspace dir: %w", err)
|
||||
}
|
||||
|
||||
// Create subdirectories
|
||||
for _, sub := range []string{"context", "output", "logs"} {
|
||||
if err := os.MkdirAll(filepath.Join(wsDir, sub), 0755); err != nil {
|
||||
return nil, fmt.Errorf("creating %s dir: %w", sub, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust title if slug was suffixed due to collision
|
||||
actualTitle := title
|
||||
if actualSlug != slug {
|
||||
suffix := actualSlug[len(slug):]
|
||||
actualTitle = title + suffix
|
||||
}
|
||||
|
||||
meta := &TaskMeta{
|
||||
ID: id,
|
||||
Slug: actualSlug,
|
||||
Title: actualTitle,
|
||||
CreatedAt: now.Truncate(time.Second),
|
||||
UpdatedAt: now.Truncate(time.Second),
|
||||
Status: "active",
|
||||
Category: opts.Category,
|
||||
Mode: opts.Mode,
|
||||
Agent: opts.Agent,
|
||||
WorkspacePath: wsDir,
|
||||
ArchivedAt: nil,
|
||||
}
|
||||
|
||||
// Write seed files (only if they don't exist)
|
||||
writeSeedFiles(wsDir, actualSlug, actualTitle, opts.Category)
|
||||
|
||||
// Write task.yaml (always write on new creation)
|
||||
metaPath := filepath.Join(wsDir, "task.yaml")
|
||||
if err := WriteMeta(metaPath, meta); err != nil {
|
||||
return nil, fmt.Errorf("writing task.yaml: %w", err)
|
||||
}
|
||||
|
||||
return &CreateResult{Path: wsDir, Meta: meta}, nil
|
||||
}
|
||||
|
||||
// writeSeedFiles writes CLAUDE.md and notes.md if they don't already exist.
|
||||
func writeSeedFiles(wsDir, slug, title, category string) {
|
||||
claudePath := filepath.Join(wsDir, "CLAUDE.md")
|
||||
if _, err := os.Stat(claudePath); os.IsNotExist(err) {
|
||||
content := seed.ClaudeMD(slug, category, wsDir)
|
||||
os.WriteFile(claudePath, []byte(content), 0644)
|
||||
}
|
||||
|
||||
notesPath := filepath.Join(wsDir, "notes.md")
|
||||
if _, err := os.Stat(notesPath); os.IsNotExist(err) {
|
||||
content := seed.NotesMD(title)
|
||||
os.WriteFile(notesPath, []byte(content), 0644)
|
||||
}
|
||||
}
|
||||
|
||||
// RelativePath returns a display-friendly relative path like "category/date_slug".
|
||||
func RelativePath(root, wsPath string) string {
|
||||
rel, err := filepath.Rel(root, wsPath)
|
||||
if err != nil {
|
||||
return wsPath
|
||||
}
|
||||
return strings.ReplaceAll(rel, string(filepath.Separator), "/")
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreateWorkspace(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
opts := CreateOpts{
|
||||
Root: root,
|
||||
Title: "test task",
|
||||
Category: "general",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
}
|
||||
|
||||
ws, err := Create(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
// Verify directory exists
|
||||
if _, err := os.Stat(ws.Path); os.IsNotExist(err) {
|
||||
t.Fatalf("workspace dir not created: %s", ws.Path)
|
||||
}
|
||||
|
||||
// Verify seed files
|
||||
for _, name := range []string{"task.yaml", "CLAUDE.md", "notes.md"} {
|
||||
p := filepath.Join(ws.Path, name)
|
||||
if _, err := os.Stat(p); os.IsNotExist(err) {
|
||||
t.Errorf("missing seed file: %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify subdirectories
|
||||
for _, name := range []string{"context", "output", "logs"} {
|
||||
p := filepath.Join(ws.Path, name)
|
||||
info, err := os.Stat(p)
|
||||
if os.IsNotExist(err) {
|
||||
t.Errorf("missing dir: %s", name)
|
||||
} else if !info.IsDir() {
|
||||
t.Errorf("%s is not a directory", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify metadata
|
||||
meta, err := ReadMeta(filepath.Join(ws.Path, "task.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadMeta: %v", err)
|
||||
}
|
||||
if meta.Slug != "test-task" {
|
||||
t.Errorf("slug: got %q, want \"test-task\"", meta.Slug)
|
||||
}
|
||||
if meta.Status != "active" {
|
||||
t.Errorf("status: got %q, want \"active\"", meta.Status)
|
||||
}
|
||||
if meta.Category != "general" {
|
||||
t.Errorf("category: got %q, want \"general\"", meta.Category)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWorkspaceNoTitle(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
opts := CreateOpts{
|
||||
Root: root,
|
||||
Title: "",
|
||||
Category: "general",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
}
|
||||
|
||||
ws, err := Create(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
meta, _ := ReadMeta(filepath.Join(ws.Path, "task.yaml"))
|
||||
if len(meta.Slug) == 0 {
|
||||
t.Error("expected auto-generated slug")
|
||||
}
|
||||
if meta.Slug[:5] != "task-" {
|
||||
t.Errorf("auto slug should start with 'task-', got %q", meta.Slug)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDoesNotOverwriteSeedFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
opts := CreateOpts{
|
||||
Root: root,
|
||||
Title: "overwrite test",
|
||||
Category: "general",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
}
|
||||
|
||||
ws, _ := Create(opts)
|
||||
|
||||
// Write custom content to notes.md
|
||||
notesPath := filepath.Join(ws.Path, "notes.md")
|
||||
os.WriteFile(notesPath, []byte("custom content"), 0644)
|
||||
|
||||
// Call writeSeedFiles again (simulating what would happen on resume)
|
||||
writeSeedFiles(ws.Path, "overwrite-test", "overwrite test", "general")
|
||||
|
||||
data, _ := os.ReadFile(notesPath)
|
||||
if string(data) != "custom content" {
|
||||
t.Error("seed files should not overwrite existing files")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCollisionSuffix(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
opts := CreateOpts{
|
||||
Root: root,
|
||||
Title: "same name",
|
||||
Category: "general",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
}
|
||||
|
||||
ws1, _ := Create(opts)
|
||||
ws2, _ := Create(opts)
|
||||
|
||||
if ws1.Path == ws2.Path {
|
||||
t.Error("two creates with same title should produce different paths")
|
||||
}
|
||||
|
||||
meta2, _ := ReadMeta(filepath.Join(ws2.Path, "task.yaml"))
|
||||
if meta2.Slug != "same-name-2" {
|
||||
t.Errorf("collision slug: got %q, want \"same-name-2\"", meta2.Slug)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user