feat(v0.6): schema_version and workspace.mode in task.yaml
Adds the two new metadata fields specified for Phase 1 of v0.6 plus
the validation and defaulting helpers around them.
internal/workspace/metadata.go:
- CurrentMetaSchemaVersion = 1 constant.
- WorkspaceSection struct {Mode string} with omitempty.
- SchemaVersion int and Workspace WorkspaceSection fields added to
TaskMeta at the top of the struct. Both are omitempty so legacy
task.yaml files (no schema_version, no workspace block) round-trip
without acquiring these keys when an unrelated field is updated.
- EffectiveSchemaVersion(meta) — returns 1 for stored-value-0 legacy
workspaces; non-zero stored values are returned verbatim.
- ValidateSchemaVersion(slug, meta) — rejects stored values higher
than CurrentMetaSchemaVersion with the spec-mandated upgrade
message. Accepts 0 (legacy missing).
- ValidateWorkspaceMode(slug, meta) — rejects modes other than ""
and "native". "adopted" is reserved for v0.7.
- ReadMeta now runs both validators after unmarshal. The validation
error includes the workspace slug (derived from task.yaml's slug
field, falling back to the directory basename when the file itself
is corrupt).
internal/workspace/create.go:
- workspace.Create stamps every new meta with
SchemaVersion: CurrentMetaSchemaVersion and Workspace.Mode: "native".
This is the ONLY write site for these fields in v0.6; resume,
archive, restore, and any other path that rewrites task.yaml MUST
NOT backfill them (the "no opportunistic schema writes" invariant).
internal/workspace/schema_test.go:
- 10 tests:
* new meta written by Create contains schema_version: 1 +
workspace.mode: native (both serialization and round-trip)
* legacy meta without these fields loads with stored value 0 / ""
and EffectiveSchemaVersion returns 1
* task.yaml with schema_version: 2 is rejected with upgrade message
* task.yaml with workspace.mode: adopted is rejected
* the no-opportunistic-writes invariant is pinned for both WriteMeta
and WriteMetaLocked: a legacy file rewritten with an updated
UpdatedAt does NOT acquire schema_version or workspace: keys
* ValidateSchemaVersion accepts {0, 1}; ValidateWorkspaceMode
accepts {"", "native"}
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user