diff --git a/internal/workspace/metadata.go b/internal/workspace/metadata.go index c3c4d74..289c296 100644 --- a/internal/workspace/metadata.go +++ b/internal/workspace/metadata.go @@ -7,7 +7,9 @@ import ( "gopkg.in/yaml.v3" ) -// TaskMeta represents the task.yaml schema. Exactly these fields, no extras in v0.1. +// TaskMeta represents the task.yaml schema. +// The Type field is v0.3+; older workspaces without this field are treated as "task" +// (see EffectiveType). Field order in this struct is the YAML output order. type TaskMeta struct { ID string `yaml:"id"` Slug string `yaml:"slug"` @@ -16,12 +18,28 @@ type TaskMeta struct { UpdatedAt time.Time `yaml:"updated_at"` Status string `yaml:"status"` Category string `yaml:"category"` + Type string `yaml:"type"` Mode string `yaml:"mode"` Agent string `yaml:"agent"` WorkspacePath string `yaml:"workspace_path"` ArchivedAt *time.Time `yaml:"archived_at"` } +// EffectiveType returns "task" or "project". An empty or missing Type field +// is treated as "task" for backward compatibility with v0.2 workspaces. +// A nil meta is also treated as "task". +func EffectiveType(m *TaskMeta) string { + if m == nil { + return "task" + } + switch m.Type { + case "project": + return "project" + default: + return "task" + } +} + // WriteMeta writes a TaskMeta to a YAML file. func WriteMeta(path string, meta *TaskMeta) error { data, err := yaml.Marshal(meta) diff --git a/internal/workspace/metadata_test.go b/internal/workspace/metadata_test.go index 68dee28..67f1535 100644 --- a/internal/workspace/metadata_test.go +++ b/internal/workspace/metadata_test.go @@ -76,13 +76,94 @@ func TestMetaYAMLFieldsPresent(t *testing.T) { data, _ := os.ReadFile(path) content := string(data) - for _, field := range []string{"id:", "slug:", "title:", "created_at:", "updated_at:", "status:", "category:", "mode:", "agent:", "workspace_path:", "archived_at:"} { + for _, field := range []string{"id:", "slug:", "title:", "created_at:", "updated_at:", "status:", "category:", "type:", "mode:", "agent:", "workspace_path:", "archived_at:"} { if !strings.Contains(content, field) { t.Errorf("missing field %s in YAML output", field) } } } +func TestMetaTypeRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "task.yaml") + + now := time.Now().UTC().Truncate(time.Second) + meta := &TaskMeta{ + ID: "20260410-120000", + Slug: "billing", + Title: "billing", + CreatedAt: now, + UpdatedAt: now, + Status: "active", + Category: "projects", + Type: "project", + Mode: "local", + Agent: "claude", + WorkspacePath: "/tmp/billing", + } + if err := WriteMeta(path, meta); err != nil { + t.Fatalf("WriteMeta: %v", err) + } + got, err := ReadMeta(path) + if err != nil { + t.Fatalf("ReadMeta: %v", err) + } + if got.Type != "project" { + t.Errorf("Type: got %q, want \"project\"", got.Type) + } + if EffectiveType(got) != "project" { + t.Errorf("EffectiveType: got %q, want \"project\"", EffectiveType(got)) + } +} + +func TestMetaTypeMissingDefaultsToTask(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "task.yaml") + + // Write a YAML file that does NOT have a type field (simulating an old v0.2 workspace). + old := []byte(`id: legacy +slug: legacy-task +title: legacy task +created_at: 2026-04-01T00:00:00Z +updated_at: 2026-04-01T00:00:00Z +status: active +category: general +mode: local +agent: claude +workspace_path: /tmp/legacy +archived_at: null +`) + if err := os.WriteFile(path, old, 0644); err != nil { + t.Fatalf("write legacy yaml: %v", err) + } + + meta, err := ReadMeta(path) + if err != nil { + t.Fatalf("ReadMeta: %v", err) + } + if meta.Type != "" { + t.Errorf("legacy Type field: got %q, want empty string", meta.Type) + } + if EffectiveType(meta) != "task" { + t.Errorf("EffectiveType for legacy: got %q, want \"task\"", EffectiveType(meta)) + } +} + +func TestEffectiveTypeEmptyDefaultsToTask(t *testing.T) { + if got := EffectiveType(&TaskMeta{Type: ""}); got != "task" { + t.Errorf("empty Type EffectiveType: got %q, want \"task\"", got) + } + if got := EffectiveType(&TaskMeta{Type: "task"}); got != "task" { + t.Errorf("explicit task EffectiveType: got %q", got) + } + if got := EffectiveType(&TaskMeta{Type: "project"}); got != "project" { + t.Errorf("explicit project EffectiveType: got %q", got) + } + if got := EffectiveType(nil); got != "task" { + t.Errorf("nil EffectiveType: got %q, want \"task\"", got) + } +} + func TestMetaArchive(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "task.yaml")