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.
This commit is contained in:
2026-04-10 14:34:53 -04:00
parent 6fe28464d5
commit 72be64cc1a
2 changed files with 101 additions and 2 deletions
+19 -1
View File
@@ -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)
+82 -1
View File
@@ -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")