diff --git a/internal/workspace/create.go b/internal/workspace/create.go index b450b2e..3fb0f53 100644 --- a/internal/workspace/create.go +++ b/internal/workspace/create.go @@ -125,6 +125,13 @@ func Create(opts CreateOpts) (*CreateResult, error) { } meta := &TaskMeta{ + // v0.6: every new workspace is stamped with the current schema + // version and the "native" workspace mode. These keys are written + // ONLY here (and by a future explicit migration command); legacy + // task.yaml files are never retroactively backfilled. The omitempty + // tags on the struct enforce that invariant from the write side. + SchemaVersion: CurrentMetaSchemaVersion, + Workspace: WorkspaceSection{Mode: "native"}, ID: id, Slug: actualSlug, Title: actualTitle, diff --git a/internal/workspace/metadata.go b/internal/workspace/metadata.go index 6da8d6c..3c2460e 100644 --- a/internal/workspace/metadata.go +++ b/internal/workspace/metadata.go @@ -10,24 +10,91 @@ import ( "gopkg.in/yaml.v3" ) +// CurrentMetaSchemaVersion is the highest task.yaml schema version +// this binary writes and reads. Reading a higher version is an error +// (see ValidateSchemaVersion). New workspaces always get this value; +// legacy workspaces with a missing schema_version field load as zero +// and are treated as v1 via EffectiveSchemaVersion. +const CurrentMetaSchemaVersion = 1 + +// WorkspaceSection is the nested `workspace:` block in task.yaml, +// introduced in v0.6 alongside schema_version. The only valid Mode +// in v0.6 is "native"; v0.7 will add "adopted". Empty Mode loads +// silently for legacy workspaces (treated as native). +type WorkspaceSection struct { + Mode string `yaml:"mode,omitempty"` +} + // TaskMeta represents the task.yaml schema. // The Type field is v0.3+; older workspaces without this field are treated as "task" // (see EffectiveType). LaunchDir is v0.5+; empty for tasks and pre-v0.5 projects, -// defaults to the project slug for new projects. Field order in this struct is -// the YAML output order. +// defaults to the project slug for new projects. SchemaVersion and Workspace +// are v0.6+; both are omitempty so legacy task.yaml files round-trip without +// acquiring these keys (the "no opportunistic schema writes" invariant — they +// are written ONLY by workspace.Create or a future explicit migration command). +// Field order in this struct is the YAML output order. type TaskMeta struct { - ID string `yaml:"id"` - Slug string `yaml:"slug"` - Title string `yaml:"title"` - CreatedAt time.Time `yaml:"created_at"` - 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"` - ArchivedAt *time.Time `yaml:"archived_at"` - LaunchDir string `yaml:"launch_dir,omitempty"` + SchemaVersion int `yaml:"schema_version,omitempty"` + Workspace WorkspaceSection `yaml:"workspace,omitempty"` + ID string `yaml:"id"` + Slug string `yaml:"slug"` + Title string `yaml:"title"` + CreatedAt time.Time `yaml:"created_at"` + 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"` + ArchivedAt *time.Time `yaml:"archived_at"` + LaunchDir string `yaml:"launch_dir,omitempty"` +} + +// EffectiveSchemaVersion returns the schema version a meta should be +// treated as. A stored value of 0 means the field was missing in +// task.yaml (legacy pre-v0.6 workspace); per spec, these are treated +// as schema version 1. Any non-zero stored value is returned verbatim. +func EffectiveSchemaVersion(m *TaskMeta) int { + if m == nil { + return 1 + } + if m.SchemaVersion == 0 { + return 1 + } + return m.SchemaVersion +} + +// ValidateSchemaVersion returns an error if the meta's stored +// SchemaVersion is greater than CurrentMetaSchemaVersion. A stored +// value of 0 (legacy missing field) is always accepted — the meta is +// not retroactively rewritten by ctask. +func ValidateSchemaVersion(slug string, m *TaskMeta) error { + if m == nil { + return nil + } + if m.SchemaVersion > CurrentMetaSchemaVersion { + return fmt.Errorf( + "workspace %q uses schema version %d, but this binary supports up to version %d. Please upgrade ctask.", + slug, m.SchemaVersion, CurrentMetaSchemaVersion) + } + return nil +} + +// ValidateWorkspaceMode returns an error if the meta's Workspace.Mode +// is set to anything other than "" (legacy) or "native" (current). The +// "adopted" mode is reserved for v0.7 and must not be accepted here. +func ValidateWorkspaceMode(slug string, m *TaskMeta) error { + if m == nil { + return nil + } + switch m.Workspace.Mode { + case "", "native": + return nil + default: + return fmt.Errorf( + "workspace %q has unsupported workspace.mode %q (only \"native\" is valid in this binary version)", + slug, m.Workspace.Mode) + } } // EffectiveType returns "task" or "project". An empty or missing Type field @@ -54,7 +121,12 @@ func WriteMeta(path string, meta *TaskMeta) error { return os.WriteFile(path, data, 0644) } -// ReadMeta reads a TaskMeta from a YAML file. +// ReadMeta reads a TaskMeta from a YAML file. v0.6: also enforces +// the schema_version and workspace.mode contracts — task.yaml files +// declaring a higher schema version than this binary supports, or +// a workspace.mode value other than "" or "native", are rejected. +// Legacy files missing either field load silently and are treated +// via the Effective* helpers. func ReadMeta(path string) (*TaskMeta, error) { data, err := os.ReadFile(path) if err != nil { @@ -64,6 +136,16 @@ func ReadMeta(path string) (*TaskMeta, error) { if err := yaml.Unmarshal(data, &meta); err != nil { return nil, err } + slug := meta.Slug + if slug == "" { + slug = filepath.Base(filepath.Dir(path)) + } + if err := ValidateSchemaVersion(slug, &meta); err != nil { + return nil, err + } + if err := ValidateWorkspaceMode(slug, &meta); err != nil { + return nil, err + } return &meta, nil } diff --git a/internal/workspace/schema_test.go b/internal/workspace/schema_test.go new file mode 100644 index 0000000..553d3c3 --- /dev/null +++ b/internal/workspace/schema_test.go @@ -0,0 +1,305 @@ +package workspace + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestNewMetaIncludesSchemaVersion — workspace.Create writes +// schema_version: 1 into the new task.yaml. +func TestNewMetaIncludesSchemaVersion(t *testing.T) { + root := t.TempDir() + result, err := Create(CreateOpts{ + Root: root, + Title: "Test schema", + Category: "general", + Mode: "local", + Agent: "claude", + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + body, err := os.ReadFile(filepath.Join(result.Path, "task.yaml")) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if !strings.Contains(string(body), "schema_version: 1") { + t.Errorf("task.yaml should contain schema_version: 1, got:\n%s", body) + } +} + +// TestNewMetaIncludesWorkspaceMode — workspace.Create writes +// workspace.mode: native into the new task.yaml. +func TestNewMetaIncludesWorkspaceMode(t *testing.T) { + root := t.TempDir() + result, err := Create(CreateOpts{ + Root: root, + Title: "Test mode", + Category: "general", + Mode: "local", + Agent: "claude", + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + body, err := os.ReadFile(filepath.Join(result.Path, "task.yaml")) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + text := string(body) + if !strings.Contains(text, "workspace:") { + t.Errorf("task.yaml should contain workspace: block, got:\n%s", text) + } + if !strings.Contains(text, "mode: native") { + t.Errorf("task.yaml should contain mode: native, got:\n%s", text) + } +} + +// TestNewMetaRoundTripWithSchemaFields — write + read returns the +// schema fields verbatim. +func TestNewMetaRoundTripWithSchemaFields(t *testing.T) { + root := t.TempDir() + result, err := Create(CreateOpts{ + Root: root, + Title: "Round trip", + Category: "general", + Mode: "local", + Agent: "claude", + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + meta, err := ReadMeta(filepath.Join(result.Path, "task.yaml")) + if err != nil { + t.Fatalf("ReadMeta: %v", err) + } + if meta.SchemaVersion != 1 { + t.Errorf("SchemaVersion: got %d, want 1", meta.SchemaVersion) + } + if meta.Workspace.Mode != "native" { + t.Errorf("Workspace.Mode: got %q, want native", meta.Workspace.Mode) + } +} + +// TestLegacyMetaDefaultsToSchemaVersion1 — a task.yaml without the +// schema_version field still loads, and EffectiveSchemaVersion returns +// 1 for it (the spec rule: missing schema_version is treated as v1). +func TestLegacyMetaDefaultsToSchemaVersion1(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "task.yaml") + body := []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 +type: task +mode: local +agent: claude +archived_at: null +`) + if err := os.WriteFile(path, body, 0644); err != nil { + t.Fatalf("write legacy yaml: %v", err) + } + + meta, err := ReadMeta(path) + if err != nil { + t.Fatalf("ReadMeta: %v", err) + } + if meta.SchemaVersion != 0 { + t.Errorf("stored SchemaVersion: got %d, want 0 (legacy missing)", meta.SchemaVersion) + } + if eff := EffectiveSchemaVersion(meta); eff != 1 { + t.Errorf("EffectiveSchemaVersion legacy: got %d, want 1", eff) + } + if meta.Workspace.Mode != "" { + t.Errorf("Workspace.Mode legacy: got %q, want empty", meta.Workspace.Mode) + } +} + +// TestUnsupportedSchemaVersionRefused — task.yaml with schema_version +// higher than CurrentMetaSchemaVersion is rejected at read time. +func TestUnsupportedSchemaVersionRefused(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "task.yaml") + body := []byte(`id: future +slug: future-task +title: future +schema_version: 2 +created_at: 2026-04-01T00:00:00Z +updated_at: 2026-04-01T00:00:00Z +status: active +category: general +type: task +mode: local +agent: claude +`) + if err := os.WriteFile(path, body, 0644); err != nil { + t.Fatalf("write: %v", err) + } + + meta, err := ReadMeta(path) + if err == nil { + t.Fatalf("expected error for schema_version 2, got meta=%+v", meta) + } + if !strings.Contains(err.Error(), "schema version") { + t.Errorf("error should mention schema version, got: %v", err) + } + if !strings.Contains(err.Error(), "upgrade") { + t.Errorf("error should mention upgrade, got: %v", err) + } +} + +// TestInvalidWorkspaceModeRefused — task.yaml with workspace.mode set +// to a value other than "native" (e.g., "adopted") is rejected. v0.7 +// adds "adopted"; v0.6 only knows "native". +func TestInvalidWorkspaceModeRefused(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "task.yaml") + body := []byte(`id: adopted-test +slug: adopted-test +title: adopted test +schema_version: 1 +workspace: + mode: adopted +created_at: 2026-04-01T00:00:00Z +updated_at: 2026-04-01T00:00:00Z +status: active +category: general +type: task +mode: local +agent: claude +`) + if err := os.WriteFile(path, body, 0644); err != nil { + t.Fatalf("write: %v", err) + } + + meta, err := ReadMeta(path) + if err == nil { + t.Fatalf("expected error for workspace.mode=adopted, got meta=%+v", meta) + } + if !strings.Contains(err.Error(), "adopted") { + t.Errorf("error should mention the bad mode value, got: %v", err) + } +} + +// TestLegacyTaskYamlNotBackfilledByWrite — the no-opportunistic-writes +// invariant: when WriteMeta or WriteMetaLocked is called against a meta +// that was read from a legacy task.yaml (no schema_version, no +// workspace block), the re-written file MUST NOT acquire those keys. +// Backfilling them on an unrelated metadata update would be an implicit +// schema migration that the v0.6 spec explicitly forbids. +func TestLegacyTaskYamlNotBackfilledByWrite(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "task.yaml") + original := []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 +type: task +mode: local +agent: claude +archived_at: null +`) + if err := os.WriteFile(path, original, 0644); err != nil { + t.Fatalf("write legacy yaml: %v", err) + } + + // Simulate what resume / archive / restore do: read, mutate one + // unrelated field, write back. + meta, err := ReadMeta(path) + if err != nil { + t.Fatalf("ReadMeta: %v", err) + } + meta.UpdatedAt = time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC) + if err := WriteMeta(path, meta); err != nil { + t.Fatalf("WriteMeta: %v", err) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read back: %v", err) + } + rewritten := string(got) + if strings.Contains(rewritten, "schema_version") { + t.Errorf("legacy task.yaml MUST NOT acquire schema_version on rewrite; got:\n%s", rewritten) + } + if strings.Contains(rewritten, "workspace:") { + t.Errorf("legacy task.yaml MUST NOT acquire workspace block on rewrite; got:\n%s", rewritten) + } +} + +// TestLegacyTaskYamlNotBackfilledByLockedWrite — same invariant via +// WriteMetaLocked (the path used by resume/archive/restore in +// production). +func TestLegacyTaskYamlNotBackfilledByLockedWrite(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "task.yaml") + original := []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 +type: task +mode: local +agent: claude +archived_at: null +`) + if err := os.WriteFile(path, original, 0644); err != nil { + t.Fatalf("write legacy yaml: %v", err) + } + + meta, err := ReadMeta(path) + if err != nil { + t.Fatalf("ReadMeta: %v", err) + } + meta.UpdatedAt = time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC) + if err := WriteMetaLocked(path, meta); err != nil { + t.Fatalf("WriteMetaLocked: %v", err) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read back: %v", err) + } + rewritten := string(got) + if strings.Contains(rewritten, "schema_version") { + t.Errorf("legacy task.yaml MUST NOT acquire schema_version on locked rewrite; got:\n%s", rewritten) + } + if strings.Contains(rewritten, "workspace:") { + t.Errorf("legacy task.yaml MUST NOT acquire workspace block on locked rewrite; got:\n%s", rewritten) + } +} + +// TestValidateSchemaVersionAccepts0And1 — the helper returns nil for +// 0 (legacy missing) and 1 (current). Higher values error. +func TestValidateSchemaVersionAccepts0And1(t *testing.T) { + for _, v := range []int{0, 1} { + m := &TaskMeta{SchemaVersion: v} + if err := ValidateSchemaVersion("test", m); err != nil { + t.Errorf("ValidateSchemaVersion(%d): got err %v, want nil", v, err) + } + } +} + +// TestValidateWorkspaceModeAccepts — empty and "native" are valid. +func TestValidateWorkspaceModeAccepts(t *testing.T) { + for _, mode := range []string{"", "native"} { + m := &TaskMeta{Workspace: WorkspaceSection{Mode: mode}} + if err := ValidateWorkspaceMode("test", m); err != nil { + t.Errorf("ValidateWorkspaceMode(%q): got err %v, want nil", mode, err) + } + } +}