diff --git a/api/v1alpha1/taskspawner_types.go b/api/v1alpha1/taskspawner_types.go index d35020e6..a698bb5b 100644 --- a/api/v1alpha1/taskspawner_types.go +++ b/api/v1alpha1/taskspawner_types.go @@ -330,6 +330,7 @@ type TaskTemplate struct { // DependsOn lists Task names that spawned Tasks depend on. // +optional + // +kubebuilder:validation:MaxItems=10 DependsOn []string `json:"dependsOn,omitempty"` // Branch is the git branch spawned Tasks should work on. @@ -373,16 +374,46 @@ type TaskTemplate struct { UpstreamRepo string `json:"upstreamRepo,omitempty"` } +// NamedTaskTemplate extends TaskTemplate with a pipeline step name. +// When used in taskTemplates, the DependsOn field references sibling step +// names within the same pipeline (the spawner translates them to +// fully-qualified task names at creation time). +type NamedTaskTemplate struct { + // Name is the step name within the pipeline, used for task name suffix + // and as a reference target for other steps' dependsOn. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=63 + // +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$` + Name string `json:"name"` + + TaskTemplate `json:",inline"` +} + // TaskSpawnerSpec defines the desired state of TaskSpawner. -// +kubebuilder:validation:XValidation:rule="!(has(self.when.githubIssues) || has(self.when.githubPullRequests)) || has(self.taskTemplate.workspaceRef)",message="taskTemplate.workspaceRef is required when using githubIssues or githubPullRequests source" +// +kubebuilder:validation:XValidation:rule="has(self.taskTemplate) != has(self.taskTemplates)",message="exactly one of taskTemplate or taskTemplates must be set" +// +kubebuilder:validation:XValidation:rule="!(has(self.when.githubIssues) || has(self.when.githubPullRequests)) || (has(self.taskTemplate) && has(self.taskTemplate.workspaceRef)) || (has(self.taskTemplates) && self.taskTemplates.exists(t, has(t.workspaceRef)))",message="workspaceRef is required when using githubIssues or githubPullRequests source" +// +kubebuilder:validation:XValidation:rule="!has(self.taskTemplates) || self.taskTemplates.all(t, self.taskTemplates.exists_one(s, s.name == t.name))",message="taskTemplates step names must be unique" +// +kubebuilder:validation:XValidation:rule="!has(self.taskTemplates) || self.taskTemplates.all(t, !has(t.dependsOn) || t.dependsOn.all(d, self.taskTemplates.exists(s, s.name == d)))",message="taskTemplates dependsOn must reference existing step names" +// +kubebuilder:validation:XValidation:rule="!has(self.taskTemplates) || self.taskTemplates.all(t, !has(t.dependsOn) || !t.dependsOn.exists(d, d == t.name))",message="taskTemplates steps must not depend on themselves" type TaskSpawnerSpec struct { // When defines the conditions that trigger task spawning. // +kubebuilder:validation:Required When When `json:"when"` // TaskTemplate defines the template for spawned Tasks. - // +kubebuilder:validation:Required - TaskTemplate TaskTemplate `json:"taskTemplate"` + // Exactly one of taskTemplate or taskTemplates must be set. + // +optional + TaskTemplate *TaskTemplate `json:"taskTemplate,omitempty"` + + // TaskTemplates defines a pipeline of named task templates. + // When set, each discovered work item spawns one Task per step, + // with DependsOn references translated to fully-qualified task names. + // Exactly one of taskTemplate or taskTemplates must be set. + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=10 + TaskTemplates []NamedTaskTemplate `json:"taskTemplates,omitempty"` // PollInterval is how often to poll the source for new items (e.g., "5m"). Defaults to "5m". // Deprecated: use per-source pollInterval (e.g., spec.when.githubIssues.pollInterval) instead. @@ -443,6 +474,12 @@ type TaskSpawnerStatus struct { // +optional ActiveTasks int `json:"activeTasks,omitempty"` + // TotalPipelinesCreated is the total number of pipeline instances created + // (one per work item when using taskTemplates). Only incremented in + // pipeline mode. + // +optional + TotalPipelinesCreated int `json:"totalPipelinesCreated,omitempty"` + // LastDiscoveryTime is the last time the source was polled. // +optional LastDiscoveryTime *metav1.Time `json:"lastDiscoveryTime,omitempty"` diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 69bc880b..1199af76 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -399,6 +399,22 @@ func (in *MCPServerSpec) DeepCopy() *MCPServerSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NamedTaskTemplate) DeepCopyInto(out *NamedTaskTemplate) { + *out = *in + in.TaskTemplate.DeepCopyInto(&out.TaskTemplate) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamedTaskTemplate. +func (in *NamedTaskTemplate) DeepCopy() *NamedTaskTemplate { + if in == nil { + return nil + } + out := new(NamedTaskTemplate) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PluginSpec) DeepCopyInto(out *PluginSpec) { *out = *in @@ -646,7 +662,18 @@ func (in *TaskSpawnerList) DeepCopyObject() runtime.Object { func (in *TaskSpawnerSpec) DeepCopyInto(out *TaskSpawnerSpec) { *out = *in in.When.DeepCopyInto(&out.When) - in.TaskTemplate.DeepCopyInto(&out.TaskTemplate) + if in.TaskTemplate != nil { + in, out := &in.TaskTemplate, &out.TaskTemplate + *out = new(TaskTemplate) + (*in).DeepCopyInto(*out) + } + if in.TaskTemplates != nil { + in, out := &in.TaskTemplates, &out.TaskTemplates + *out = make([]NamedTaskTemplate, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.MaxConcurrency != nil { in, out := &in.MaxConcurrency, &out.MaxConcurrency *out = new(int32) diff --git a/cmd/kelos-spawner/main.go b/cmd/kelos-spawner/main.go index 70f45487..aba86fa3 100644 --- a/cmd/kelos-spawner/main.go +++ b/cmd/kelos-spawner/main.go @@ -239,41 +239,86 @@ func runCycleWithSource(ctx context.Context, cl client.Client, key types.Namespa } existingTaskMap := make(map[string]*kelosv1alpha1.Task) - activeTasks := 0 for i := range existingTaskList.Items { t := &existingTaskList.Items[i] existingTaskMap[t.Name] = t - if t.Status.Phase != kelosv1alpha1.TaskPhaseSucceeded && t.Status.Phase != kelosv1alpha1.TaskPhaseFailed { - activeTasks++ + } + + pipelineMode := len(ts.Spec.TaskTemplates) > 0 + + // Count active tasks/pipelines depending on mode. + activeTasks := 0 + if pipelineMode { + // In pipeline mode, count distinct active pipeline instances. + activeItems := make(map[string]bool) + for i := range existingTaskList.Items { + t := &existingTaskList.Items[i] + if t.Status.Phase != kelosv1alpha1.TaskPhaseSucceeded && t.Status.Phase != kelosv1alpha1.TaskPhaseFailed { + if itemID := t.Labels["kelos.dev/pipeline-item"]; itemID != "" { + activeItems[itemID] = true + } + } + } + activeTasks = len(activeItems) + } else { + for i := range existingTaskList.Items { + t := &existingTaskList.Items[i] + if t.Status.Phase != kelosv1alpha1.TaskPhaseSucceeded && t.Status.Phase != kelosv1alpha1.TaskPhaseFailed { + activeTasks++ + } } } var newItems []source.WorkItem + // In pipeline mode, track how many steps are missing per item for budget calculation. + var missingStepsPerItem map[string]int + if pipelineMode { + missingStepsPerItem = make(map[string]int) + } for _, item := range items { - taskName := fmt.Sprintf("%s-%s", ts.Name, item.ID) - existing, found := existingTaskMap[taskName] - if !found { - newItems = append(newItems, item) - continue - } - - // Retrigger: when the source provides a trigger time and the existing - // task is completed, check whether a new trigger arrived after the task - // finished. If so, delete the completed task so a new one can be created. - // Note: if creation is later blocked by maxConcurrency or maxTotalTasks, - // the item will be picked up as new on the next cycle since the old task - // no longer exists. - if !item.TriggerTime.IsZero() && - (existing.Status.Phase == kelosv1alpha1.TaskPhaseSucceeded || existing.Status.Phase == kelosv1alpha1.TaskPhaseFailed) && - existing.Status.CompletionTime != nil && - item.TriggerTime.After(existing.Status.CompletionTime.Time) { - - if err := cl.Delete(ctx, existing); err != nil && !apierrors.IsNotFound(err) { - log.Error(err, "Deleting completed task for retrigger", "task", taskName) + if pipelineMode { + // In pipeline mode, check if any step is missing for this item. + // This supports partial pipeline recovery: if the spawner crashed + // after creating some steps, the remaining steps are created on + // the next cycle. + missing := 0 + for _, step := range ts.Spec.TaskTemplates { + taskName := fmt.Sprintf("%s-%s-%s", ts.Name, item.ID, step.Name) + if _, found := existingTaskMap[taskName]; !found { + missing++ + } + } + if missing > 0 { + newItems = append(newItems, item) + missingStepsPerItem[item.ID] = missing + } + // TODO: retrigger support for pipelines can be added later + } else { + taskName := fmt.Sprintf("%s-%s", ts.Name, item.ID) + existing, found := existingTaskMap[taskName] + if !found { + newItems = append(newItems, item) continue } - log.Info("Deleted completed task for retrigger", "task", taskName) - newItems = append(newItems, item) + + // Retrigger: when the source provides a trigger time and the existing + // task is completed, check whether a new trigger arrived after the task + // finished. If so, delete the completed task so a new one can be created. + // Note: if creation is later blocked by maxConcurrency or maxTotalTasks, + // the item will be picked up as new on the next cycle since the old task + // no longer exists. + if !item.TriggerTime.IsZero() && + (existing.Status.Phase == kelosv1alpha1.TaskPhaseSucceeded || existing.Status.Phase == kelosv1alpha1.TaskPhaseFailed) && + existing.Status.CompletionTime != nil && + item.TriggerTime.After(existing.Status.CompletionTime.Time) { + + if err := cl.Delete(ctx, existing); err != nil && !apierrors.IsNotFound(err) { + log.Error(err, "Deleting completed task for retrigger", "task", taskName) + continue + } + log.Info("Deleted completed task for retrigger", "task", taskName) + newItems = append(newItems, item) + } } } @@ -293,89 +338,198 @@ func runCycleWithSource(ctx context.Context, cl client.Client, key types.Namespa } newTasksCreated := 0 + newPipelinesCreated := 0 for _, item := range newItems { - // Enforce max concurrency limit - if maxConcurrency > 0 && int32(activeTasks) >= maxConcurrency { - log.Info("Max concurrency reached, skipping remaining items", "activeTasks", activeTasks, "maxConcurrency", maxConcurrency) - break - } + if pipelineMode { + // In pipeline mode, maxConcurrency counts pipeline instances + // (distinct items with at least one non-terminal task). + if maxConcurrency > 0 && int32(activeTasks) >= maxConcurrency { + log.Info("Max concurrency reached, skipping remaining items", "activePipelines", activeTasks, "maxConcurrency", maxConcurrency) + break + } - // Enforce max total tasks limit - if maxTotalTasks > 0 && ts.Status.TotalTasksCreated+newTasksCreated >= maxTotalTasks { - log.Info("Task budget exhausted, skipping remaining items", "totalCreated", ts.Status.TotalTasksCreated+newTasksCreated, "maxTotalTasks", maxTotalTasks) - break - } + stepsToCreate := missingStepsPerItem[item.ID] - taskName := fmt.Sprintf("%s-%s", ts.Name, item.ID) + // Enforce max total tasks limit (counts individual tasks) + if maxTotalTasks > 0 && ts.Status.TotalTasksCreated+newTasksCreated+stepsToCreate > maxTotalTasks { + log.Info("Task budget exhausted, skipping remaining items", "totalCreated", ts.Status.TotalTasksCreated+newTasksCreated, "stepsNeeded", stepsToCreate, "maxTotalTasks", maxTotalTasks) + break + } - prompt, err := source.RenderPrompt(ts.Spec.TaskTemplate.PromptTemplate, item) - if err != nil { - log.Error(err, "rendering prompt", "item", item.ID) - continue - } + annotations := sourceAnnotations(&ts, item) + pipelineCreated := false + + for _, step := range ts.Spec.TaskTemplates { + taskName := fmt.Sprintf("%s-%s-%s", ts.Name, item.ID, step.Name) + + // Check if this step already exists (partial pipeline recovery) + if _, exists := existingTaskMap[taskName]; exists { + continue + } + + prompt, err := source.RenderPrompt(step.PromptTemplate, item) + if err != nil { + log.Error(err, "Rendering prompt for pipeline step", "item", item.ID, "step", step.Name) + continue + } + + task := &kelosv1alpha1.Task{ + ObjectMeta: metav1.ObjectMeta{ + Name: taskName, + Namespace: ts.Namespace, + Labels: map[string]string{ + "kelos.dev/taskspawner": ts.Name, + "kelos.dev/pipeline-item": item.ID, + }, + Annotations: annotations, + }, + Spec: kelosv1alpha1.TaskSpec{ + Type: step.Type, + Prompt: prompt, + Credentials: step.Credentials, + Model: step.Model, + Image: step.Image, + TTLSecondsAfterFinished: step.TTLSecondsAfterFinished, + PodOverrides: step.PodOverrides, + }, + } + + if step.WorkspaceRef != nil { + task.Spec.WorkspaceRef = step.WorkspaceRef + } + if step.AgentConfigRef != nil { + task.Spec.AgentConfigRef = step.AgentConfigRef + } + + // Translate sibling step names to fully-qualified task names + if len(step.DependsOn) > 0 { + translated := make([]string, len(step.DependsOn)) + for i, dep := range step.DependsOn { + translated[i] = fmt.Sprintf("%s-%s-%s", ts.Name, item.ID, dep) + } + task.Spec.DependsOn = translated + } + + if step.Branch != "" { + branch, err := source.RenderTemplate(step.Branch, item) + if err != nil { + log.Error(err, "Rendering branch template for pipeline step", "item", item.ID, "step", step.Name) + continue + } + task.Spec.Branch = branch + } + + if step.UpstreamRepo != "" { + task.Spec.UpstreamRepo = step.UpstreamRepo + } else if upstreamRepo := deriveUpstreamRepo(&ts); upstreamRepo != "" { + task.Spec.UpstreamRepo = upstreamRepo + } + + if err := cl.Create(ctx, task); err != nil { + if apierrors.IsAlreadyExists(err) { + log.Info("Pipeline task already exists, skipping", "task", taskName) + } else { + log.Error(err, "Creating pipeline task", "task", taskName) + } + continue + } + + log.Info("Created pipeline task", "task", taskName, "item", item.ID, "step", step.Name) + newTasksCreated++ + pipelineCreated = true + } - annotations := sourceAnnotations(&ts, item) + if pipelineCreated { + newPipelinesCreated++ + activeTasks++ + } + } else { + // Single-task mode (original behavior) + tmpl := ts.Spec.TaskTemplate - task := &kelosv1alpha1.Task{ - ObjectMeta: metav1.ObjectMeta{ - Name: taskName, - Namespace: ts.Namespace, - Labels: map[string]string{ - "kelos.dev/taskspawner": ts.Name, - }, - Annotations: annotations, - }, - Spec: kelosv1alpha1.TaskSpec{ - Type: ts.Spec.TaskTemplate.Type, - Prompt: prompt, - Credentials: ts.Spec.TaskTemplate.Credentials, - Model: ts.Spec.TaskTemplate.Model, - Image: ts.Spec.TaskTemplate.Image, - TTLSecondsAfterFinished: ts.Spec.TaskTemplate.TTLSecondsAfterFinished, - PodOverrides: ts.Spec.TaskTemplate.PodOverrides, - }, - } + // Enforce max concurrency limit + if maxConcurrency > 0 && int32(activeTasks) >= maxConcurrency { + log.Info("Max concurrency reached, skipping remaining items", "activeTasks", activeTasks, "maxConcurrency", maxConcurrency) + break + } - if ts.Spec.TaskTemplate.WorkspaceRef != nil { - task.Spec.WorkspaceRef = ts.Spec.TaskTemplate.WorkspaceRef - } - if ts.Spec.TaskTemplate.AgentConfigRef != nil { - task.Spec.AgentConfigRef = ts.Spec.TaskTemplate.AgentConfigRef - } + // Enforce max total tasks limit + if maxTotalTasks > 0 && ts.Status.TotalTasksCreated+newTasksCreated >= maxTotalTasks { + log.Info("Task budget exhausted, skipping remaining items", "totalCreated", ts.Status.TotalTasksCreated+newTasksCreated, "maxTotalTasks", maxTotalTasks) + break + } - if len(ts.Spec.TaskTemplate.DependsOn) > 0 { - task.Spec.DependsOn = ts.Spec.TaskTemplate.DependsOn - } - if ts.Spec.TaskTemplate.Branch != "" { - branch, err := source.RenderTemplate(ts.Spec.TaskTemplate.Branch, item) + taskName := fmt.Sprintf("%s-%s", ts.Name, item.ID) + + prompt, err := source.RenderPrompt(tmpl.PromptTemplate, item) if err != nil { - log.Error(err, "rendering branch template", "item", item.ID) + log.Error(err, "rendering prompt", "item", item.ID) continue } - task.Spec.Branch = branch - } - // Propagate upstream repo for fork workflows. Explicit template - // value takes precedence; otherwise derive from the source repo - // override (githubIssues.repo or githubPullRequests.repo). - if ts.Spec.TaskTemplate.UpstreamRepo != "" { - task.Spec.UpstreamRepo = ts.Spec.TaskTemplate.UpstreamRepo - } else if upstreamRepo := deriveUpstreamRepo(&ts); upstreamRepo != "" { - task.Spec.UpstreamRepo = upstreamRepo - } + annotations := sourceAnnotations(&ts, item) - if err := cl.Create(ctx, task); err != nil { - if apierrors.IsAlreadyExists(err) { - log.Info("Task already exists, skipping", "task", taskName) - } else { - log.Error(err, "creating Task", "task", taskName) + task := &kelosv1alpha1.Task{ + ObjectMeta: metav1.ObjectMeta{ + Name: taskName, + Namespace: ts.Namespace, + Labels: map[string]string{ + "kelos.dev/taskspawner": ts.Name, + }, + Annotations: annotations, + }, + Spec: kelosv1alpha1.TaskSpec{ + Type: tmpl.Type, + Prompt: prompt, + Credentials: tmpl.Credentials, + Model: tmpl.Model, + Image: tmpl.Image, + TTLSecondsAfterFinished: tmpl.TTLSecondsAfterFinished, + PodOverrides: tmpl.PodOverrides, + }, } - continue - } - log.Info("Created Task", "task", taskName, "item", item.ID) - newTasksCreated++ - activeTasks++ + if tmpl.WorkspaceRef != nil { + task.Spec.WorkspaceRef = tmpl.WorkspaceRef + } + if tmpl.AgentConfigRef != nil { + task.Spec.AgentConfigRef = tmpl.AgentConfigRef + } + + if len(tmpl.DependsOn) > 0 { + task.Spec.DependsOn = tmpl.DependsOn + } + if tmpl.Branch != "" { + branch, err := source.RenderTemplate(tmpl.Branch, item) + if err != nil { + log.Error(err, "rendering branch template", "item", item.ID) + continue + } + task.Spec.Branch = branch + } + + // Propagate upstream repo for fork workflows. Explicit template + // value takes precedence; otherwise derive from the source repo + // override (githubIssues.repo or githubPullRequests.repo). + if tmpl.UpstreamRepo != "" { + task.Spec.UpstreamRepo = tmpl.UpstreamRepo + } else if upstreamRepo := deriveUpstreamRepo(&ts); upstreamRepo != "" { + task.Spec.UpstreamRepo = upstreamRepo + } + + if err := cl.Create(ctx, task); err != nil { + if apierrors.IsAlreadyExists(err) { + log.Info("Task already exists, skipping", "task", taskName) + } else { + log.Error(err, "creating Task", "task", taskName) + } + continue + } + + log.Info("Created Task", "task", taskName, "item", item.ID) + newTasksCreated++ + activeTasks++ + } } // Update status in a single batch @@ -388,6 +542,7 @@ func runCycleWithSource(ctx context.Context, cl client.Client, key types.Namespa ts.Status.LastDiscoveryTime = &now ts.Status.TotalDiscovered = len(items) ts.Status.TotalTasksCreated += newTasksCreated + ts.Status.TotalPipelinesCreated += newPipelinesCreated ts.Status.ActiveTasks = activeTasks ts.Status.Message = fmt.Sprintf("Discovered %d items, created %d tasks total", ts.Status.TotalDiscovered, ts.Status.TotalTasksCreated) diff --git a/cmd/kelos-spawner/main_test.go b/cmd/kelos-spawner/main_test.go index 0cec6029..bd0728ee 100644 --- a/cmd/kelos-spawner/main_test.go +++ b/cmd/kelos-spawner/main_test.go @@ -73,7 +73,7 @@ func newTaskSpawner(name, namespace string, maxConcurrency *int32) *kelosv1alpha When: kelosv1alpha1.When{ GitHubIssues: &kelosv1alpha1.GitHubIssues{}, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, @@ -208,7 +208,7 @@ func TestBuildSource_Jira(t *testing.T) { SecretRef: kelosv1alpha1.SecretReference{Name: "jira-creds"}, }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, @@ -1516,7 +1516,7 @@ func TestRunCycleWithSource_PropagatesUpstreamRepo(t *testing.T) { Repo: "https://github.com/upstream-org/upstream-repo.git", }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, @@ -1559,7 +1559,7 @@ func TestRunCycleWithSource_ExplicitUpstreamRepoTakesPrecedence(t *testing.T) { Repo: "https://github.com/upstream-org/upstream-repo.git", }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, @@ -2202,3 +2202,388 @@ func TestRunOnce_ReturnsSourcePollInterval(t *testing.T) { t.Fatalf("Interval = %v, want %v", interval, 15*time.Second) } } + +// newPipelineTaskSpawner creates a TaskSpawner with taskTemplates (pipeline mode). +func newPipelineTaskSpawner(name, namespace string, maxConcurrency *int32) *kelosv1alpha1.TaskSpawner { + return &kelosv1alpha1.TaskSpawner{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: kelosv1alpha1.TaskSpawnerSpec{ + When: kelosv1alpha1.When{ + GitHubIssues: &kelosv1alpha1.GitHubIssues{}, + }, + TaskTemplates: []kelosv1alpha1.NamedTaskTemplate{ + { + Name: "plan", + TaskTemplate: kelosv1alpha1.TaskTemplate{ + Type: "claude-code", + Credentials: kelosv1alpha1.Credentials{ + Type: kelosv1alpha1.CredentialTypeOAuth, + SecretRef: kelosv1alpha1.SecretReference{Name: "creds"}, + }, + WorkspaceRef: &kelosv1alpha1.WorkspaceReference{Name: "test-ws"}, + Model: "opus", + }, + }, + { + Name: "implement", + TaskTemplate: kelosv1alpha1.TaskTemplate{ + Type: "claude-code", + Credentials: kelosv1alpha1.Credentials{ + Type: kelosv1alpha1.CredentialTypeOAuth, + SecretRef: kelosv1alpha1.SecretReference{Name: "creds"}, + }, + WorkspaceRef: &kelosv1alpha1.WorkspaceReference{Name: "test-ws"}, + DependsOn: []string{"plan"}, + Model: "sonnet", + Branch: "kelos-{{.Number}}", + }, + }, + { + Name: "open-pr", + TaskTemplate: kelosv1alpha1.TaskTemplate{ + Type: "claude-code", + Credentials: kelosv1alpha1.Credentials{ + Type: kelosv1alpha1.CredentialTypeOAuth, + SecretRef: kelosv1alpha1.SecretReference{Name: "creds"}, + }, + WorkspaceRef: &kelosv1alpha1.WorkspaceReference{Name: "test-ws"}, + DependsOn: []string{"implement"}, + Branch: "kelos-{{.Number}}", + }, + }, + }, + MaxConcurrency: maxConcurrency, + }, + } +} + +func newPipelineTask(name, namespace, spawnerName, itemID string, phase kelosv1alpha1.TaskPhase) kelosv1alpha1.Task { + return kelosv1alpha1.Task{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: map[string]string{ + "kelos.dev/taskspawner": spawnerName, + "kelos.dev/pipeline-item": itemID, + }, + }, + Spec: kelosv1alpha1.TaskSpec{ + Type: "claude-code", + Prompt: "test", + Credentials: kelosv1alpha1.Credentials{ + Type: kelosv1alpha1.CredentialTypeOAuth, + SecretRef: kelosv1alpha1.SecretReference{Name: "creds"}, + }, + }, + Status: kelosv1alpha1.TaskStatus{ + Phase: phase, + }, + } +} + +func TestRunCycleWithSource_TaskTemplates_CreatesMultipleTasksPerItem(t *testing.T) { + ts := newPipelineTaskSpawner("spawner", "default", nil) + cl, key := setupTest(t, ts) + + src := &fakeSource{ + items: []source.WorkItem{ + {ID: "42", Number: 42, Title: "Fix bug"}, + }, + } + + if err := runCycleWithSource(context.Background(), cl, key, src); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var taskList kelosv1alpha1.TaskList + if err := cl.List(context.Background(), &taskList, client.InNamespace("default")); err != nil { + t.Fatalf("Listing tasks: %v", err) + } + if len(taskList.Items) != 3 { + t.Fatalf("Expected 3 tasks (one per pipeline step), got %d", len(taskList.Items)) + } + + taskNames := make(map[string]bool) + for _, task := range taskList.Items { + taskNames[task.Name] = true + } + for _, expected := range []string{"spawner-42-plan", "spawner-42-implement", "spawner-42-open-pr"} { + if !taskNames[expected] { + t.Errorf("Expected task %q to be created", expected) + } + } +} + +func TestRunCycleWithSource_TaskTemplates_DependsOnTranslation(t *testing.T) { + ts := newPipelineTaskSpawner("spawner", "default", nil) + cl, key := setupTest(t, ts) + + src := &fakeSource{ + items: []source.WorkItem{ + {ID: "42", Number: 42, Title: "Fix bug"}, + }, + } + + if err := runCycleWithSource(context.Background(), cl, key, src); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var taskList kelosv1alpha1.TaskList + if err := cl.List(context.Background(), &taskList, client.InNamespace("default")); err != nil { + t.Fatalf("Listing tasks: %v", err) + } + + taskByName := make(map[string]*kelosv1alpha1.Task) + for i := range taskList.Items { + taskByName[taskList.Items[i].Name] = &taskList.Items[i] + } + + // plan step should have no dependencies + planTask := taskByName["spawner-42-plan"] + if planTask == nil { + t.Fatal("Expected plan task to exist") + } + if len(planTask.Spec.DependsOn) != 0 { + t.Errorf("Plan task should have no dependencies, got %v", planTask.Spec.DependsOn) + } + + // implement step should depend on the fully-qualified plan task name + implTask := taskByName["spawner-42-implement"] + if implTask == nil { + t.Fatal("Expected implement task to exist") + } + if len(implTask.Spec.DependsOn) != 1 || implTask.Spec.DependsOn[0] != "spawner-42-plan" { + t.Errorf("Implement task dependsOn = %v, want [spawner-42-plan]", implTask.Spec.DependsOn) + } + + // open-pr step should depend on the fully-qualified implement task name + prTask := taskByName["spawner-42-open-pr"] + if prTask == nil { + t.Fatal("Expected open-pr task to exist") + } + if len(prTask.Spec.DependsOn) != 1 || prTask.Spec.DependsOn[0] != "spawner-42-implement" { + t.Errorf("Open-pr task dependsOn = %v, want [spawner-42-implement]", prTask.Spec.DependsOn) + } +} + +func TestRunCycleWithSource_TaskTemplates_IndependentStepFields(t *testing.T) { + ts := newPipelineTaskSpawner("spawner", "default", nil) + cl, key := setupTest(t, ts) + + src := &fakeSource{ + items: []source.WorkItem{ + {ID: "42", Number: 42, Title: "Fix bug"}, + }, + } + + if err := runCycleWithSource(context.Background(), cl, key, src); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var taskList kelosv1alpha1.TaskList + if err := cl.List(context.Background(), &taskList, client.InNamespace("default")); err != nil { + t.Fatalf("Listing tasks: %v", err) + } + + taskByName := make(map[string]*kelosv1alpha1.Task) + for i := range taskList.Items { + taskByName[taskList.Items[i].Name] = &taskList.Items[i] + } + + // plan step should use opus model + planTask := taskByName["spawner-42-plan"] + if planTask.Spec.Model != "opus" { + t.Errorf("Plan task model = %q, want %q", planTask.Spec.Model, "opus") + } + + // implement step should use sonnet model and rendered branch + implTask := taskByName["spawner-42-implement"] + if implTask.Spec.Model != "sonnet" { + t.Errorf("Implement task model = %q, want %q", implTask.Spec.Model, "sonnet") + } + if implTask.Spec.Branch != "kelos-42" { + t.Errorf("Implement task branch = %q, want %q", implTask.Spec.Branch, "kelos-42") + } +} + +func TestRunCycleWithSource_TaskTemplates_PipelineLabels(t *testing.T) { + ts := newPipelineTaskSpawner("spawner", "default", nil) + cl, key := setupTest(t, ts) + + src := &fakeSource{ + items: []source.WorkItem{ + {ID: "42", Number: 42, Title: "Fix bug"}, + }, + } + + if err := runCycleWithSource(context.Background(), cl, key, src); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var taskList kelosv1alpha1.TaskList + if err := cl.List(context.Background(), &taskList, client.InNamespace("default")); err != nil { + t.Fatalf("Listing tasks: %v", err) + } + + for _, task := range taskList.Items { + if task.Labels["kelos.dev/taskspawner"] != "spawner" { + t.Errorf("Task %s missing spawner label", task.Name) + } + if task.Labels["kelos.dev/pipeline-item"] != "42" { + t.Errorf("Task %s pipeline-item label = %q, want %q", task.Name, task.Labels["kelos.dev/pipeline-item"], "42") + } + } +} + +func TestRunCycleWithSource_TaskTemplates_MaxConcurrencyCountsPipelines(t *testing.T) { + ts := newPipelineTaskSpawner("spawner", "default", int32Ptr(1)) + + // One existing active pipeline (item 1) + existingTasks := []kelosv1alpha1.Task{ + newPipelineTask("spawner-1-plan", "default", "spawner", "1", kelosv1alpha1.TaskPhaseSucceeded), + newPipelineTask("spawner-1-implement", "default", "spawner", "1", kelosv1alpha1.TaskPhaseRunning), + newPipelineTask("spawner-1-open-pr", "default", "spawner", "1", kelosv1alpha1.TaskPhasePending), + } + cl, key := setupTest(t, ts, existingTasks...) + + src := &fakeSource{ + items: []source.WorkItem{ + {ID: "1", Number: 1, Title: "Existing"}, + {ID: "2", Number: 2, Title: "New item"}, + }, + } + + if err := runCycleWithSource(context.Background(), cl, key, src); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var taskList kelosv1alpha1.TaskList + if err := cl.List(context.Background(), &taskList, client.InNamespace("default")); err != nil { + t.Fatalf("Listing tasks: %v", err) + } + + // Should still have only the original 3 tasks (pipeline for item 1). + // New item 2 should be blocked by maxConcurrency=1 (item 1 has active tasks). + if len(taskList.Items) != 3 { + t.Errorf("Expected 3 tasks (maxConcurrency=1 blocks new pipeline), got %d", len(taskList.Items)) + } +} + +func TestRunCycleWithSource_TaskTemplates_MaxTotalTasksBudget(t *testing.T) { + ts := newPipelineTaskSpawner("spawner", "default", nil) + ts.Spec.MaxTotalTasks = int32Ptr(4) // budget for 4 tasks, but each pipeline is 3 steps + cl, key := setupTest(t, ts) + + src := &fakeSource{ + items: []source.WorkItem{ + {ID: "1", Number: 1, Title: "Item 1"}, + {ID: "2", Number: 2, Title: "Item 2"}, + }, + } + + if err := runCycleWithSource(context.Background(), cl, key, src); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var taskList kelosv1alpha1.TaskList + if err := cl.List(context.Background(), &taskList, client.InNamespace("default")); err != nil { + t.Fatalf("Listing tasks: %v", err) + } + + // First pipeline (3 tasks) fits within budget. Second pipeline (3 tasks) + // would exceed budget (3+3=6 > 4), so it's skipped entirely. + if len(taskList.Items) != 3 { + t.Errorf("Expected 3 tasks (one pipeline, second blocked by budget), got %d", len(taskList.Items)) + } +} + +func TestRunCycleWithSource_TaskTemplates_MultipleItems(t *testing.T) { + ts := newPipelineTaskSpawner("spawner", "default", nil) + cl, key := setupTest(t, ts) + + src := &fakeSource{ + items: []source.WorkItem{ + {ID: "1", Number: 1, Title: "Item 1"}, + {ID: "2", Number: 2, Title: "Item 2"}, + }, + } + + if err := runCycleWithSource(context.Background(), cl, key, src); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var taskList kelosv1alpha1.TaskList + if err := cl.List(context.Background(), &taskList, client.InNamespace("default")); err != nil { + t.Fatalf("Listing tasks: %v", err) + } + + // 2 items * 3 steps = 6 tasks + if len(taskList.Items) != 6 { + t.Fatalf("Expected 6 tasks (2 items * 3 steps), got %d", len(taskList.Items)) + } + + // Verify status + var updatedTS kelosv1alpha1.TaskSpawner + if err := cl.Get(context.Background(), key, &updatedTS); err != nil { + t.Fatalf("Getting TaskSpawner: %v", err) + } + if updatedTS.Status.TotalTasksCreated != 6 { + t.Errorf("TotalTasksCreated = %d, want 6", updatedTS.Status.TotalTasksCreated) + } + if updatedTS.Status.TotalPipelinesCreated != 2 { + t.Errorf("TotalPipelinesCreated = %d, want 2", updatedTS.Status.TotalPipelinesCreated) + } +} + +func TestRunCycleWithSource_TaskTemplates_PartialPipelineRecovery(t *testing.T) { + ts := newPipelineTaskSpawner("spawner", "default", nil) + + // Only the first step exists (simulating partial creation) + existingTasks := []kelosv1alpha1.Task{ + newPipelineTask("spawner-42-plan", "default", "spawner", "42", kelosv1alpha1.TaskPhasePending), + } + cl, key := setupTest(t, ts, existingTasks...) + + src := &fakeSource{ + items: []source.WorkItem{ + {ID: "42", Number: 42, Title: "Partial pipeline"}, + {ID: "99", Number: 99, Title: "New item"}, + }, + } + + if err := runCycleWithSource(context.Background(), cl, key, src); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + var taskList kelosv1alpha1.TaskList + if err := cl.List(context.Background(), &taskList, client.InNamespace("default")); err != nil { + t.Fatalf("Listing tasks: %v", err) + } + + // Item 42: plan exists, implement+open-pr should be created (partial recovery). + // Item 99: all 3 steps should be created (new item). + // Total: 1 (existing plan) + 2 (recovered steps) + 3 (new item 99) = 6 tasks. + expected := map[string]bool{ + "spawner-42-plan": true, + "spawner-42-implement": true, + "spawner-42-open-pr": true, + "spawner-99-plan": true, + "spawner-99-implement": true, + "spawner-99-open-pr": true, + } + if len(taskList.Items) != len(expected) { + t.Fatalf("Expected %d tasks, got %d", len(expected), len(taskList.Items)) + } + for _, task := range taskList.Items { + if !expected[task.Name] { + t.Errorf("Unexpected task created: %s", task.Name) + } + delete(expected, task.Name) + } + for name := range expected { + t.Errorf("Expected task %q to be present", name) + } +} diff --git a/examples/09-taskspawner-pipeline/README.md b/examples/09-taskspawner-pipeline/README.md new file mode 100644 index 00000000..2b9a11ef --- /dev/null +++ b/examples/09-taskspawner-pipeline/README.md @@ -0,0 +1,35 @@ +# TaskSpawner Pipeline + +This example demonstrates using `taskTemplates` (plural) to spawn a multi-step +pipeline of Tasks for each discovered work item. + +## How it works + +When a GitHub issue with the `agent-pipeline` label is discovered, the spawner +creates three Tasks per issue: + +1. **plan** - Analyzes the issue and produces an implementation plan +2. **implement** - Implements the plan on a dedicated branch (depends on `plan`) +3. **open-pr** - Opens a pull request for the implementation (depends on `implement`) + +Each step's `dependsOn` field references sibling step names. The spawner +translates these to fully-qualified task names at creation time: + +``` +dependsOn: [plan] -> dependsOn: [issue-pipeline-42-plan] +``` + +Steps execute in dependency order. Results from earlier steps are available +via the `{{.Deps}}` template variable. + +## Key concepts + +- **`maxConcurrency`** counts pipeline instances (not individual tasks) +- **`maxTotalTasks`** counts individual tasks created across all pipelines +- Each step independently configures `type`, `model`, `credentials`, `branch`, etc. +- DAG dependencies are supported (a step can depend on multiple predecessors) + +## Prerequisites + +- A `Workspace` named `my-workspace` with a GitHub repository +- A `Secret` named `claude-credentials` with OAuth credentials diff --git a/examples/09-taskspawner-pipeline/taskspawner-pipeline.yaml b/examples/09-taskspawner-pipeline/taskspawner-pipeline.yaml new file mode 100644 index 00000000..acdb8a0d --- /dev/null +++ b/examples/09-taskspawner-pipeline/taskspawner-pipeline.yaml @@ -0,0 +1,77 @@ +# TaskSpawner Pipeline Example +# +# This example demonstrates using taskTemplates (plural) to create a +# multi-step pipeline for each discovered work item. Each GitHub issue +# matching the configured labels triggers a 3-step pipeline: +# plan -> implement -> open-pr +# +# The spawner translates dependsOn step names to fully-qualified task names: +# dependsOn: [plan] -> dependsOn: [issue-pipeline-42-plan] +apiVersion: kelos.dev/v1alpha1 +kind: TaskSpawner +metadata: + name: issue-pipeline +spec: + when: + githubIssues: + labels: + - agent-pipeline + maxConcurrency: 2 + taskTemplates: + - name: plan + type: claude-code + model: claude-sonnet-4-6 + credentials: + type: oauth + secretRef: + name: claude-credentials + workspaceRef: + name: my-workspace + promptTemplate: | + You are a technical planner. Analyze this issue and create + a detailed implementation plan. + + Issue #{{.Number}}: {{.Title}} + {{.Body}} + + Output a structured plan with clear steps. Do NOT write code. + + - name: implement + type: claude-code + model: claude-sonnet-4-6 + dependsOn: + - plan + credentials: + type: oauth + secretRef: + name: claude-credentials + workspaceRef: + name: my-workspace + branch: "kelos-{{.Number}}" + promptTemplate: | + Implement the plan from the planning step. + + Plan output: + {{index .Deps "plan" "Outputs"}} + + Issue #{{.Number}}: {{.Title}} + + Create a branch, write code, run tests, commit, and push. + + - name: open-pr + type: claude-code + dependsOn: + - implement + credentials: + type: oauth + secretRef: + name: claude-credentials + workspaceRef: + name: my-workspace + branch: "kelos-{{.Number}}" + promptTemplate: | + Open a pull request for the implementation on branch + {{index .Deps "implement" "Results" "branch"}}. + + Reference issue #{{.Number}} in the PR description. + Use `gh pr create` to open the PR. diff --git a/install-crd.yaml b/install-crd.yaml index 9cff7acd..61ea3b6f 100644 --- a/install-crd.yaml +++ b/install-crd.yaml @@ -764,7 +764,9 @@ spec: Defaults to false. type: boolean taskTemplate: - description: TaskTemplate defines the template for spawned Tasks. + description: |- + TaskTemplate defines the template for spawned Tasks. + Exactly one of taskTemplate or taskTemplates must be set. properties: agentConfigRef: description: |- @@ -815,6 +817,7 @@ spec: on. items: type: string + maxItems: 10 type: array image: description: |- @@ -1119,6 +1122,387 @@ spec: - credentials - type type: object + taskTemplates: + description: |- + TaskTemplates defines a pipeline of named task templates. + When set, each discovered work item spawns one Task per step, + with DependsOn references translated to fully-qualified task names. + Exactly one of taskTemplate or taskTemplates must be set. + items: + description: |- + NamedTaskTemplate extends TaskTemplate with a pipeline step name. + When used in taskTemplates, the DependsOn field references sibling step + names within the same pipeline (the spawner translates them to + fully-qualified task names at creation time). + properties: + agentConfigRef: + description: |- + AgentConfigRef references an AgentConfig resource. + When set, spawned Tasks inherit this agent config reference. + properties: + name: + description: Name is the name of the AgentConfig resource. + type: string + required: + - name + type: object + branch: + description: |- + Branch is the git branch spawned Tasks should work on. + Supports Go text/template variables from the work item, e.g. "kelos-task-{{.Number}}". + Available variables (all sources): {{.ID}}, {{.Title}}, {{.Kind}} + GitHub issue/Jira sources: {{.Number}}, {{.Body}}, {{.URL}}, {{.Labels}}, {{.Comments}} + GitHub pull request sources additionally expose: {{.Branch}}, {{.ReviewState}}, {{.ReviewComments}} + Cron sources: {{.Time}}, {{.Schedule}} + type: string + credentials: + description: Credentials specifies how to authenticate with + the agent. + properties: + secretRef: + description: SecretRef references the Secret containing + credentials. + properties: + name: + description: Name is the name of the secret. + type: string + required: + - name + type: object + type: + description: Type specifies the credential type (api-key + or oauth). + enum: + - api-key + - oauth + type: string + required: + - secretRef + - type + type: object + dependsOn: + description: DependsOn lists Task names that spawned Tasks depend + on. + items: + type: string + maxItems: 10 + type: array + image: + description: |- + Image optionally overrides the default agent container image. + Custom images must implement the agent image interface + (see docs/agent-image-interface.md). + type: string + model: + description: Model optionally overrides the default model. + type: string + name: + description: |- + Name is the step name within the pipeline, used for task name suffix + and as a reference target for other steps' dependsOn. + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + podOverrides: + description: PodOverrides allows customizing the agent pod configuration + for spawned Tasks. + properties: + activeDeadlineSeconds: + description: |- + ActiveDeadlineSeconds specifies the maximum duration in seconds + that the agent pod can run before being terminated. + This is set on the Job's activeDeadlineSeconds field. + format: int64 + minimum: 1 + type: integer + env: + description: |- + Env specifies additional environment variables for the agent container. + These are appended after the built-in env vars (credentials, model, GitHub token). + If a user-specified env var conflicts with a built-in one, the built-in takes precedence. + items: + description: EnvVar represents an environment variable + present in a Container. + properties: + name: + description: |- + Name of the environment variable. + May consist of any printable ASCII characters except '='. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any service environment variables. If a variable cannot be resolved, + the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + Escaped references will never be expanded, regardless of whether the variable + exists or not. + Defaults to "". + type: string + valueFrom: + description: Source for the environment variable's + value. Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in + the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + fileKeyRef: + description: |- + FileKeyRef selects a key of the env file. + Requires the EnvFiles feature gate to be enabled. + properties: + key: + description: |- + The key within the env file. An invalid key will prevent the pod from starting. + The keys defined within a source may consist of any printable ASCII characters except '='. + During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters. + type: string + optional: + default: false + description: |- + Specify whether the file or its key must be defined. If the file or key + does not exist, then the env var is not published. + If optional is set to true and the specified key does not exist, + the environment variable will not be set in the Pod's containers. + + If optional is set to false and the specified key does not exist, + an error will be returned during Pod creation. + type: boolean + path: + description: |- + The path within the volume from which to select the file. + Must be relative and may not contain the '..' path or start with '..'. + type: string + volumeName: + description: The name of the volume mount + containing the env file. + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + properties: + containerName: + description: 'Container name: required for + volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of + the exposed resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the + pod's namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + nodeSelector: + additionalProperties: + type: string + description: NodeSelector constrains agent pods to nodes + matching the given labels. + type: object + resources: + description: Resources defines resource limits and requests + for the agent container. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in + PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + type: object + promptTemplate: + description: |- + PromptTemplate is a Go text/template for rendering the task prompt. + Available variables (all sources): {{.ID}}, {{.Title}}, {{.Kind}} + GitHub issue/Jira sources: {{.Number}}, {{.Body}}, {{.URL}}, {{.Labels}}, {{.Comments}} + GitHub pull request sources additionally expose: {{.Branch}}, {{.ReviewState}}, {{.ReviewComments}} + Cron sources: {{.Time}}, {{.Schedule}} + type: string + ttlSecondsAfterFinished: + description: |- + TTLSecondsAfterFinished limits the lifetime of a Task that has finished + execution (either Succeeded or Failed). If set, spawned Tasks will be + automatically deleted after the given number of seconds once they reach + a terminal phase, allowing TaskSpawner to create a new Task. + If this field is unset, spawned Tasks will not be automatically deleted. + If this field is set to zero, spawned Tasks will be eligible to be deleted + immediately after they finish. + format: int32 + minimum: 0 + type: integer + type: + description: Type specifies the agent type (e.g., claude-code). + enum: + - claude-code + - codex + - gemini + - opencode + - cursor + type: string + upstreamRepo: + description: |- + UpstreamRepo is the upstream repository in "owner/repo" format. + When set, spawned Tasks inherit this value and inject + KELOS_UPSTREAM_REPO into the agent container. This is typically + derived automatically from githubIssues.repo or + githubPullRequests.repo by the spawner, but can be set explicitly. + type: string + workspaceRef: + description: |- + WorkspaceRef references the Workspace that defines the repository. + Required when using githubIssues or githubPullRequests source; optional + for other sources. + When set, spawned Tasks inherit this workspace reference. + properties: + name: + description: Name is the name of the Workspace resource. + type: string + required: + - name + type: object + required: + - credentials + - name + - type + type: object + maxItems: 10 + minItems: 1 + type: array when: description: When defines the conditions that trigger task spawning. properties: @@ -1466,14 +1850,25 @@ spec: type: object type: object required: - - taskTemplate - when type: object x-kubernetes-validations: - - message: taskTemplate.workspaceRef is required when using githubIssues - or githubPullRequests source + - message: exactly one of taskTemplate or taskTemplates must be set + rule: has(self.taskTemplate) != has(self.taskTemplates) + - message: workspaceRef is required when using githubIssues or githubPullRequests + source rule: '!(has(self.when.githubIssues) || has(self.when.githubPullRequests)) - || has(self.taskTemplate.workspaceRef)' + || (has(self.taskTemplate) && has(self.taskTemplate.workspaceRef)) + || (has(self.taskTemplates) && self.taskTemplates.exists(t, has(t.workspaceRef)))' + - message: taskTemplates step names must be unique + rule: '!has(self.taskTemplates) || self.taskTemplates.all(t, self.taskTemplates.exists_one(s, + s.name == t.name))' + - message: taskTemplates dependsOn must reference existing step names + rule: '!has(self.taskTemplates) || self.taskTemplates.all(t, !has(t.dependsOn) + || t.dependsOn.all(d, self.taskTemplates.exists(s, s.name == d)))' + - message: taskTemplates steps must not depend on themselves + rule: '!has(self.taskTemplates) || self.taskTemplates.all(t, !has(t.dependsOn) + || !t.dependsOn.exists(d, d == t.name))' status: description: TaskSpawnerStatus defines the observed state of TaskSpawner. properties: @@ -1565,6 +1960,12 @@ spec: totalDiscovered: description: TotalDiscovered is the total number of work items discovered. type: integer + totalPipelinesCreated: + description: |- + TotalPipelinesCreated is the total number of pipeline instances created + (one per work item when using taskTemplates). Only incremented in + pipeline mode. + type: integer totalTasksCreated: description: TotalTasksCreated is the total number of Tasks created. type: integer diff --git a/internal/cli/printer.go b/internal/cli/printer.go index e20c90fd..ee7e369f 100644 --- a/internal/cli/printer.go +++ b/internal/cli/printer.go @@ -143,14 +143,14 @@ func printTaskSpawnerTable(w io.Writer, spawners []kelosv1alpha1.TaskSpawner, al age := duration.HumanDuration(time.Since(s.CreationTimestamp.Time)) source := "" if s.Spec.When.GitHubIssues != nil { - if s.Spec.TaskTemplate.WorkspaceRef != nil { - source = s.Spec.TaskTemplate.WorkspaceRef.Name + if wsRef := taskSpawnerWorkspaceRef(&s); wsRef != nil { + source = wsRef.Name } else { source = "GitHub Issues" } } else if s.Spec.When.GitHubPullRequests != nil { - if s.Spec.TaskTemplate.WorkspaceRef != nil { - source = s.Spec.TaskTemplate.WorkspaceRef.Name + if wsRef := taskSpawnerWorkspaceRef(&s); wsRef != nil { + source = wsRef.Name } else { source = "GitHub Pull Requests" } @@ -176,8 +176,8 @@ func printTaskSpawnerDetail(w io.Writer, ts *kelosv1alpha1.TaskSpawner) { printField(w, "Name", ts.Name) printField(w, "Namespace", ts.Namespace) printField(w, "Phase", string(ts.Status.Phase)) - if ts.Spec.TaskTemplate.WorkspaceRef != nil { - printField(w, "Workspace", ts.Spec.TaskTemplate.WorkspaceRef.Name) + if wsRef := taskSpawnerWorkspaceRef(ts); wsRef != nil { + printField(w, "Workspace", wsRef.Name) } if ts.Spec.When.GitHubIssues != nil { gh := ts.Spec.When.GitHubIssues @@ -214,9 +214,17 @@ func printTaskSpawnerDetail(w io.Writer, ts *kelosv1alpha1.TaskSpawner) { printField(w, "Source", "Cron") printField(w, "Schedule", ts.Spec.When.Cron.Schedule) } - printField(w, "Task Type", ts.Spec.TaskTemplate.Type) - if ts.Spec.TaskTemplate.Model != "" { - printField(w, "Model", ts.Spec.TaskTemplate.Model) + if ts.Spec.TaskTemplate != nil { + printField(w, "Task Type", ts.Spec.TaskTemplate.Type) + if ts.Spec.TaskTemplate.Model != "" { + printField(w, "Model", ts.Spec.TaskTemplate.Model) + } + } else if len(ts.Spec.TaskTemplates) > 0 { + stepNames := make([]string, len(ts.Spec.TaskTemplates)) + for i, t := range ts.Spec.TaskTemplates { + stepNames[i] = t.Name + } + printField(w, "Pipeline Steps", fmt.Sprintf("%v", stepNames)) } printField(w, "Poll Interval", ts.Spec.PollInterval) if ts.Status.DeploymentName != "" { @@ -368,3 +376,18 @@ func printJSON(w io.Writer, obj interface{}) error { _, err = fmt.Fprintln(w, string(data)) return err } + +// taskSpawnerWorkspaceRef returns the first WorkspaceReference found in the +// TaskSpawner spec — either from taskTemplate or the first taskTemplates +// entry that has one. +func taskSpawnerWorkspaceRef(ts *kelosv1alpha1.TaskSpawner) *kelosv1alpha1.WorkspaceReference { + if ts.Spec.TaskTemplate != nil && ts.Spec.TaskTemplate.WorkspaceRef != nil { + return ts.Spec.TaskTemplate.WorkspaceRef + } + for i := range ts.Spec.TaskTemplates { + if ts.Spec.TaskTemplates[i].WorkspaceRef != nil { + return ts.Spec.TaskTemplates[i].WorkspaceRef + } + } + return nil +} diff --git a/internal/cli/printer_test.go b/internal/cli/printer_test.go index 80330601..37de4aa7 100644 --- a/internal/cli/printer_test.go +++ b/internal/cli/printer_test.go @@ -260,7 +260,7 @@ func TestPrintTaskSpawnerTableAllNamespaces(t *testing.T) { When: kelosv1alpha1.When{ GitHubIssues: &kelosv1alpha1.GitHubIssues{}, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ WorkspaceRef: &kelosv1alpha1.WorkspaceReference{ Name: "my-ws", }, @@ -298,7 +298,7 @@ func TestPrintTaskSpawnerTableGitHubPullRequests(t *testing.T) { When: kelosv1alpha1.When{ GitHubPullRequests: &kelosv1alpha1.GitHubPullRequests{}, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ WorkspaceRef: &kelosv1alpha1.WorkspaceReference{ Name: "my-ws", }, @@ -393,7 +393,7 @@ func TestPrintTaskSpawnerDetailGitHubPullRequests(t *testing.T) { ReviewState: "changes_requested", }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", WorkspaceRef: &kelosv1alpha1.WorkspaceReference{ Name: "my-ws", @@ -441,7 +441,7 @@ func TestPrintTaskSpawnerDetailJira(t *testing.T) { }, }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", }, PollInterval: "10m", @@ -617,7 +617,7 @@ func TestPrintTaskSpawnerDetail(t *testing.T) { Schedule: "0 * * * *", }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Model: "claude-sonnet-4-20250514", }, diff --git a/internal/controller/taskspawner_controller.go b/internal/controller/taskspawner_controller.go index 7d051639..b2518e3a 100644 --- a/internal/controller/taskspawner_controller.go +++ b/internal/controller/taskspawner_controller.go @@ -123,8 +123,8 @@ func (r *TaskSpawnerReconciler) reconcileDeployment(ctx context.Context, req ctr // Resolve workspace if workspaceRef is set in taskTemplate var workspace *kelosv1alpha1.WorkspaceSpec var isGitHubApp bool - if ts.Spec.TaskTemplate.WorkspaceRef != nil { - workspaceRefName := ts.Spec.TaskTemplate.WorkspaceRef.Name + if wsRef := taskSpawnerWorkspaceRef(ts); wsRef != nil { + workspaceRefName := wsRef.Name var ws kelosv1alpha1.Workspace if err := r.Get(ctx, client.ObjectKey{ Namespace: ts.Namespace, @@ -236,8 +236,8 @@ func (r *TaskSpawnerReconciler) reconcileCronJob(ctx context.Context, req ctrl.R // Resolve workspace if workspaceRef is set in taskTemplate var workspace *kelosv1alpha1.WorkspaceSpec var isGitHubApp bool - if ts.Spec.TaskTemplate.WorkspaceRef != nil { - workspaceRefName := ts.Spec.TaskTemplate.WorkspaceRef.Name + if wsRef := taskSpawnerWorkspaceRef(ts); wsRef != nil { + workspaceRefName := wsRef.Name var ws kelosv1alpha1.Workspace if err := r.Get(ctx, client.ObjectKey{ Namespace: ts.Namespace, @@ -726,7 +726,7 @@ func (r *TaskSpawnerReconciler) findTaskSpawnersForSecret(ctx context.Context, o var requests []reconcile.Request for _, ts := range tsList.Items { - if ts.Spec.TaskTemplate.WorkspaceRef != nil && wsNameSet[ts.Spec.TaskTemplate.WorkspaceRef.Name] { + if wsRef := taskSpawnerWorkspaceRef(&ts); wsRef != nil && wsNameSet[wsRef.Name] { requests = append(requests, reconcile.Request{ NamespacedName: types.NamespacedName{ Namespace: ts.Namespace, @@ -753,7 +753,7 @@ func (r *TaskSpawnerReconciler) findTaskSpawnersForWorkspace(ctx context.Context var requests []reconcile.Request for _, ts := range tsList.Items { - if ts.Spec.TaskTemplate.WorkspaceRef != nil && ts.Spec.TaskTemplate.WorkspaceRef.Name == ws.Name { + if wsRef := taskSpawnerWorkspaceRef(&ts); wsRef != nil && wsRef.Name == ws.Name { requests = append(requests, reconcile.Request{ NamespacedName: types.NamespacedName{ Namespace: ts.Namespace, @@ -764,3 +764,18 @@ func (r *TaskSpawnerReconciler) findTaskSpawnersForWorkspace(ctx context.Context } return requests } + +// taskSpawnerWorkspaceRef returns the first WorkspaceReference found in the +// TaskSpawner spec — either from taskTemplate or the first taskTemplates +// entry that has one. +func taskSpawnerWorkspaceRef(ts *kelosv1alpha1.TaskSpawner) *kelosv1alpha1.WorkspaceReference { + if ts.Spec.TaskTemplate != nil && ts.Spec.TaskTemplate.WorkspaceRef != nil { + return ts.Spec.TaskTemplate.WorkspaceRef + } + for i := range ts.Spec.TaskTemplates { + if ts.Spec.TaskTemplates[i].WorkspaceRef != nil { + return ts.Spec.TaskTemplates[i].WorkspaceRef + } + } + return nil +} diff --git a/internal/controller/taskspawner_deployment_builder_test.go b/internal/controller/taskspawner_deployment_builder_test.go index 2fc0dce7..79949fe5 100644 --- a/internal/controller/taskspawner_deployment_builder_test.go +++ b/internal/controller/taskspawner_deployment_builder_test.go @@ -267,7 +267,7 @@ func TestDeploymentBuilder_GitHubApp(t *testing.T) { When: kelosv1alpha1.When{ GitHubIssues: &kelosv1alpha1.GitHubIssues{}, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", WorkspaceRef: &kelosv1alpha1.WorkspaceReference{Name: "ws"}, }, @@ -347,7 +347,7 @@ func TestDeploymentBuilder_GitHubAppEnterprise(t *testing.T) { When: kelosv1alpha1.When{ GitHubIssues: &kelosv1alpha1.GitHubIssues{}, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", WorkspaceRef: &kelosv1alpha1.WorkspaceReference{Name: "ws"}, }, @@ -389,7 +389,7 @@ func TestDeploymentBuilder_GitHubAppGitHubCom(t *testing.T) { When: kelosv1alpha1.When{ GitHubIssues: &kelosv1alpha1.GitHubIssues{}, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", WorkspaceRef: &kelosv1alpha1.WorkspaceReference{Name: "ws"}, }, @@ -428,7 +428,7 @@ func TestDeploymentBuilder_PAT(t *testing.T) { When: kelosv1alpha1.When{ GitHubIssues: &kelosv1alpha1.GitHubIssues{}, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", WorkspaceRef: &kelosv1alpha1.WorkspaceReference{Name: "ws"}, }, @@ -481,7 +481,7 @@ func TestDeploymentBuilder_Jira(t *testing.T) { SecretRef: kelosv1alpha1.SecretReference{Name: "jira-creds"}, }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", }, }, @@ -827,7 +827,7 @@ func TestDeploymentBuilder_JiraNoJQL(t *testing.T) { SecretRef: kelosv1alpha1.SecretReference{Name: "jira-creds"}, }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", }, }, @@ -856,7 +856,7 @@ func TestUpdateDeployment_SuspendScalesDown(t *testing.T) { When: kelosv1alpha1.When{ GitHubIssues: &kelosv1alpha1.GitHubIssues{}, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", WorkspaceRef: &kelosv1alpha1.WorkspaceReference{Name: "ws"}, }, @@ -915,7 +915,7 @@ func TestUpdateDeployment_ResumeScalesUp(t *testing.T) { When: kelosv1alpha1.When{ GitHubIssues: &kelosv1alpha1.GitHubIssues{}, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", WorkspaceRef: &kelosv1alpha1.WorkspaceReference{Name: "ws"}, }, @@ -973,7 +973,7 @@ func TestUpdateDeployment_NoUpdateWhenReplicasMatch(t *testing.T) { When: kelosv1alpha1.When{ GitHubIssues: &kelosv1alpha1.GitHubIssues{}, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", WorkspaceRef: &kelosv1alpha1.WorkspaceReference{Name: "ws"}, }, @@ -1027,7 +1027,7 @@ func TestUpdateDeployment_PATToGitHubApp(t *testing.T) { When: kelosv1alpha1.When{ GitHubIssues: &kelosv1alpha1.GitHubIssues{}, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", WorkspaceRef: &kelosv1alpha1.WorkspaceReference{Name: "ws"}, }, @@ -1124,7 +1124,7 @@ func TestUpdateDeployment_GitHubAppToPAT(t *testing.T) { When: kelosv1alpha1.When{ GitHubIssues: &kelosv1alpha1.GitHubIssues{}, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", WorkspaceRef: &kelosv1alpha1.WorkspaceReference{Name: "ws"}, }, @@ -1255,7 +1255,7 @@ func TestFindTaskSpawnersForSecret(t *testing.T) { When: kelosv1alpha1.When{ GitHubIssues: &kelosv1alpha1.GitHubIssues{}, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", WorkspaceRef: &kelosv1alpha1.WorkspaceReference{Name: "ws"}, }, @@ -1271,7 +1271,7 @@ func TestFindTaskSpawnersForSecret(t *testing.T) { When: kelosv1alpha1.When{ GitHubIssues: &kelosv1alpha1.GitHubIssues{}, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", WorkspaceRef: &kelosv1alpha1.WorkspaceReference{Name: "ws-other"}, }, @@ -1291,7 +1291,7 @@ func TestFindTaskSpawnersForSecret(t *testing.T) { SecretRef: kelosv1alpha1.SecretReference{Name: "jira-creds"}, }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", }, }, @@ -1344,7 +1344,7 @@ func TestFindTaskSpawnersForWorkspace(t *testing.T) { When: kelosv1alpha1.When{ GitHubIssues: &kelosv1alpha1.GitHubIssues{}, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", WorkspaceRef: &kelosv1alpha1.WorkspaceReference{Name: "ws"}, }, @@ -1360,7 +1360,7 @@ func TestFindTaskSpawnersForWorkspace(t *testing.T) { When: kelosv1alpha1.When{ GitHubIssues: &kelosv1alpha1.GitHubIssues{}, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", WorkspaceRef: &kelosv1alpha1.WorkspaceReference{Name: "ws"}, }, @@ -1376,7 +1376,7 @@ func TestFindTaskSpawnersForWorkspace(t *testing.T) { When: kelosv1alpha1.When{ GitHubIssues: &kelosv1alpha1.GitHubIssues{}, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", WorkspaceRef: &kelosv1alpha1.WorkspaceReference{Name: "other-ws"}, }, @@ -1428,7 +1428,7 @@ func TestBuildCronJob_BasicSchedule(t *testing.T) { Schedule: "0 9 * * 1", }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, @@ -1529,7 +1529,7 @@ func TestBuildCronJob_BackoffLimit(t *testing.T) { Schedule: "*/5 * * * *", }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", }, }, @@ -1556,7 +1556,7 @@ func TestIsCronBased(t *testing.T) { Schedule: "0 9 * * 1", }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, @@ -1599,7 +1599,7 @@ func TestUpdateCronJob_ScheduleChange(t *testing.T) { Schedule: "0 10 * * 1", // Changed from 9 to 10 }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", }, }, @@ -1651,7 +1651,7 @@ func TestUpdateCronJob_SuspendToggle(t *testing.T) { Schedule: "0 9 * * 1", }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", }, Suspend: boolPtr(true), @@ -1703,7 +1703,7 @@ func TestUpdateCronJob_PodSpecChanges(t *testing.T) { Schedule: "0 9 * * 1", }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", }, }, @@ -1783,7 +1783,7 @@ func TestReconcileCronJob_DeletesStaleDeployment(t *testing.T) { Schedule: "0 9 * * 1", }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", }, }, @@ -1854,7 +1854,7 @@ func TestReconcileDeployment_DeletesStaleCronJob(t *testing.T) { When: kelosv1alpha1.When{ GitHubIssues: &kelosv1alpha1.GitHubIssues{}, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", }, }, @@ -1927,7 +1927,7 @@ func TestBuildCronJob_WithWorkspacePAT(t *testing.T) { Schedule: "0 9 * * 1", }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", WorkspaceRef: &kelosv1alpha1.WorkspaceReference{ Name: "my-workspace", @@ -1998,7 +1998,7 @@ func TestBuildCronJob_WithWorkspaceGitHubApp(t *testing.T) { Schedule: "0 9 * * 1", }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", WorkspaceRef: &kelosv1alpha1.WorkspaceReference{ Name: "my-workspace", @@ -2082,7 +2082,7 @@ func TestReconcileCronJob_ClearsStaleDeploymentName(t *testing.T) { Schedule: "0 9 * * 1", }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", }, }, @@ -2139,7 +2139,7 @@ func TestReconcileDeployment_ClearsStaleCronJobName(t *testing.T) { When: kelosv1alpha1.When{ GitHubIssues: &kelosv1alpha1.GitHubIssues{}, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", }, }, diff --git a/internal/manifests/install-crd.yaml b/internal/manifests/install-crd.yaml index 9cff7acd..61ea3b6f 100644 --- a/internal/manifests/install-crd.yaml +++ b/internal/manifests/install-crd.yaml @@ -764,7 +764,9 @@ spec: Defaults to false. type: boolean taskTemplate: - description: TaskTemplate defines the template for spawned Tasks. + description: |- + TaskTemplate defines the template for spawned Tasks. + Exactly one of taskTemplate or taskTemplates must be set. properties: agentConfigRef: description: |- @@ -815,6 +817,7 @@ spec: on. items: type: string + maxItems: 10 type: array image: description: |- @@ -1119,6 +1122,387 @@ spec: - credentials - type type: object + taskTemplates: + description: |- + TaskTemplates defines a pipeline of named task templates. + When set, each discovered work item spawns one Task per step, + with DependsOn references translated to fully-qualified task names. + Exactly one of taskTemplate or taskTemplates must be set. + items: + description: |- + NamedTaskTemplate extends TaskTemplate with a pipeline step name. + When used in taskTemplates, the DependsOn field references sibling step + names within the same pipeline (the spawner translates them to + fully-qualified task names at creation time). + properties: + agentConfigRef: + description: |- + AgentConfigRef references an AgentConfig resource. + When set, spawned Tasks inherit this agent config reference. + properties: + name: + description: Name is the name of the AgentConfig resource. + type: string + required: + - name + type: object + branch: + description: |- + Branch is the git branch spawned Tasks should work on. + Supports Go text/template variables from the work item, e.g. "kelos-task-{{.Number}}". + Available variables (all sources): {{.ID}}, {{.Title}}, {{.Kind}} + GitHub issue/Jira sources: {{.Number}}, {{.Body}}, {{.URL}}, {{.Labels}}, {{.Comments}} + GitHub pull request sources additionally expose: {{.Branch}}, {{.ReviewState}}, {{.ReviewComments}} + Cron sources: {{.Time}}, {{.Schedule}} + type: string + credentials: + description: Credentials specifies how to authenticate with + the agent. + properties: + secretRef: + description: SecretRef references the Secret containing + credentials. + properties: + name: + description: Name is the name of the secret. + type: string + required: + - name + type: object + type: + description: Type specifies the credential type (api-key + or oauth). + enum: + - api-key + - oauth + type: string + required: + - secretRef + - type + type: object + dependsOn: + description: DependsOn lists Task names that spawned Tasks depend + on. + items: + type: string + maxItems: 10 + type: array + image: + description: |- + Image optionally overrides the default agent container image. + Custom images must implement the agent image interface + (see docs/agent-image-interface.md). + type: string + model: + description: Model optionally overrides the default model. + type: string + name: + description: |- + Name is the step name within the pipeline, used for task name suffix + and as a reference target for other steps' dependsOn. + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + podOverrides: + description: PodOverrides allows customizing the agent pod configuration + for spawned Tasks. + properties: + activeDeadlineSeconds: + description: |- + ActiveDeadlineSeconds specifies the maximum duration in seconds + that the agent pod can run before being terminated. + This is set on the Job's activeDeadlineSeconds field. + format: int64 + minimum: 1 + type: integer + env: + description: |- + Env specifies additional environment variables for the agent container. + These are appended after the built-in env vars (credentials, model, GitHub token). + If a user-specified env var conflicts with a built-in one, the built-in takes precedence. + items: + description: EnvVar represents an environment variable + present in a Container. + properties: + name: + description: |- + Name of the environment variable. + May consist of any printable ASCII characters except '='. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any service environment variables. If a variable cannot be resolved, + the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + Escaped references will never be expanded, regardless of whether the variable + exists or not. + Defaults to "". + type: string + valueFrom: + description: Source for the environment variable's + value. Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in + the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + fileKeyRef: + description: |- + FileKeyRef selects a key of the env file. + Requires the EnvFiles feature gate to be enabled. + properties: + key: + description: |- + The key within the env file. An invalid key will prevent the pod from starting. + The keys defined within a source may consist of any printable ASCII characters except '='. + During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters. + type: string + optional: + default: false + description: |- + Specify whether the file or its key must be defined. If the file or key + does not exist, then the env var is not published. + If optional is set to true and the specified key does not exist, + the environment variable will not be set in the Pod's containers. + + If optional is set to false and the specified key does not exist, + an error will be returned during Pod creation. + type: boolean + path: + description: |- + The path within the volume from which to select the file. + Must be relative and may not contain the '..' path or start with '..'. + type: string + volumeName: + description: The name of the volume mount + containing the env file. + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + properties: + containerName: + description: 'Container name: required for + volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of + the exposed resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the + pod's namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + nodeSelector: + additionalProperties: + type: string + description: NodeSelector constrains agent pods to nodes + matching the given labels. + type: object + resources: + description: Resources defines resource limits and requests + for the agent container. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in + PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + type: object + promptTemplate: + description: |- + PromptTemplate is a Go text/template for rendering the task prompt. + Available variables (all sources): {{.ID}}, {{.Title}}, {{.Kind}} + GitHub issue/Jira sources: {{.Number}}, {{.Body}}, {{.URL}}, {{.Labels}}, {{.Comments}} + GitHub pull request sources additionally expose: {{.Branch}}, {{.ReviewState}}, {{.ReviewComments}} + Cron sources: {{.Time}}, {{.Schedule}} + type: string + ttlSecondsAfterFinished: + description: |- + TTLSecondsAfterFinished limits the lifetime of a Task that has finished + execution (either Succeeded or Failed). If set, spawned Tasks will be + automatically deleted after the given number of seconds once they reach + a terminal phase, allowing TaskSpawner to create a new Task. + If this field is unset, spawned Tasks will not be automatically deleted. + If this field is set to zero, spawned Tasks will be eligible to be deleted + immediately after they finish. + format: int32 + minimum: 0 + type: integer + type: + description: Type specifies the agent type (e.g., claude-code). + enum: + - claude-code + - codex + - gemini + - opencode + - cursor + type: string + upstreamRepo: + description: |- + UpstreamRepo is the upstream repository in "owner/repo" format. + When set, spawned Tasks inherit this value and inject + KELOS_UPSTREAM_REPO into the agent container. This is typically + derived automatically from githubIssues.repo or + githubPullRequests.repo by the spawner, but can be set explicitly. + type: string + workspaceRef: + description: |- + WorkspaceRef references the Workspace that defines the repository. + Required when using githubIssues or githubPullRequests source; optional + for other sources. + When set, spawned Tasks inherit this workspace reference. + properties: + name: + description: Name is the name of the Workspace resource. + type: string + required: + - name + type: object + required: + - credentials + - name + - type + type: object + maxItems: 10 + minItems: 1 + type: array when: description: When defines the conditions that trigger task spawning. properties: @@ -1466,14 +1850,25 @@ spec: type: object type: object required: - - taskTemplate - when type: object x-kubernetes-validations: - - message: taskTemplate.workspaceRef is required when using githubIssues - or githubPullRequests source + - message: exactly one of taskTemplate or taskTemplates must be set + rule: has(self.taskTemplate) != has(self.taskTemplates) + - message: workspaceRef is required when using githubIssues or githubPullRequests + source rule: '!(has(self.when.githubIssues) || has(self.when.githubPullRequests)) - || has(self.taskTemplate.workspaceRef)' + || (has(self.taskTemplate) && has(self.taskTemplate.workspaceRef)) + || (has(self.taskTemplates) && self.taskTemplates.exists(t, has(t.workspaceRef)))' + - message: taskTemplates step names must be unique + rule: '!has(self.taskTemplates) || self.taskTemplates.all(t, self.taskTemplates.exists_one(s, + s.name == t.name))' + - message: taskTemplates dependsOn must reference existing step names + rule: '!has(self.taskTemplates) || self.taskTemplates.all(t, !has(t.dependsOn) + || t.dependsOn.all(d, self.taskTemplates.exists(s, s.name == d)))' + - message: taskTemplates steps must not depend on themselves + rule: '!has(self.taskTemplates) || self.taskTemplates.all(t, !has(t.dependsOn) + || !t.dependsOn.exists(d, d == t.name))' status: description: TaskSpawnerStatus defines the observed state of TaskSpawner. properties: @@ -1565,6 +1960,12 @@ spec: totalDiscovered: description: TotalDiscovered is the total number of work items discovered. type: integer + totalPipelinesCreated: + description: |- + TotalPipelinesCreated is the total number of pipeline instances created + (one per work item when using taskTemplates). Only incremented in + pipeline mode. + type: integer totalTasksCreated: description: TotalTasksCreated is the total number of Tasks created. type: integer diff --git a/test/e2e/taskspawner_test.go b/test/e2e/taskspawner_test.go index 471b963c..91184f22 100644 --- a/test/e2e/taskspawner_test.go +++ b/test/e2e/taskspawner_test.go @@ -56,7 +56,7 @@ var _ = Describe("TaskSpawner", func() { State: "open", }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", WorkspaceRef: &kelosv1alpha1.WorkspaceReference{ Name: "e2e-spawner-workspace", @@ -105,7 +105,7 @@ var _ = Describe("TaskSpawner", func() { When: kelosv1alpha1.When{ GitHubIssues: &kelosv1alpha1.GitHubIssues{}, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", WorkspaceRef: &kelosv1alpha1.WorkspaceReference{ Name: "e2e-spawner-workspace", @@ -169,7 +169,7 @@ var _ = Describe("Cron TaskSpawner", func() { Schedule: "* * * * *", }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Model: testModel, Credentials: kelosv1alpha1.Credentials{ @@ -208,7 +208,7 @@ var _ = Describe("Cron TaskSpawner", func() { Schedule: "0 9 * * 1", }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, diff --git a/test/integration/cli_test.go b/test/integration/cli_test.go index ed610eca..68cb5056 100644 --- a/test/integration/cli_test.go +++ b/test/integration/cli_test.go @@ -285,7 +285,7 @@ var _ = Describe("CLI Delete All Commands", func() { Namespace: ns.Name, }, Spec: kelosv1alpha1.TaskSpawnerSpec{ - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, @@ -375,7 +375,7 @@ var _ = Describe("CLI Delete TaskSpawner Command", func() { Namespace: ns.Name, }, Spec: kelosv1alpha1.TaskSpawnerSpec{ - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, @@ -423,7 +423,7 @@ var _ = Describe("CLI Delete TaskSpawner Command", func() { Namespace: ns.Name, }, Spec: kelosv1alpha1.TaskSpawnerSpec{ - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, @@ -471,7 +471,7 @@ var _ = Describe("CLI Delete TaskSpawner Command", func() { Namespace: ns.Name, }, Spec: kelosv1alpha1.TaskSpawnerSpec{ - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, @@ -539,7 +539,7 @@ var _ = Describe("CLI Suspend/Resume Commands", func() { Namespace: ns.Name, }, Spec: kelosv1alpha1.TaskSpawnerSpec{ - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, @@ -585,7 +585,7 @@ var _ = Describe("CLI Suspend/Resume Commands", func() { }, Spec: kelosv1alpha1.TaskSpawnerSpec{ Suspend: &suspend, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, @@ -631,7 +631,7 @@ var _ = Describe("CLI Suspend/Resume Commands", func() { }, Spec: kelosv1alpha1.TaskSpawnerSpec{ Suspend: &suspend, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, @@ -675,7 +675,7 @@ var _ = Describe("CLI Suspend/Resume Commands", func() { Namespace: ns.Name, }, Spec: kelosv1alpha1.TaskSpawnerSpec{ - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, @@ -713,7 +713,7 @@ var _ = Describe("CLI Suspend/Resume Commands", func() { Namespace: ns.Name, }, Spec: kelosv1alpha1.TaskSpawnerSpec{ - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, @@ -766,7 +766,7 @@ var _ = Describe("CLI Suspend/Resume Commands", func() { Namespace: ns.Name, }, Spec: kelosv1alpha1.TaskSpawnerSpec{ - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, diff --git a/test/integration/completion_test.go b/test/integration/completion_test.go index 99e295a8..e2ee0ef8 100644 --- a/test/integration/completion_test.go +++ b/test/integration/completion_test.go @@ -102,7 +102,7 @@ var _ = Describe("Completion", func() { Namespace: ns.Name, }, Spec: kelosv1alpha1.TaskSpawnerSpec{ - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeAPIKey, diff --git a/test/integration/taskspawner_test.go b/test/integration/taskspawner_test.go index 7489aa85..add2dd10 100644 --- a/test/integration/taskspawner_test.go +++ b/test/integration/taskspawner_test.go @@ -62,7 +62,7 @@ var _ = Describe("TaskSpawner Controller", func() { State: "open", }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, @@ -195,7 +195,7 @@ var _ = Describe("TaskSpawner Controller", func() { When: kelosv1alpha1.When{ GitHubIssues: &kelosv1alpha1.GitHubIssues{}, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, @@ -261,7 +261,7 @@ var _ = Describe("TaskSpawner Controller", func() { When: kelosv1alpha1.When{ GitHubIssues: &kelosv1alpha1.GitHubIssues{}, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, @@ -331,7 +331,7 @@ var _ = Describe("TaskSpawner Controller", func() { When: kelosv1alpha1.When{ GitHubIssues: &kelosv1alpha1.GitHubIssues{}, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, @@ -424,7 +424,7 @@ var _ = Describe("TaskSpawner Controller", func() { State: "open", }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, @@ -481,7 +481,7 @@ var _ = Describe("TaskSpawner Controller", func() { When: kelosv1alpha1.When{ GitHubIssues: &kelosv1alpha1.GitHubIssues{}, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, @@ -574,7 +574,7 @@ var _ = Describe("TaskSpawner Controller", func() { State: "open", }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, @@ -654,7 +654,7 @@ var _ = Describe("TaskSpawner Controller", func() { Schedule: "0 9 * * 1", }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, @@ -797,7 +797,7 @@ var _ = Describe("TaskSpawner Controller", func() { State: "open", }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, @@ -920,7 +920,7 @@ var _ = Describe("TaskSpawner Controller", func() { State: "open", }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, @@ -1001,7 +1001,7 @@ var _ = Describe("TaskSpawner Controller", func() { State: "open", }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, @@ -1111,7 +1111,7 @@ var _ = Describe("TaskSpawner Controller", func() { State: "open", }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, @@ -1184,7 +1184,7 @@ var _ = Describe("TaskSpawner Controller", func() { State: "open", }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, @@ -1305,7 +1305,7 @@ var _ = Describe("TaskSpawner Controller", func() { State: "open", }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, @@ -1384,7 +1384,7 @@ var _ = Describe("TaskSpawner Controller", func() { State: "open", }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, @@ -1461,7 +1461,7 @@ var _ = Describe("TaskSpawner Controller", func() { State: "open", }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, @@ -1543,7 +1543,7 @@ var _ = Describe("TaskSpawner Controller", func() { When: kelosv1alpha1.When{ GitHubIssues: &kelosv1alpha1.GitHubIssues{}, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, @@ -1681,7 +1681,7 @@ var _ = Describe("TaskSpawner Controller", func() { When: kelosv1alpha1.When{ GitHubIssues: &kelosv1alpha1.GitHubIssues{}, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, @@ -1827,7 +1827,7 @@ var _ = Describe("TaskSpawner Controller", func() { When: kelosv1alpha1.When{ GitHubIssues: &kelosv1alpha1.GitHubIssues{}, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, @@ -1917,7 +1917,7 @@ var _ = Describe("TaskSpawner Controller", func() { ExcludeComments: []string{"/kelos needs-input", "/kelos pause"}, }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, @@ -1992,7 +1992,7 @@ var _ = Describe("TaskSpawner Controller", func() { TriggerComment: "/kelos pick-up", }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, @@ -2063,7 +2063,7 @@ var _ = Describe("TaskSpawner Controller", func() { ExcludeComments: []string{"/kelos needs-input"}, }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, @@ -2140,7 +2140,7 @@ var _ = Describe("TaskSpawner Controller", func() { }, }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, @@ -2218,7 +2218,7 @@ var _ = Describe("TaskSpawner Controller", func() { }, }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, @@ -2275,7 +2275,7 @@ var _ = Describe("TaskSpawner Controller", func() { }, }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, @@ -2337,7 +2337,7 @@ var _ = Describe("TaskSpawner Controller", func() { Draft: &draft, }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth, @@ -2414,7 +2414,7 @@ var _ = Describe("TaskSpawner Controller", func() { PollInterval: "30s", }, }, - TaskTemplate: kelosv1alpha1.TaskTemplate{ + TaskTemplate: &kelosv1alpha1.TaskTemplate{ Type: "claude-code", Credentials: kelosv1alpha1.Credentials{ Type: kelosv1alpha1.CredentialTypeOAuth,