From 72be64cc1a6817a64263fcef33c3bfd9fa6aadc8 Mon Sep 17 00:00:00 2001 From: typebasedio Date: Fri, 10 Apr 2026 14:34:53 -0400 Subject: [PATCH] feat(v0.3): add Type field to TaskMeta with backward-compat helper TaskMeta now records type: task or type: project. EffectiveType returns 'task' for missing/empty/unknown values and for nil meta, so v0.2 workspaces continue to read as tasks without any migration. The field is placed between Category and Mode in the YAML output. Tests cover the round-trip and the legacy no-type-field case. --- internal/workspace/metadata.go | 20 ++++++- internal/workspace/metadata_test.go | 83 ++++++++++++++++++++++++++++- 2 files changed, 101 insertions(+), 2 deletions(-) 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")