feat(v0.6): AgentSpec field on TaskMeta with backward-compat unmarshal
Replace TaskMeta.Agent (string) with TaskMeta.Agent (AgentSpec) carrying type/command/args/env. Custom UnmarshalYAML preserves the legacy scalar form: a built-in name (claude, opencode) maps to that type; any other scalar maps to type=custom with the scalar as command. A missing agent field leaves Type empty so the resolver fills in default_agent at launch. ValidateAgentSpec enforces: known type (claude|opencode|custom), type=custom requires command, command must be an executable name or path with no whitespace or shell metacharacters. Launch-path wiring (Task 3) and the --agent flag rework (Task 4) are intentionally not part of this commit; cmd/* call sites are patched to the minimum needed for the build to compile.
This commit is contained in:
+10
-10
@@ -22,16 +22,16 @@ func makeArchiveWs(t *testing.T, root, category, dirName string) string {
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
slug := dirName[11:]
|
||||
meta := &workspace.TaskMeta{
|
||||
ID: "test",
|
||||
Slug: slug,
|
||||
Title: slug,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Status: "active",
|
||||
Category: category,
|
||||
Type: "task",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
ID: "test",
|
||||
Slug: slug,
|
||||
Title: slug,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Status: "active",
|
||||
Category: category,
|
||||
Type: "task",
|
||||
Mode: "local",
|
||||
Agent: workspace.AgentSpec{Type: "claude"},
|
||||
}
|
||||
workspace.WriteMeta(filepath.Join(dir, "task.yaml"), meta)
|
||||
return dir
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ func runAttach(cmd *cobra.Command, args []string) error {
|
||||
|
||||
agent := attachAgent
|
||||
if agent == "" {
|
||||
agent = ws.Meta.Agent
|
||||
agent = ws.Meta.Agent.Type
|
||||
}
|
||||
|
||||
return runWorkspaceEntry(WorkspaceEntryOptions{
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ func TestAttachForwardsToEntryHelperWithAlwaysPersistent(t *testing.T) {
|
||||
ID: "t", Slug: "attach-fwd-demo", Title: "attach-fwd-demo",
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
Status: "active", Category: "general", Type: "task",
|
||||
Mode: "local", Agent: "claude",
|
||||
Mode: "local", Agent: workspace.AgentSpec{Type: "claude"},
|
||||
}
|
||||
if err := workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta); err != nil {
|
||||
t.Fatalf("WriteMeta: %v", err)
|
||||
|
||||
@@ -27,7 +27,7 @@ func completionTestEnv(t *testing.T) string {
|
||||
ID: "t", Slug: slug, Title: slug,
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
Status: status, Category: category, Type: taskType,
|
||||
Mode: "local", Agent: "claude",
|
||||
Mode: "local", Agent: workspace.AgentSpec{Type: "claude"},
|
||||
}
|
||||
if status == "archived" {
|
||||
meta.ArchivedAt = &now
|
||||
|
||||
+9
-9
@@ -21,15 +21,15 @@ func createTestWs(t *testing.T, root, category, dirName, status string) string {
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
slug := dirName[11:] // skip "YYYY-MM-DD_"
|
||||
meta := &workspace.TaskMeta{
|
||||
ID: "test",
|
||||
Slug: slug,
|
||||
Title: slug,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Status: status,
|
||||
Category: category,
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
ID: "test",
|
||||
Slug: slug,
|
||||
Title: slug,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Status: status,
|
||||
Category: category,
|
||||
Mode: "local",
|
||||
Agent: workspace.AgentSpec{Type: "claude"},
|
||||
}
|
||||
workspace.WriteMeta(filepath.Join(dir, "task.yaml"), meta)
|
||||
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ func TestRunWorkspaceEntryIsInjectable(t *testing.T) {
|
||||
want := WorkspaceEntryOptions{
|
||||
WsPath: "/tmp/ws",
|
||||
WsRoot: "/tmp",
|
||||
WsMeta: &workspace.TaskMeta{Slug: "demo", Category: "projects", Mode: "local", Agent: "claude"},
|
||||
WsMeta: &workspace.TaskMeta{Slug: "demo", Category: "projects", Mode: "local", Agent: workspace.AgentSpec{Type: "claude"}},
|
||||
Agent: "claude",
|
||||
Shell: true,
|
||||
CommandName: "test",
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ func runInfo(cmd *cobra.Command, args []string) error {
|
||||
fmt.Printf("Category: %s\n", m.Category)
|
||||
fmt.Printf("Status: %s\n", m.Status)
|
||||
fmt.Printf("Mode: %s\n", m.Mode)
|
||||
fmt.Printf("Agent: %s\n", agentLineWithSource(m.Agent, resolver))
|
||||
fmt.Printf("Agent: %s\n", agentLineWithSource(m.Agent.Type, resolver))
|
||||
fmt.Printf("Launch session mode: %s (%s)\n",
|
||||
resolver.SessionMode().Value, infoSourceLabel(resolver.SessionMode()))
|
||||
// v0.5.1: display timestamps in local time. task.yaml stores UTC;
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestInfoAgentSourceWorkspace(t *testing.T) {
|
||||
ID: "t", Slug: "agentwsdemo", Title: "agentwsdemo",
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
Status: "active", Category: "general", Type: "task",
|
||||
Mode: "local", Agent: "opencode",
|
||||
Mode: "local", Agent: workspace.AgentSpec{Type: "opencode"},
|
||||
})
|
||||
|
||||
out, err := runInfoCapture(t, root, "agentwsdemo")
|
||||
@@ -70,7 +70,7 @@ func TestInfoAgentSourceDefaultForLegacy(t *testing.T) {
|
||||
ID: "t", Slug: "legacyagent", Title: "legacy agent",
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
Status: "active", Category: "general", Type: "task",
|
||||
Mode: "local", Agent: "", // legacy: no agent recorded
|
||||
Mode: "local", Agent: workspace.AgentSpec{Type: ""}, // legacy: no agent recorded
|
||||
})
|
||||
|
||||
out, err := runInfoCapture(t, root, "legacyagent")
|
||||
@@ -107,7 +107,7 @@ func TestInfoLaunchSessionModeFromConfig(t *testing.T) {
|
||||
ID: "t", Slug: "modesrc", Title: "mode src",
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
Status: "active", Category: "general", Type: "task",
|
||||
Mode: "local", Agent: "claude",
|
||||
Mode: "local", Agent: workspace.AgentSpec{Type: "claude"},
|
||||
})
|
||||
|
||||
out, err := runInfoCapture(t, root, "modesrc")
|
||||
@@ -138,7 +138,7 @@ func TestInfoLaunchSessionModeBuiltinDefault(t *testing.T) {
|
||||
ID: "t", Slug: "modedefault", Title: "mode default",
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
Status: "active", Category: "general", Type: "task",
|
||||
Mode: "local", Agent: "claude",
|
||||
Mode: "local", Agent: workspace.AgentSpec{Type: "claude"},
|
||||
})
|
||||
|
||||
out, err := runInfoCapture(t, root, "modedefault")
|
||||
@@ -167,7 +167,7 @@ func TestInfoLaunchSessionModeAfterAgentBeforeCreated(t *testing.T) {
|
||||
ID: "t", Slug: "placement", Title: "placement",
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
Status: "active", Category: "general", Type: "task",
|
||||
Mode: "local", Agent: "claude",
|
||||
Mode: "local", Agent: workspace.AgentSpec{Type: "claude"},
|
||||
})
|
||||
|
||||
out, err := runInfoCapture(t, root, "placement")
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestInfoShowsLaunchFieldsWhenLaunchDirSet(t *testing.T) {
|
||||
ID: "t", Slug: "demo", Title: "demo",
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
Status: "active", Category: "projects", Type: "project",
|
||||
Mode: "local", Agent: "claude", LaunchDir: "demo",
|
||||
Mode: "local", Agent: workspace.AgentSpec{Type: "claude"}, LaunchDir: "demo",
|
||||
}
|
||||
workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta)
|
||||
|
||||
@@ -73,7 +73,7 @@ func TestInfoOmitsLaunchFieldsForTask(t *testing.T) {
|
||||
ID: "t", Slug: "regular", Title: "regular",
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
Status: "active", Category: "general", Type: "task",
|
||||
Mode: "local", Agent: "claude",
|
||||
Mode: "local", Agent: workspace.AgentSpec{Type: "claude"},
|
||||
}
|
||||
workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta)
|
||||
|
||||
@@ -104,8 +104,8 @@ func TestInfoFormatsTimestampsInLocalZone(t *testing.T) {
|
||||
ID: "t", Slug: "tz-test", Title: "tz-test",
|
||||
CreatedAt: fixedUTC, UpdatedAt: fixedUTC,
|
||||
ArchivedAt: &archived,
|
||||
Status: "archived", Category: "general", Type: "task",
|
||||
Mode: "local", Agent: "claude",
|
||||
Status: "archived", Category: "general", Type: "task",
|
||||
Mode: "local", Agent: workspace.AgentSpec{Type: "claude"},
|
||||
}
|
||||
workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta)
|
||||
|
||||
@@ -153,7 +153,7 @@ func TestInfoFindsArchivedWorkspaceWithoutFlag(t *testing.T) {
|
||||
Category: "general",
|
||||
Type: "task",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
Agent: workspace.AgentSpec{Type: "claude"},
|
||||
}
|
||||
workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta)
|
||||
|
||||
@@ -179,7 +179,7 @@ func TestInfoShowsDirExistsNoWhenLaunchDirMissing(t *testing.T) {
|
||||
ID: "t", Slug: "renamed", Title: "renamed",
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
Status: "active", Category: "projects", Type: "project",
|
||||
Mode: "local", Agent: "claude", LaunchDir: "renamed",
|
||||
Mode: "local", Agent: workspace.AgentSpec{Type: "claude"}, LaunchDir: "renamed",
|
||||
}
|
||||
workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta)
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ func makeInfoSessionWorkspace(t *testing.T, root, slug string) string {
|
||||
ID: "t", Slug: slug, Title: slug,
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
Status: "active", Category: "general", Type: "task",
|
||||
Mode: "local", Agent: "claude",
|
||||
Mode: "local", Agent: workspace.AgentSpec{Type: "claude"},
|
||||
}
|
||||
if err := workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta); err != nil {
|
||||
t.Fatalf("WriteMeta: %v", err)
|
||||
|
||||
@@ -26,7 +26,7 @@ func makeListSessionWorkspace(t *testing.T, root, category, dirName, slug, statu
|
||||
ID: "t", Slug: slug, Title: slug,
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
Status: status, Category: category, Type: "task",
|
||||
Mode: "local", Agent: "claude",
|
||||
Mode: "local", Agent: workspace.AgentSpec{Type: "claude"},
|
||||
}
|
||||
if status == "archived" {
|
||||
meta.ArchivedAt = &now
|
||||
|
||||
+10
-10
@@ -24,16 +24,16 @@ func listTestEnv(t *testing.T) string {
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
slug := dirName[11:]
|
||||
meta := &workspace.TaskMeta{
|
||||
ID: "test",
|
||||
Slug: slug,
|
||||
Title: slug,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Status: status,
|
||||
Category: category,
|
||||
Type: taskType,
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
ID: "test",
|
||||
Slug: slug,
|
||||
Title: slug,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Status: status,
|
||||
Category: category,
|
||||
Type: taskType,
|
||||
Mode: "local",
|
||||
Agent: workspace.AgentSpec{Type: "claude"},
|
||||
}
|
||||
workspace.WriteMeta(filepath.Join(dir, "task.yaml"), meta)
|
||||
}
|
||||
|
||||
+1
-1
@@ -92,7 +92,7 @@ func runNew(cmd *cobra.Command, args []string) error {
|
||||
Title: title,
|
||||
Category: category,
|
||||
Mode: "local",
|
||||
Agent: agent,
|
||||
AgentSpec: workspace.AgentSpec{Type: agent},
|
||||
IsProject: newProject,
|
||||
SeedDir: config.ResolveSeedDir(),
|
||||
SkipCategoryDir: skipCategoryDir,
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ func makeNotesWs(t *testing.T, root, category, dirName, status, notesBody string
|
||||
ID: "t", Slug: slug, Title: slug,
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
Status: status, Category: category, Type: "task",
|
||||
Mode: "local", Agent: "claude",
|
||||
Mode: "local", Agent: workspace.AgentSpec{Type: "claude"},
|
||||
}
|
||||
if status == "archived" {
|
||||
meta.ArchivedAt = &now
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ func runOpen(cmd *cobra.Command, args []string) error {
|
||||
WsPath: ws.Path,
|
||||
WsRoot: ws.Root,
|
||||
WsMeta: ws.Meta,
|
||||
Agent: ws.Meta.Agent,
|
||||
Agent: ws.Meta.Agent.Type,
|
||||
Shell: true, // open always launches a shell
|
||||
Force: openForce,
|
||||
Direct: openDirect,
|
||||
|
||||
@@ -35,7 +35,7 @@ func TestOpenForwardsToEntryHelperWithShellTrue(t *testing.T) {
|
||||
ID: "t", Slug: "open-fwd-demo", Title: "open-fwd-demo",
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
Status: "active", Category: "general", Type: "task",
|
||||
Mode: "local", Agent: "claude",
|
||||
Mode: "local", Agent: workspace.AgentSpec{Type: "claude"},
|
||||
}
|
||||
if err := workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta); err != nil {
|
||||
t.Fatalf("WriteMeta: %v", err)
|
||||
|
||||
+2
-2
@@ -47,7 +47,7 @@ func TestPathPrintsAbsolutePath(t *testing.T) {
|
||||
ID: "t", Slug: "path-active", Title: "path-active",
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
Status: "active", Category: "general", Type: "task",
|
||||
Mode: "local", Agent: "claude",
|
||||
Mode: "local", Agent: workspace.AgentSpec{Type: "claude"},
|
||||
}
|
||||
workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta)
|
||||
|
||||
@@ -83,7 +83,7 @@ func TestPathWorksOnArchivedWorkspace(t *testing.T) {
|
||||
Category: "general",
|
||||
Type: "task",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
Agent: workspace.AgentSpec{Type: "claude"},
|
||||
}
|
||||
workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta)
|
||||
|
||||
|
||||
+2
-2
@@ -34,7 +34,7 @@ func makeArchivedWs(t *testing.T, root, category, dirName string) string {
|
||||
Category: category,
|
||||
Type: "task",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
Agent: workspace.AgentSpec{Type: "claude"},
|
||||
}
|
||||
workspace.WriteMeta(filepath.Join(dir, "task.yaml"), meta)
|
||||
return dir
|
||||
@@ -149,7 +149,7 @@ func TestRestoreRefusesAlreadyActive(t *testing.T) {
|
||||
ID: "t", Slug: "already", Title: "already",
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
Status: "active", Category: "general", Type: "task",
|
||||
Mode: "local", Agent: "claude",
|
||||
Mode: "local", Agent: workspace.AgentSpec{Type: "claude"},
|
||||
}
|
||||
workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta)
|
||||
|
||||
|
||||
+1
-1
@@ -86,7 +86,7 @@ func doResume(query string, container, useShell, force bool, agentOverride strin
|
||||
|
||||
agent := agentOverride
|
||||
if agent == "" {
|
||||
agent = ws.Meta.Agent
|
||||
agent = ws.Meta.Agent.Type
|
||||
}
|
||||
|
||||
return runWorkspaceEntry(WorkspaceEntryOptions{
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestResumeArchivedHintNoDuplicateError(t *testing.T) {
|
||||
Category: "general",
|
||||
Type: "task",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
Agent: workspace.AgentSpec{Type: "claude"},
|
||||
}
|
||||
if err := workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta); err != nil {
|
||||
t.Fatalf("WriteMeta: %v", err)
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestRunResumeForwardsToEntryHelperWithCommandNameResume(t *testing.T) {
|
||||
ID: "t", Slug: "resume-fwd-demo", Title: "resume-fwd-demo",
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
Status: "active", Category: "general", Type: "task",
|
||||
Mode: "local", Agent: "claude",
|
||||
Mode: "local", Agent: workspace.AgentSpec{Type: "claude"},
|
||||
}
|
||||
if err := workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta); err != nil {
|
||||
t.Fatalf("WriteMeta: %v", err)
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ func TestResumeArchivedWorkspaceShowsRestoreHint(t *testing.T) {
|
||||
Category: "general",
|
||||
Type: "task",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
Agent: workspace.AgentSpec{Type: "claude"},
|
||||
}
|
||||
workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta)
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ func newAdoptionFixture(t *testing.T) *adoptionFixture {
|
||||
ID: "demo-id", Slug: "demo", Title: "demo",
|
||||
CreatedAt: initial, UpdatedAt: initial,
|
||||
Status: "active", Category: "projects", Type: "project",
|
||||
Mode: "local", Agent: "claude",
|
||||
Mode: "local", Agent: workspace.AgentSpec{Type: "claude"},
|
||||
}
|
||||
if err := workspace.WriteMeta(filepath.Join(ws, "task.yaml"), meta); err != nil {
|
||||
t.Fatalf("WriteMeta: %v", err)
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestAgentSpecRoundTripMappingForm(t *testing.T) {
|
||||
src := []byte(`
|
||||
agent:
|
||||
type: opencode
|
||||
command: /usr/local/bin/opencode
|
||||
args:
|
||||
- --model
|
||||
- deepseek-coder
|
||||
env:
|
||||
OPENAI_API_KEY: ollama
|
||||
`)
|
||||
var doc struct {
|
||||
Agent AgentSpec `yaml:"agent"`
|
||||
}
|
||||
if err := yaml.Unmarshal(src, &doc); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if doc.Agent.Type != "opencode" {
|
||||
t.Errorf("Type = %q, want opencode", doc.Agent.Type)
|
||||
}
|
||||
if doc.Agent.Command != "/usr/local/bin/opencode" {
|
||||
t.Errorf("Command = %q, want /usr/local/bin/opencode", doc.Agent.Command)
|
||||
}
|
||||
if got := doc.Agent.Args; len(got) != 2 || got[0] != "--model" || got[1] != "deepseek-coder" {
|
||||
t.Errorf("Args = %v, want [--model deepseek-coder]", got)
|
||||
}
|
||||
if doc.Agent.Env["OPENAI_API_KEY"] != "ollama" {
|
||||
t.Errorf("Env[OPENAI_API_KEY] = %q, want ollama", doc.Agent.Env["OPENAI_API_KEY"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentSpecLegacyScalarForm(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
src string
|
||||
wantType string
|
||||
wantCommand string
|
||||
}{
|
||||
{"builtin claude", "agent: claude", "claude", ""},
|
||||
{"builtin opencode", "agent: opencode", "opencode", ""},
|
||||
{"arbitrary command", "agent: aider", "custom", "aider"},
|
||||
{"absolute path", "agent: /opt/agent", "custom", "/opt/agent"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var doc struct {
|
||||
Agent AgentSpec `yaml:"agent"`
|
||||
}
|
||||
if err := yaml.Unmarshal([]byte(tc.src), &doc); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if doc.Agent.Type != tc.wantType {
|
||||
t.Errorf("Type = %q, want %q", doc.Agent.Type, tc.wantType)
|
||||
}
|
||||
if doc.Agent.Command != tc.wantCommand {
|
||||
t.Errorf("Command = %q, want %q", doc.Agent.Command, tc.wantCommand)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentSpecMissingFieldStaysEmpty(t *testing.T) {
|
||||
// No `agent:` key at all — Type stays empty so resolution falls
|
||||
// through to the user-level default_agent.
|
||||
var doc struct {
|
||||
Agent AgentSpec `yaml:"agent"`
|
||||
}
|
||||
if err := yaml.Unmarshal([]byte("title: x\n"), &doc); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if doc.Agent.Type != "" || doc.Agent.Command != "" {
|
||||
t.Errorf("got %+v, want zero AgentSpec", doc.Agent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBuiltinAgentType(t *testing.T) {
|
||||
for _, name := range []string{"claude", "opencode"} {
|
||||
if !IsBuiltinAgentType(name) {
|
||||
t.Errorf("IsBuiltinAgentType(%q) = false, want true", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"custom", "gemini", "", "aider"} {
|
||||
if IsBuiltinAgentType(name) {
|
||||
t.Errorf("IsBuiltinAgentType(%q) = true, want false", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAgentSpec(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
spec AgentSpec
|
||||
wantErr string // substring; "" means no error
|
||||
}{
|
||||
{"empty ok (resolved later)", AgentSpec{}, ""},
|
||||
{"claude ok", AgentSpec{Type: "claude"}, ""},
|
||||
{"opencode ok", AgentSpec{Type: "opencode"}, ""},
|
||||
{"custom needs command", AgentSpec{Type: "custom"}, "type \"custom\" requires command"},
|
||||
{"custom with command ok", AgentSpec{Type: "custom", Command: "my-agent"}, ""},
|
||||
{"unknown type rejected", AgentSpec{Type: "gemini"}, "unknown agent type"},
|
||||
{"command with space rejected", AgentSpec{Type: "claude", Command: "claude --debug"}, "must be an executable name"},
|
||||
{"command with shell metachar rejected", AgentSpec{Type: "claude", Command: "claude|grep"}, "must be an executable name"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := ValidateAgentSpec("ws", &TaskMeta{Agent: tc.spec})
|
||||
if tc.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("got %v, want nil", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("got %v, want substring %q", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,11 @@ import (
|
||||
|
||||
// CreateOpts holds parameters for workspace creation.
|
||||
type CreateOpts struct {
|
||||
Root string
|
||||
Title string
|
||||
Category string
|
||||
Mode string
|
||||
Agent string
|
||||
Root string
|
||||
Title string
|
||||
Category string
|
||||
Mode string
|
||||
AgentSpec AgentSpec
|
||||
|
||||
// IsProject switches the built-in defaults to project-oriented templates,
|
||||
// records type=project in task.yaml, and triggers project seed overlay.
|
||||
@@ -141,7 +141,7 @@ func Create(opts CreateOpts) (*CreateResult, error) {
|
||||
Category: opts.Category,
|
||||
Type: taskType,
|
||||
Mode: opts.Mode,
|
||||
Agent: opts.Agent,
|
||||
Agent: opts.AgentSpec,
|
||||
ArchivedAt: nil,
|
||||
}
|
||||
if opts.IsProject {
|
||||
|
||||
@@ -12,11 +12,11 @@ func TestCreateWorkspace(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
opts := CreateOpts{
|
||||
Root: root,
|
||||
Title: "test task",
|
||||
Category: "general",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
Root: root,
|
||||
Title: "test task",
|
||||
Category: "general",
|
||||
Mode: "local",
|
||||
AgentSpec: AgentSpec{Type: "claude"},
|
||||
}
|
||||
|
||||
ws, err := Create(opts)
|
||||
@@ -68,11 +68,11 @@ func TestCreateWorkspaceNoTitle(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
opts := CreateOpts{
|
||||
Root: root,
|
||||
Title: "",
|
||||
Category: "general",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
Root: root,
|
||||
Title: "",
|
||||
Category: "general",
|
||||
Mode: "local",
|
||||
AgentSpec: AgentSpec{Type: "claude"},
|
||||
}
|
||||
|
||||
ws, err := Create(opts)
|
||||
@@ -96,7 +96,7 @@ func TestCreateProjectWritesProjectClaudeMD(t *testing.T) {
|
||||
Title: "billing",
|
||||
Category: "projects",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
AgentSpec: AgentSpec{Type: "claude"},
|
||||
IsProject: true,
|
||||
}
|
||||
ws, err := Create(opts)
|
||||
@@ -119,11 +119,11 @@ func TestCreateProjectWritesProjectClaudeMD(t *testing.T) {
|
||||
func TestCreateTaskWritesTaskClaudeMD(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
opts := CreateOpts{
|
||||
Root: root,
|
||||
Title: "fix bug",
|
||||
Category: "general",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
Root: root,
|
||||
Title: "fix bug",
|
||||
Category: "general",
|
||||
Mode: "local",
|
||||
AgentSpec: AgentSpec{Type: "claude"},
|
||||
}
|
||||
ws, err := Create(opts)
|
||||
if err != nil {
|
||||
@@ -150,12 +150,12 @@ func TestCreateAppliesGeneralSeed(t *testing.T) {
|
||||
}
|
||||
|
||||
opts := CreateOpts{
|
||||
Root: root,
|
||||
Title: "seeded",
|
||||
Category: "general",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
SeedDir: seedDir,
|
||||
Root: root,
|
||||
Title: "seeded",
|
||||
Category: "general",
|
||||
Mode: "local",
|
||||
AgentSpec: AgentSpec{Type: "claude"},
|
||||
SeedDir: seedDir,
|
||||
}
|
||||
ws, err := Create(opts)
|
||||
if err != nil {
|
||||
@@ -193,7 +193,7 @@ func TestCreateProjectAppliesGeneralThenProjectSeed(t *testing.T) {
|
||||
Title: "billing",
|
||||
Category: "projects",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
AgentSpec: AgentSpec{Type: "claude"},
|
||||
IsProject: true,
|
||||
SeedDir: general,
|
||||
ProjectSeedDir: project,
|
||||
@@ -222,12 +222,12 @@ func TestCreateSeedDoesNotReplaceTaskYAML(t *testing.T) {
|
||||
t.Fatalf("write seed task.yaml: %v", err)
|
||||
}
|
||||
opts := CreateOpts{
|
||||
Root: root,
|
||||
Title: "no clobber",
|
||||
Category: "general",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
SeedDir: seedDir,
|
||||
Root: root,
|
||||
Title: "no clobber",
|
||||
Category: "general",
|
||||
Mode: "local",
|
||||
AgentSpec: AgentSpec{Type: "claude"},
|
||||
SeedDir: seedDir,
|
||||
}
|
||||
ws, err := Create(opts)
|
||||
if err != nil {
|
||||
@@ -249,7 +249,7 @@ func TestCreateSkipCategoryDirPlacesUnderRoot(t *testing.T) {
|
||||
Title: "billing service",
|
||||
Category: "projects", // recorded in metadata
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
AgentSpec: AgentSpec{Type: "claude"},
|
||||
IsProject: true,
|
||||
SkipCategoryDir: true,
|
||||
}
|
||||
@@ -273,7 +273,7 @@ func TestCreateExplicitCategoryAppendsSubdir(t *testing.T) {
|
||||
Title: "billing service",
|
||||
Category: "backend",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
AgentSpec: AgentSpec{Type: "claude"},
|
||||
IsProject: true,
|
||||
// SkipCategoryDir: false (default)
|
||||
}
|
||||
@@ -291,11 +291,11 @@ func TestCreateCollisionSuffix(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
opts := CreateOpts{
|
||||
Root: root,
|
||||
Title: "same name",
|
||||
Category: "general",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
Root: root,
|
||||
Title: "same name",
|
||||
Category: "general",
|
||||
Mode: "local",
|
||||
AgentSpec: AgentSpec{Type: "claude"},
|
||||
}
|
||||
|
||||
ws1, _ := Create(opts)
|
||||
@@ -318,7 +318,7 @@ func TestCreateProjectScaffoldsSubdirAndSetsLaunchDir(t *testing.T) {
|
||||
Title: "litlink-v2",
|
||||
Category: "projects",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
AgentSpec: AgentSpec{Type: "claude"},
|
||||
IsProject: true,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -345,7 +345,7 @@ func TestCreateProjectSubdirMatchesSuffixedSlug(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
first, err := Create(CreateOpts{
|
||||
Root: root, Title: "dup", Category: "projects",
|
||||
Mode: "local", Agent: "claude", IsProject: true,
|
||||
Mode: "local", AgentSpec: AgentSpec{Type: "claude"}, IsProject: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("first Create: %v", err)
|
||||
@@ -356,7 +356,7 @@ func TestCreateProjectSubdirMatchesSuffixedSlug(t *testing.T) {
|
||||
|
||||
second, err := Create(CreateOpts{
|
||||
Root: root, Title: "dup", Category: "projects",
|
||||
Mode: "local", Agent: "claude", IsProject: true,
|
||||
Mode: "local", AgentSpec: AgentSpec{Type: "claude"}, IsProject: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second Create: %v", err)
|
||||
@@ -380,7 +380,7 @@ func TestCreateDirectoryPrefixUsesLocalDate(t *testing.T) {
|
||||
expected := time.Now().Format("2006-01-02") // LOCAL — no .UTC()
|
||||
res, err := Create(CreateOpts{
|
||||
Root: root, Title: "tz-check", Category: "general",
|
||||
Mode: "local", Agent: "claude",
|
||||
Mode: "local", AgentSpec: AgentSpec{Type: "claude"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
@@ -397,7 +397,7 @@ func TestCreateStoresTimestampsInUTC(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
res, err := Create(CreateOpts{
|
||||
Root: root, Title: "tz-check-stored", Category: "general",
|
||||
Mode: "local", Agent: "claude",
|
||||
Mode: "local", AgentSpec: AgentSpec{Type: "claude"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
@@ -414,7 +414,7 @@ func TestCreateTaskDoesNotScaffoldSubdir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
res, err := Create(CreateOpts{
|
||||
Root: root, Title: "hello", Category: "general",
|
||||
Mode: "local", Agent: "claude", IsProject: false,
|
||||
Mode: "local", AgentSpec: AgentSpec{Type: "claude"}, IsProject: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
|
||||
@@ -59,7 +59,7 @@ func TestCreateProjectPreservesGeneralSeedGitignore(t *testing.T) {
|
||||
Title: "gen gi proj",
|
||||
Category: "projects",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
AgentSpec: AgentSpec{Type: "claude"},
|
||||
IsProject: true,
|
||||
SeedDir: general,
|
||||
})
|
||||
@@ -91,7 +91,7 @@ func TestCreateProjectPreservesProjectSeedGitignore(t *testing.T) {
|
||||
Title: "proj gi proj",
|
||||
Category: "projects",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
AgentSpec: AgentSpec{Type: "claude"},
|
||||
IsProject: true,
|
||||
ProjectSeedDir: project,
|
||||
})
|
||||
@@ -116,7 +116,7 @@ func TestCreateProjectCreatesMinimalGitignoreWhenNoSeed(t *testing.T) {
|
||||
Title: "no seed gi",
|
||||
Category: "projects",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
AgentSpec: AgentSpec{Type: "claude"},
|
||||
IsProject: true,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/warrenronsiek/ctask/internal/lockfile"
|
||||
@@ -45,11 +46,118 @@ type TaskMeta struct {
|
||||
Category string `yaml:"category"`
|
||||
Type string `yaml:"type"`
|
||||
Mode string `yaml:"mode"`
|
||||
Agent string `yaml:"agent"`
|
||||
Agent AgentSpec `yaml:"agent,omitempty"`
|
||||
ArchivedAt *time.Time `yaml:"archived_at"`
|
||||
LaunchDir string `yaml:"launch_dir,omitempty"`
|
||||
}
|
||||
|
||||
// AgentSpec is the v0.6 agent profile carried in task.yaml. All fields are
|
||||
// optional; a missing Type falls through to the user-level default_agent at
|
||||
// resolution time (see internal/agent.Resolve).
|
||||
type AgentSpec struct {
|
||||
Type string `yaml:"type,omitempty"`
|
||||
Command string `yaml:"command,omitempty"`
|
||||
Args []string `yaml:"args,omitempty"`
|
||||
Env map[string]string `yaml:"env,omitempty"`
|
||||
}
|
||||
|
||||
// IsBuiltinAgentType reports whether name is a built-in agent type with a
|
||||
// known default command (claude, opencode). "custom" is NOT a built-in —
|
||||
// it is the escape hatch. internal/agent.BuiltinProfiles must mirror this
|
||||
// exact set; when a new built-in lands, update both together.
|
||||
func IsBuiltinAgentType(name string) bool {
|
||||
switch name {
|
||||
case "claude", "opencode":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// UnmarshalYAML accepts either:
|
||||
// - a mapping: the v0.6+ structured form, decoded field-by-field.
|
||||
// - a scalar string: the legacy v0.1-v0.5 form. The scalar is PRESERVED,
|
||||
// not dropped. A scalar matching a built-in name (claude, opencode)
|
||||
// maps to AgentSpec{Type: <scalar>}. Any other scalar — a path or an
|
||||
// arbitrary command such as `aider` or `/opt/agent` — maps to
|
||||
// AgentSpec{Type: "custom", Command: <scalar>}, so a legacy workspace
|
||||
// keeps launching the agent it was created with.
|
||||
// - a null/empty node: Type stays empty so resolution falls through to
|
||||
// the user-level default_agent (the agent field is effectively
|
||||
// missing).
|
||||
func (a *AgentSpec) UnmarshalYAML(node *yaml.Node) error {
|
||||
switch node.Kind {
|
||||
case yaml.ScalarNode:
|
||||
var s string
|
||||
if err := node.Decode(&s); err != nil {
|
||||
return fmt.Errorf("agent: %w", err)
|
||||
}
|
||||
switch {
|
||||
case s == "":
|
||||
// Empty/null — leave Type empty; resolver fills in the default.
|
||||
case IsBuiltinAgentType(s):
|
||||
*a = AgentSpec{Type: s}
|
||||
default:
|
||||
*a = AgentSpec{Type: "custom", Command: s}
|
||||
}
|
||||
return nil
|
||||
case yaml.MappingNode:
|
||||
type rawAgentSpec AgentSpec // alias to avoid recursion
|
||||
var v rawAgentSpec
|
||||
if err := node.Decode(&v); err != nil {
|
||||
return fmt.Errorf("agent: %w", err)
|
||||
}
|
||||
*a = AgentSpec(v)
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("agent: expected string or mapping, got node kind %d", node.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
// knownAgentTypes is the set of agent types accepted in task.yaml. "custom"
|
||||
// is the escape hatch — any executable can be used, but agent.command is
|
||||
// required for it. Built-in profiles (claude, opencode) live in
|
||||
// internal/agent.BuiltinProfiles; this set must stay in sync with that map
|
||||
// and with IsBuiltinAgentType.
|
||||
var knownAgentTypes = map[string]struct{}{
|
||||
"claude": {},
|
||||
"opencode": {},
|
||||
"custom": {},
|
||||
}
|
||||
|
||||
// agentCommandForbidden is the set of characters disallowed in agent.command.
|
||||
// command must be an executable name or absolute/relative path — arguments
|
||||
// belong in agent.args. Whitespace and shell metacharacters indicate the
|
||||
// user is trying to embed args; we reject them with a hint.
|
||||
const agentCommandForbidden = " \t|&;<>()$`"
|
||||
|
||||
// ValidateAgentSpec enforces the v0.6 invariants on the agent block. An
|
||||
// empty Type is always allowed (resolution fills in default_agent at
|
||||
// launch time). Any non-empty Type must be in knownAgentTypes. type:custom
|
||||
// requires a non-empty Command. Command (when set) must look like a single
|
||||
// executable: no whitespace, no shell metacharacters.
|
||||
func ValidateAgentSpec(slug string, m *TaskMeta) error {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
a := m.Agent
|
||||
if a.Type != "" {
|
||||
if _, ok := knownAgentTypes[a.Type]; !ok {
|
||||
return fmt.Errorf("workspace %q: unknown agent type %q (must be claude, opencode, or custom)",
|
||||
slug, a.Type)
|
||||
}
|
||||
}
|
||||
if a.Type == "custom" && a.Command == "" {
|
||||
return fmt.Errorf("workspace %q: agent type \"custom\" requires command field",
|
||||
slug)
|
||||
}
|
||||
if a.Command != "" && strings.ContainsAny(a.Command, agentCommandForbidden) {
|
||||
return fmt.Errorf("workspace %q: agent.command %q must be an executable name or path with no whitespace or shell metacharacters; put arguments in agent.args",
|
||||
slug, a.Command)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -146,6 +254,9 @@ func ReadMeta(path string) (*TaskMeta, error) {
|
||||
if err := ValidateWorkspaceMode(slug, &meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ValidateAgentSpec(slug, &meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &meta, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ func TestWriteMetaLockedHappyPath(t *testing.T) {
|
||||
ID: "1", Slug: "s", Title: "t",
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
Status: "active", Category: "general", Type: "task",
|
||||
Mode: "local", Agent: "claude",
|
||||
Mode: "local", Agent: AgentSpec{Type: "claude"},
|
||||
}
|
||||
|
||||
if err := WriteMetaLocked(metaPath, meta); err != nil {
|
||||
@@ -68,16 +68,16 @@ func TestWriteAndReadMeta(t *testing.T) {
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
meta := &TaskMeta{
|
||||
ID: "20260405-143022",
|
||||
Slug: "arch-notes",
|
||||
Title: "arch notes",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Status: "active",
|
||||
Category: "general",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
ArchivedAt: nil,
|
||||
ID: "20260405-143022",
|
||||
Slug: "arch-notes",
|
||||
Title: "arch notes",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Status: "active",
|
||||
Category: "general",
|
||||
Mode: "local",
|
||||
Agent: AgentSpec{Type: "claude"},
|
||||
ArchivedAt: nil,
|
||||
}
|
||||
|
||||
if err := WriteMeta(path, meta); err != nil {
|
||||
@@ -112,15 +112,15 @@ func TestMetaYAMLFieldsPresent(t *testing.T) {
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
meta := &TaskMeta{
|
||||
ID: "20260405-143022",
|
||||
Slug: "test",
|
||||
Title: "test",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Status: "active",
|
||||
Category: "general",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
ID: "20260405-143022",
|
||||
Slug: "test",
|
||||
Title: "test",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Status: "active",
|
||||
Category: "general",
|
||||
Mode: "local",
|
||||
Agent: AgentSpec{Type: "claude"},
|
||||
}
|
||||
|
||||
WriteMeta(path, meta)
|
||||
@@ -141,16 +141,16 @@ func TestMetaTypeRoundTrip(t *testing.T) {
|
||||
|
||||
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",
|
||||
ID: "20260410-120000",
|
||||
Slug: "billing",
|
||||
Title: "billing",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Status: "active",
|
||||
Category: "projects",
|
||||
Type: "project",
|
||||
Mode: "local",
|
||||
Agent: AgentSpec{Type: "claude"},
|
||||
}
|
||||
if err := WriteMeta(path, meta); err != nil {
|
||||
t.Fatalf("WriteMeta: %v", err)
|
||||
@@ -221,15 +221,15 @@ func TestMetaArchive(t *testing.T) {
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
meta := &TaskMeta{
|
||||
ID: "20260405-143022",
|
||||
Slug: "test",
|
||||
Title: "test",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Status: "active",
|
||||
Category: "general",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
ID: "20260405-143022",
|
||||
Slug: "test",
|
||||
Title: "test",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Status: "active",
|
||||
Category: "general",
|
||||
Mode: "local",
|
||||
Agent: AgentSpec{Type: "claude"},
|
||||
}
|
||||
|
||||
WriteMeta(path, meta)
|
||||
@@ -265,7 +265,7 @@ func TestMetaLaunchDirRoundTrip(t *testing.T) {
|
||||
Category: "projects",
|
||||
Type: "project",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
Agent: AgentSpec{Type: "claude"},
|
||||
LaunchDir: "demo",
|
||||
}
|
||||
if err := WriteMeta(path, meta); err != nil {
|
||||
@@ -289,8 +289,8 @@ func TestMetaLaunchDirOmittedWhenEmpty(t *testing.T) {
|
||||
ID: "t", Slug: "t", Title: "t",
|
||||
CreatedAt: time.Now().UTC().Truncate(time.Second),
|
||||
UpdatedAt: time.Now().UTC().Truncate(time.Second),
|
||||
Status: "active", Category: "general", Type: "task",
|
||||
Mode: "local", Agent: "claude",
|
||||
Status: "active", Category: "general", Type: "task",
|
||||
Mode: "local", Agent: AgentSpec{Type: "claude"},
|
||||
}
|
||||
if err := WriteMeta(path, meta); err != nil {
|
||||
t.Fatalf("WriteMeta: %v", err)
|
||||
|
||||
@@ -30,16 +30,16 @@ func createTestWorkspaceFull(t *testing.T, root, category, dirName, status, task
|
||||
// Extract slug from dirName (skip "YYYY-MM-DD_")
|
||||
slug := dirName[11:]
|
||||
meta := &TaskMeta{
|
||||
ID: "test",
|
||||
Slug: slug,
|
||||
Title: slug,
|
||||
CreatedAt: updatedAt,
|
||||
UpdatedAt: updatedAt,
|
||||
Status: status,
|
||||
Category: category,
|
||||
Type: taskType,
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
ID: "test",
|
||||
Slug: slug,
|
||||
Title: slug,
|
||||
CreatedAt: updatedAt,
|
||||
UpdatedAt: updatedAt,
|
||||
Status: status,
|
||||
Category: category,
|
||||
Type: taskType,
|
||||
Mode: "local",
|
||||
Agent: AgentSpec{Type: "claude"},
|
||||
}
|
||||
WriteMeta(filepath.Join(dir, "task.yaml"), meta)
|
||||
}
|
||||
@@ -175,16 +175,16 @@ func createFlatProjectWorkspaceFull(t *testing.T, root, dirName string, updatedA
|
||||
os.MkdirAll(dir, 0755)
|
||||
slug := dirName[11:]
|
||||
meta := &TaskMeta{
|
||||
ID: "test",
|
||||
Slug: slug,
|
||||
Title: slug,
|
||||
CreatedAt: updatedAt,
|
||||
UpdatedAt: updatedAt,
|
||||
Status: "active",
|
||||
Category: "projects",
|
||||
Type: "project",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
ID: "test",
|
||||
Slug: slug,
|
||||
Title: slug,
|
||||
CreatedAt: updatedAt,
|
||||
UpdatedAt: updatedAt,
|
||||
Status: "active",
|
||||
Category: "projects",
|
||||
Type: "project",
|
||||
Mode: "local",
|
||||
Agent: AgentSpec{Type: "claude"},
|
||||
}
|
||||
WriteMeta(filepath.Join(dir, "task.yaml"), meta)
|
||||
}
|
||||
|
||||
@@ -13,11 +13,11 @@ import (
|
||||
func TestNewMetaIncludesSchemaVersion(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
result, err := Create(CreateOpts{
|
||||
Root: root,
|
||||
Title: "Test schema",
|
||||
Category: "general",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
Root: root,
|
||||
Title: "Test schema",
|
||||
Category: "general",
|
||||
Mode: "local",
|
||||
AgentSpec: AgentSpec{Type: "claude"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
@@ -37,11 +37,11 @@ func TestNewMetaIncludesSchemaVersion(t *testing.T) {
|
||||
func TestNewMetaIncludesWorkspaceMode(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
result, err := Create(CreateOpts{
|
||||
Root: root,
|
||||
Title: "Test mode",
|
||||
Category: "general",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
Root: root,
|
||||
Title: "Test mode",
|
||||
Category: "general",
|
||||
Mode: "local",
|
||||
AgentSpec: AgentSpec{Type: "claude"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
@@ -65,11 +65,11 @@ func TestNewMetaIncludesWorkspaceMode(t *testing.T) {
|
||||
func TestNewMetaRoundTripWithSchemaFields(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
result, err := Create(CreateOpts{
|
||||
Root: root,
|
||||
Title: "Round trip",
|
||||
Category: "general",
|
||||
Mode: "local",
|
||||
Agent: "claude",
|
||||
Root: root,
|
||||
Title: "Round trip",
|
||||
Category: "general",
|
||||
Mode: "local",
|
||||
AgentSpec: AgentSpec{Type: "claude"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
|
||||
Reference in New Issue
Block a user