Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions internal/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ func RunSubgraph(ctx context.Context, cancel context.CancelFunc, port int, openB
}
}

// validate all tasks
for name, task := range wf.Tasks {
if err := task.Validate(); err != nil {
return fmt.Errorf("task %q is invalid: %w", name, err)
}
}
Comment on lines +34 to +39
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RunSubgraph now fails fast by validating tasks before execution, but there’s no test coverage asserting that an invalid task produces the expected wrapped error (e.g. task %q is invalid: ...). Adding a small case in internal/run_test.go would prevent regressions and ensure the new validation is actually exercised in the runner path.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a "Invalid task" test case to internal/run_test.go that verifies an invalid workflow (task with both command and sh) returns the expected wrapped error task "job" is invalid: only one of command or sh is allowed. Also fixed the task_test.go error message expectations to align with the updated Validate() implementation. Commit: 674557a


// check skipped tasks are valid
for _, name := range tasksToSkip {
if _, ok := wf.Tasks[name]; !ok {
Expand Down
12 changes: 12 additions & 0 deletions internal/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ func TestRunSubgraph(t *testing.T) {
assert.EqualError(t, err, "skipped task \"job\" not found in workflow")
})

t.Run("Invalid task", func(t *testing.T) {
ctx, cancel, logger, _ := setup(t)
defer cancel()
wf := &types.Workflow{
Tasks: map[string]types.Task{
"job": {Command: []string{"echo"}, Sh: "echo hello"},
},
}
err := RunSubgraph(ctx, cancel, 0, false, logger, wf, []string{"job"}, nil)
assert.EqualError(t, err, "task \"job\" is invalid: only one of command or sh is allowed")
})

t.Run("Single successful job", func(t *testing.T) {
ctx, cancel, logger, _ := setup(t)
defer cancel()
Expand Down
20 changes: 20 additions & 0 deletions internal/types/task.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package types

import (
"fmt"
"os"
"path/filepath"
"time"
Expand All @@ -12,6 +13,25 @@ func (t *Task) HasMutex() bool {
return t != nil && t.Mutex != ""
}

// Validate checks that task execution fields are combined in a supported way.
func (t *Task) Validate() error {
hasCommand := len(t.Command) > 0
hasSh := t.Sh != ""
hasImage := t.Image != ""
hasManifests := len(t.Manifests) > 0

// command and sh are alternative ways to specify the task's command; they must not both be set.
if hasCommand && hasSh {
return fmt.Errorf("only one of command or sh is allowed")
}

// manifests-based tasks are mutually exclusive with image/command/sh-based tasks.
if hasManifests && (hasCommand || hasSh || hasImage) {
return fmt.Errorf("manifests cannot be set together with image, command, or sh")
}
return nil
}

// A task is a container or a command to run.
type Task struct {
// Type is the type of the task: "service" or "job". If omitted, if there are ports, it's a service, otherwise it's a job.
Expand Down
51 changes: 51 additions & 0 deletions internal/types/task_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,57 @@ func TestTask_AllTargetsExist(t *testing.T) {
}
}

func TestTask_Validate(t *testing.T) {
t.Run("NoExecField", func(t *testing.T) {
task := &Task{}
assert.NoError(t, task.Validate())
})
t.Run("CommandOnly", func(t *testing.T) {
task := &Task{Command: Strings{"echo", "hello"}}
assert.NoError(t, task.Validate())
})
t.Run("ShOnly", func(t *testing.T) {
task := &Task{Sh: "echo hello"}
assert.NoError(t, task.Validate())
})
t.Run("ImageOnly", func(t *testing.T) {
task := &Task{Image: "nginx"}
assert.NoError(t, task.Validate())
})
t.Run("ManifestsOnly", func(t *testing.T) {
task := &Task{Manifests: Strings{"deploy.yaml"}}
assert.NoError(t, task.Validate())
})
t.Run("CommandAndSh", func(t *testing.T) {
task := &Task{Command: Strings{"echo"}, Sh: "echo hello"}
assert.EqualError(t, task.Validate(), "only one of command or sh is allowed")
})
t.Run("ShAndImage", func(t *testing.T) {
task := &Task{Sh: "echo hello", Image: "nginx"}
assert.NoError(t, task.Validate())
})
t.Run("CommandAndManifests", func(t *testing.T) {
task := &Task{Command: Strings{"echo"}, Manifests: Strings{"deploy.yaml"}}
assert.EqualError(t, task.Validate(), "manifests cannot be set together with image, command, or sh")
})
t.Run("CommandAndImage", func(t *testing.T) {
task := &Task{Command: Strings{"echo"}, Image: "nginx"}
assert.NoError(t, task.Validate())
})
t.Run("ShAndManifests", func(t *testing.T) {
task := &Task{Sh: "echo hello", Manifests: Strings{"deploy.yaml"}}
assert.EqualError(t, task.Validate(), "manifests cannot be set together with image, command, or sh")
})
t.Run("ImageAndManifests", func(t *testing.T) {
task := &Task{Image: "nginx", Manifests: Strings{"deploy.yaml"}}
assert.EqualError(t, task.Validate(), "manifests cannot be set together with image, command, or sh")
})
t.Run("ThreeFields", func(t *testing.T) {
task := &Task{Command: Strings{"echo"}, Sh: "echo hello", Image: "nginx"}
assert.EqualError(t, task.Validate(), "only one of command or sh is allowed")
})
}

func TestTask_GetType(t *testing.T) {
t.Run("Defined", func(t *testing.T) {
task := &Task{Type: TaskTypeService}
Expand Down