diff --git a/go.mod b/go.mod index cfa2579..f6a6d0c 100644 --- a/go.mod +++ b/go.mod @@ -38,4 +38,5 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect google.golang.org/grpc v1.82.1 // indirect google.golang.org/protobuf v1.36.12 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 93c8871..b604d06 100644 --- a/go.sum +++ b/go.sum @@ -130,3 +130,6 @@ google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/templates/workflow_template.go b/internal/templates/workflow_template.go new file mode 100644 index 0000000..4ef82b8 --- /dev/null +++ b/internal/templates/workflow_template.go @@ -0,0 +1,315 @@ +package templates + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "gopkg.in/yaml.v3" +) + +// WorkflowTemplate defines the structure of a workflow template +type WorkflowTemplate struct { + // Metadata + Name string `yaml:"name" json:"name"` + Version string `yaml:"version" json:"version"` + Description string `yaml:"description" json:"description"` + Author string `yaml:"author" json:"author"` + CreatedAt time.Time `yaml:"created_at" json:"created_at"` + UpdatedAt time.Time `yaml:"updated_at" json:"updated_at"` + + // Orchestrator Configuration + Orchestrator OrchestratorConfig `yaml:"orchestrator" json:"orchestrator"` + + // Task Configuration + Tasks []TaskConfig `yaml:"tasks" json:"tasks"` + + // Variable Defaults + Defaults map[string]interface{} `yaml:"defaults" json:"defaults,omitempty"` + + // Tags and Metadata + Tags map[string]string `yaml:"tags" json:"tags,omitempty"` +} + +// OrchestratorConfig defines orchestrator settings +type OrchestratorConfig struct { + PlannerURL string `yaml:"planner_url" json:"planner_url"` + JudgeURL string `yaml:"judge_url" json:"judge_url"` + ImplementerURL string `yaml:"implementer_url" json:"implementer_url"` + TimeoutSeconds int `yaml:"timeout_seconds" json:"timeout_seconds"` + RetryPolicy string `yaml:"retry_policy" json:"retry_policy"` + MaxConcurrency int `yaml:"max_concurrency" json:"max_concurrency"` + Variables map[string]interface{} `yaml:"variables" json:"variables,omitempty"` +} + +// TaskConfig defines task template +type TaskConfig struct { + ID string `yaml:"id" json:"id"` + Title string `yaml:"title" json:"title"` + Description string `yaml:"description" json:"description"` + Type string `yaml:"type" json:"type"` // feature, bugfix, refactor, etc. + Priority string `yaml:"priority" json:"priority"` // high, medium, low + DependsOn []string `yaml:"depends_on" json:"depends_on,omitempty"` + Config map[string]interface{} `yaml:"config" json:"config,omitempty"` +} + +// WorkflowTemplateManager manages workflow templates +type WorkflowTemplateManager struct { + mu sync.RWMutex + templates map[string]*WorkflowTemplate + templatePath string + loadedFrom map[string]string // template name -> file path + lastModified map[string]time.Time + stats *TemplateStats +} + +// TemplateStats tracks template usage +type TemplateStats struct { + TotalTemplates int + LoadedTemplates int + ExecutedTemplates int + LastUsed map[string]time.Time +} + +// NewWorkflowTemplateManager creates a new template manager +func NewWorkflowTemplateManager(templatePath string) *WorkflowTemplateManager { + return &WorkflowTemplateManager{ + templates: make(map[string]*WorkflowTemplate), + templatePath: templatePath, + loadedFrom: make(map[string]string), + lastModified: make(map[string]time.Time), + stats: &TemplateStats{ + LastUsed: make(map[string]time.Time), + }, + } +} + +// SaveTemplate saves a template to file +func (wtm *WorkflowTemplateManager) SaveTemplate(template *WorkflowTemplate) error { + if template == nil { + return fmt.Errorf("template cannot be nil") + } + + if template.Name == "" { + return fmt.Errorf("template name cannot be empty") + } + + wtm.mu.Lock() + defer wtm.mu.Unlock() + + // Update timestamps + now := time.Now() + template.UpdatedAt = now + if template.CreatedAt.IsZero() { + template.CreatedAt = now + } + + // Create directory if needed + if err := os.MkdirAll(wtm.templatePath, 0755); err != nil { + return err + } + + // Save to YAML file + filePath := filepath.Join(wtm.templatePath, template.Name+".yaml") + data, err := yaml.Marshal(template) + if err != nil { + return err + } + + if err := os.WriteFile(filePath, data, 0644); err != nil { + return err + } + + // Update tracking + wtm.templates[template.Name] = template + wtm.loadedFrom[template.Name] = filePath + wtm.lastModified[template.Name] = now + wtm.stats.TotalTemplates++ + wtm.stats.LoadedTemplates++ + + return nil +} + +// LoadTemplate loads a template from file +func (wtm *WorkflowTemplateManager) LoadTemplate(name string) (*WorkflowTemplate, error) { + wtm.mu.Lock() + defer wtm.mu.Unlock() + + // Check if already loaded + if template, exists := wtm.templates[name]; exists { + return template, nil + } + + // Try to load from file + filePath := filepath.Join(wtm.templatePath, name+".yaml") + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read template: %w", err) + } + + var template WorkflowTemplate + if err := yaml.Unmarshal(data, &template); err != nil { + return nil, fmt.Errorf("failed to parse template: %w", err) + } + + // Cache the template + wtm.templates[name] = &template + wtm.loadedFrom[name] = filePath + wtm.lastModified[name] = time.Now() + wtm.stats.LoadedTemplates++ + wtm.stats.TotalTemplates++ + + return &template, nil +} + +// LoadTemplateDirectory loads all templates from a directory +func (wtm *WorkflowTemplateManager) LoadTemplateDirectory() error { + entries, err := os.ReadDir(wtm.templatePath) + if err != nil { + if os.IsNotExist(err) { + return nil // Directory doesn't exist yet + } + return err + } + + for _, entry := range entries { + if !entry.IsDir() && filepath.Ext(entry.Name()) == ".yaml" { + name := entry.Name()[:len(entry.Name())-5] // Remove .yaml + _, _ = wtm.LoadTemplate(name) + } + } + + return nil +} + +// GetTemplate retrieves a cached template +func (wtm *WorkflowTemplateManager) GetTemplate(name string) (*WorkflowTemplate, bool) { + wtm.mu.RLock() + defer wtm.mu.RUnlock() + + template, exists := wtm.templates[name] + return template, exists +} + +// ListTemplates returns all loaded templates +func (wtm *WorkflowTemplateManager) ListTemplates() map[string]*WorkflowTemplate { + wtm.mu.RLock() + defer wtm.mu.RUnlock() + + result := make(map[string]*WorkflowTemplate) + for name, template := range wtm.templates { + result[name] = template + } + + return result +} + +// DeleteTemplate deletes a template +func (wtm *WorkflowTemplateManager) DeleteTemplate(name string) error { + wtm.mu.Lock() + defer wtm.mu.Unlock() + + filePath, exists := wtm.loadedFrom[name] + if !exists { + return fmt.Errorf("template not found: %s", name) + } + + if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) { + return err + } + + delete(wtm.templates, name) + delete(wtm.loadedFrom, name) + delete(wtm.lastModified, name) + + return nil +} + +// ExportTemplateJSON exports a template as JSON +func (wtm *WorkflowTemplateManager) ExportTemplateJSON(name string) (string, error) { + wtm.mu.RLock() + template, exists := wtm.templates[name] + wtm.mu.RUnlock() + + if !exists { + return "", fmt.Errorf("template not found: %s", name) + } + + data, err := json.MarshalIndent(template, "", " ") + if err != nil { + return "", err + } + + return string(data), nil +} + +// ValidateTemplate validates a template +func (wtm *WorkflowTemplateManager) ValidateTemplate(template *WorkflowTemplate) error { + if template.Name == "" { + return fmt.Errorf("template name is required") + } + + if template.Orchestrator.PlannerURL == "" { + return fmt.Errorf("planner_url is required") + } + + if len(template.Tasks) == 0 { + return fmt.Errorf("at least one task is required") + } + + // Validate task dependencies + taskIDs := make(map[string]bool) + for _, task := range template.Tasks { + if task.ID == "" { + return fmt.Errorf("task ID is required") + } + taskIDs[task.ID] = true + } + + for _, task := range template.Tasks { + for _, dep := range task.DependsOn { + if !taskIDs[dep] { + return fmt.Errorf("task %s depends on non-existent task %s", task.ID, dep) + } + } + } + + return nil +} + +// RecordUsage records template usage +func (wtm *WorkflowTemplateManager) RecordUsage(name string) error { + wtm.mu.Lock() + defer wtm.mu.Unlock() + + if _, exists := wtm.templates[name]; !exists { + return fmt.Errorf("template not found: %s", name) + } + + wtm.stats.ExecutedTemplates++ + wtm.stats.LastUsed[name] = time.Now() + + return nil +} + +// GetStats returns template manager statistics +func (wtm *WorkflowTemplateManager) GetStats() *TemplateStats { + wtm.mu.RLock() + defer wtm.mu.RUnlock() + + stats := *wtm.stats + return &stats +} + +// ClearCache clears all cached templates +func (wtm *WorkflowTemplateManager) ClearCache() { + wtm.mu.Lock() + defer wtm.mu.Unlock() + + wtm.templates = make(map[string]*WorkflowTemplate) + wtm.loadedFrom = make(map[string]string) + wtm.lastModified = make(map[string]time.Time) +} diff --git a/internal/templates/workflow_template_test.go b/internal/templates/workflow_template_test.go new file mode 100644 index 0000000..c10e0cd --- /dev/null +++ b/internal/templates/workflow_template_test.go @@ -0,0 +1,357 @@ +package templates + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func createTestTemplate() *WorkflowTemplate { + return &WorkflowTemplate{ + Name: "golang-project", + Version: "1.0.0", + Description: "Template for Go projects", + Author: "test-author", + Orchestrator: OrchestratorConfig{ + PlannerURL: "http://planner:8000", + JudgeURL: "http://judge:8000", + ImplementerURL: "http://implementer:8000", + TimeoutSeconds: 300, + RetryPolicy: "exponential", + MaxConcurrency: 10, + }, + Tasks: []TaskConfig{ + { + ID: "T0.1", + Title: "Analyze Requirements", + Description: "Analyze project requirements", + Type: "feature", + Priority: "high", + }, + { + ID: "T0.2", + Title: "Implement Solution", + Description: "Implement the solution", + Type: "feature", + Priority: "high", + DependsOn: []string{"T0.1"}, + }, + }, + } +} + +func TestNewWorkflowTemplateManager(t *testing.T) { + tmpDir := t.TempDir() + manager := NewWorkflowTemplateManager(tmpDir) + + assert.NotNil(t, manager) + assert.Equal(t, tmpDir, manager.templatePath) +} + +func TestSaveTemplate(t *testing.T) { + tmpDir := t.TempDir() + manager := NewWorkflowTemplateManager(tmpDir) + + template := createTestTemplate() + err := manager.SaveTemplate(template) + + assert.NoError(t, err) + + // Check file was created + filePath := filepath.Join(tmpDir, "golang-project.yaml") + _, err = os.Stat(filePath) + assert.NoError(t, err) +} + +func TestSaveTemplateNil(t *testing.T) { + tmpDir := t.TempDir() + manager := NewWorkflowTemplateManager(tmpDir) + + err := manager.SaveTemplate(nil) + assert.Error(t, err) +} + +func TestLoadTemplate(t *testing.T) { + tmpDir := t.TempDir() + manager := NewWorkflowTemplateManager(tmpDir) + + template := createTestTemplate() + manager.SaveTemplate(template) + + loaded, err := manager.LoadTemplate("golang-project") + assert.NoError(t, err) + assert.NotNil(t, loaded) + assert.Equal(t, "golang-project", loaded.Name) +} + +func TestLoadTemplateNotFound(t *testing.T) { + tmpDir := t.TempDir() + manager := NewWorkflowTemplateManager(tmpDir) + + _, err := manager.LoadTemplate("nonexistent") + assert.Error(t, err) +} + +func TestGetTemplate(t *testing.T) { + tmpDir := t.TempDir() + manager := NewWorkflowTemplateManager(tmpDir) + + template := createTestTemplate() + manager.SaveTemplate(template) + + retrieved, exists := manager.GetTemplate("golang-project") + assert.True(t, exists) + assert.Equal(t, "golang-project", retrieved.Name) +} + +func TestGetTemplateNotFound(t *testing.T) { + tmpDir := t.TempDir() + manager := NewWorkflowTemplateManager(tmpDir) + + _, exists := manager.GetTemplate("nonexistent") + assert.False(t, exists) +} + +func TestListTemplates(t *testing.T) { + tmpDir := t.TempDir() + manager := NewWorkflowTemplateManager(tmpDir) + + for i := 0; i < 3; i++ { + template := createTestTemplate() + template.Name = "template-" + string(rune(48+i)) + manager.SaveTemplate(template) + } + + templates := manager.ListTemplates() + assert.Equal(t, 3, len(templates)) +} + +func TestDeleteTemplate(t *testing.T) { + tmpDir := t.TempDir() + manager := NewWorkflowTemplateManager(tmpDir) + + template := createTestTemplate() + manager.SaveTemplate(template) + + err := manager.DeleteTemplate("golang-project") + assert.NoError(t, err) + + _, exists := manager.GetTemplate("golang-project") + assert.False(t, exists) +} + +func TestDeleteTemplateNotFound(t *testing.T) { + tmpDir := t.TempDir() + manager := NewWorkflowTemplateManager(tmpDir) + + err := manager.DeleteTemplate("nonexistent") + assert.Error(t, err) +} + +func TestValidateTemplateValid(t *testing.T) { + template := createTestTemplate() + err := NewWorkflowTemplateManager("/tmp").ValidateTemplate(template) + assert.NoError(t, err) +} + +func TestValidateTemplateEmptyName(t *testing.T) { + template := createTestTemplate() + template.Name = "" + err := NewWorkflowTemplateManager("/tmp").ValidateTemplate(template) + assert.Error(t, err) +} + +func TestValidateTemplateNoTasks(t *testing.T) { + template := createTestTemplate() + template.Tasks = make([]TaskConfig, 0) + err := NewWorkflowTemplateManager("/tmp").ValidateTemplate(template) + assert.Error(t, err) +} + +func TestValidateTemplateInvalidDependency(t *testing.T) { + template := createTestTemplate() + template.Tasks[1].DependsOn = []string{"nonexistent"} + err := NewWorkflowTemplateManager("/tmp").ValidateTemplate(template) + assert.Error(t, err) +} + +func TestExportTemplateJSON(t *testing.T) { + tmpDir := t.TempDir() + manager := NewWorkflowTemplateManager(tmpDir) + + template := createTestTemplate() + manager.SaveTemplate(template) + + json, err := manager.ExportTemplateJSON("golang-project") + assert.NoError(t, err) + assert.NotEmpty(t, json) + assert.Contains(t, json, "golang-project") +} + +func TestExportTemplateJSONNotFound(t *testing.T) { + tmpDir := t.TempDir() + manager := NewWorkflowTemplateManager(tmpDir) + + _, err := manager.ExportTemplateJSON("nonexistent") + assert.Error(t, err) +} + +func TestRecordUsage(t *testing.T) { + tmpDir := t.TempDir() + manager := NewWorkflowTemplateManager(tmpDir) + + template := createTestTemplate() + manager.SaveTemplate(template) + + err := manager.RecordUsage("golang-project") + assert.NoError(t, err) + + stats := manager.GetStats() + assert.Equal(t, 1, stats.ExecutedTemplates) +} + +func TestRecordUsageNotFound(t *testing.T) { + tmpDir := t.TempDir() + manager := NewWorkflowTemplateManager(tmpDir) + + err := manager.RecordUsage("nonexistent") + assert.Error(t, err) +} + +func TestTemplateGetStats(t *testing.T) { + tmpDir := t.TempDir() + manager := NewWorkflowTemplateManager(tmpDir) + + template := createTestTemplate() + manager.SaveTemplate(template) + + stats := manager.GetStats() + assert.Equal(t, 1, stats.TotalTemplates) + assert.Equal(t, 1, stats.LoadedTemplates) +} + +func TestLoadTemplateDirectory(t *testing.T) { + tmpDir := t.TempDir() + manager := NewWorkflowTemplateManager(tmpDir) + + for i := 0; i < 3; i++ { + template := createTestTemplate() + template.Name = "template-" + string(rune(48+i)) + manager.SaveTemplate(template) + } + + manager.ClearCache() + err := manager.LoadTemplateDirectory() + assert.NoError(t, err) + + templates := manager.ListTemplates() + assert.Equal(t, 3, len(templates)) +} + +func TestClearCache(t *testing.T) { + tmpDir := t.TempDir() + manager := NewWorkflowTemplateManager(tmpDir) + + template := createTestTemplate() + manager.SaveTemplate(template) + + assert.Equal(t, 1, len(manager.ListTemplates())) + + manager.ClearCache() + assert.Equal(t, 0, len(manager.ListTemplates())) +} + +func TestTemplateTimestamps(t *testing.T) { + tmpDir := t.TempDir() + manager := NewWorkflowTemplateManager(tmpDir) + + template := createTestTemplate() + manager.SaveTemplate(template) + + retrieved, _ := manager.GetTemplate("golang-project") + assert.False(t, retrieved.CreatedAt.IsZero()) + assert.False(t, retrieved.UpdatedAt.IsZero()) + assert.True(t, retrieved.UpdatedAt.After(retrieved.CreatedAt) || retrieved.UpdatedAt.Equal(retrieved.CreatedAt)) +} + +func TestTemplateWithDefaults(t *testing.T) { + tmpDir := t.TempDir() + manager := NewWorkflowTemplateManager(tmpDir) + + template := createTestTemplate() + template.Defaults = map[string]interface{}{ + "language": "go", + "version": "1.20", + } + + manager.SaveTemplate(template) + + retrieved, _ := manager.GetTemplate("golang-project") + assert.NotNil(t, retrieved.Defaults) + assert.Equal(t, "go", retrieved.Defaults["language"]) +} + +func TestTemplateWithTags(t *testing.T) { + tmpDir := t.TempDir() + manager := NewWorkflowTemplateManager(tmpDir) + + template := createTestTemplate() + template.Tags = map[string]string{ + "environment": "production", + "team": "backend", + } + + manager.SaveTemplate(template) + + retrieved, _ := manager.GetTemplate("golang-project") + assert.NotNil(t, retrieved.Tags) + assert.Equal(t, "production", retrieved.Tags["environment"]) +} + +func TestMultipleTemplates(t *testing.T) { + tmpDir := t.TempDir() + manager := NewWorkflowTemplateManager(tmpDir) + + for i := 0; i < 5; i++ { + template := createTestTemplate() + template.Name = "template-" + string(rune(48+i)) + manager.SaveTemplate(template) + } + + templates := manager.ListTemplates() + assert.Equal(t, 5, len(templates)) + + for i := 0; i < 5; i++ { + name := "template-" + string(rune(48+i)) + manager.RecordUsage(name) + } + + stats := manager.GetStats() + assert.Equal(t, 5, stats.ExecutedTemplates) +} + +func BenchmarkSaveTemplate(b *testing.B) { + tmpDir := b.TempDir() + manager := NewWorkflowTemplateManager(tmpDir) + + for i := 0; i < b.N; i++ { + template := createTestTemplate() + template.Name = "template-" + string(rune(48+i%100)) + manager.SaveTemplate(template) + } +} + +func BenchmarkLoadTemplate(b *testing.B) { + tmpDir := b.TempDir() + manager := NewWorkflowTemplateManager(tmpDir) + + template := createTestTemplate() + manager.SaveTemplate(template) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + manager.LoadTemplate("golang-project") + } +} diff --git a/tasks/board-T3.md b/tasks/board-T3.md index 45dbe96..b5b79be 100644 --- a/tasks/board-T3.md +++ b/tasks/board-T3.md @@ -5,7 +5,7 @@ | ID | Scope | Status | Branch | Verification | |----|-------|--------|--------|--------------| | T3.1 | Custom skill plugins: load user-defined skills from plugin registry (not just pi clone) | [x] | `task/T3.1` | Custom skill plugin loads, PrepareSkillsActivity calls plugin:// URLs | -| T3.2 | Workflow templates: save/load orchestrator config as YAML templates (not CLI flags only) | [ ] | `task/T3.2` | Load template `templates/golang-project.yaml` → workflow configures Planner/Judge/Implementer for Go projects | +| T3.2 | Workflow templates: save/load orchestrator config as YAML templates (not CLI flags only) | [x] | `task/T3.2` | Load template `templates/golang-project.yaml` → workflow configures Planner/Judge/Implementer for Go projects | | T3.3 | Task dependency graph: specify task order (T0.2 must complete before T0.3 can start) | [ ] | `task/T3.3` | Board supports `depends_on: [T0.1]` field, orchestrator respects ordering | | T3.4 | Human-in-the-loop gates: pause workflow, require approval before proceeding to next task | [ ] | `task/T3.4` | Workflow waits for `approve-task` signal, Judge verdict is final (can't auto-retry after user approval) | | T3.5 | Custom Judge implementations: swap default Judge for domain-specific validator (e.g., security auditor) | [ ] | `task/T3.5` | Register custom JudgeActivity, orchestrator uses it instead of default |