|
| 1 | +package campaign |
| 2 | + |
| 3 | +import ( |
| 4 | + "fmt" |
| 5 | + "strings" |
| 6 | + |
| 7 | + "github.com/githubnext/gh-aw/pkg/logger" |
| 8 | + "github.com/githubnext/gh-aw/pkg/workflow" |
| 9 | +) |
| 10 | + |
| 11 | +var injectionLog = logger.New("campaign:injection") |
| 12 | + |
| 13 | +// InjectOrchestratorFeatures detects if a workflow has project field with campaign |
| 14 | +// configuration and injects orchestrator features directly into the workflow during compilation. |
| 15 | +// This transforms the workflow into a campaign orchestrator without generating separate files. |
| 16 | +func InjectOrchestratorFeatures(workflowData *workflow.WorkflowData) error { |
| 17 | + injectionLog.Print("Checking workflow for campaign orchestrator features") |
| 18 | + |
| 19 | + // Check if this workflow has project configuration with campaign fields |
| 20 | + if workflowData.ParsedFrontmatter == nil || workflowData.ParsedFrontmatter.Project == nil { |
| 21 | + injectionLog.Print("No project field detected, skipping campaign injection") |
| 22 | + return nil |
| 23 | + } |
| 24 | + |
| 25 | + project := workflowData.ParsedFrontmatter.Project |
| 26 | + |
| 27 | + // Check if project has any campaign orchestration fields to determine if this is a campaign |
| 28 | + // Campaign indicators (any of these present means it's a campaign orchestrator): |
| 29 | + // - workflows list (predefined workers) |
| 30 | + // - governance policies (campaign-specific constraints) |
| 31 | + // - bootstrap configuration (initial work item generation) |
| 32 | + // - memory-paths, metrics-glob, cursor-glob (campaign state tracking) |
| 33 | + // If only URL and scope are present, it's simple project tracking, not a campaign |
| 34 | + isCampaign := len(project.Workflows) > 0 || |
| 35 | + project.Governance != nil || |
| 36 | + project.Bootstrap != nil || |
| 37 | + len(project.MemoryPaths) > 0 || |
| 38 | + project.MetricsGlob != "" || |
| 39 | + project.CursorGlob != "" |
| 40 | + |
| 41 | + if !isCampaign { |
| 42 | + injectionLog.Print("Project field present but no campaign indicators, treating as simple project tracking") |
| 43 | + return nil |
| 44 | + } |
| 45 | + |
| 46 | + injectionLog.Printf("Detected campaign orchestrator: workflows=%d, has_governance=%v, has_bootstrap=%v", |
| 47 | + len(project.Workflows), project.Governance != nil, project.Bootstrap != nil) |
| 48 | + |
| 49 | + // Derive campaign ID from workflow name or use explicit ID |
| 50 | + campaignID := workflowData.FrontmatterName |
| 51 | + if project.ID != "" { |
| 52 | + campaignID = project.ID |
| 53 | + } |
| 54 | + |
| 55 | + // Build campaign prompt data from project configuration |
| 56 | + promptData := CampaignPromptData{ |
| 57 | + CampaignID: campaignID, |
| 58 | + CampaignName: workflowData.Name, |
| 59 | + ProjectURL: project.URL, |
| 60 | + CursorGlob: project.CursorGlob, |
| 61 | + MetricsGlob: project.MetricsGlob, |
| 62 | + Workflows: project.Workflows, |
| 63 | + } |
| 64 | + |
| 65 | + if project.Governance != nil { |
| 66 | + promptData.MaxDiscoveryItemsPerRun = project.Governance.MaxDiscoveryItemsPerRun |
| 67 | + promptData.MaxDiscoveryPagesPerRun = project.Governance.MaxDiscoveryPagesPerRun |
| 68 | + promptData.MaxProjectUpdatesPerRun = project.Governance.MaxProjectUpdatesPerRun |
| 69 | + promptData.MaxProjectCommentsPerRun = project.Governance.MaxCommentsPerRun |
| 70 | + } |
| 71 | + |
| 72 | + if project.Bootstrap != nil { |
| 73 | + promptData.BootstrapMode = project.Bootstrap.Mode |
| 74 | + if project.Bootstrap.SeederWorker != nil { |
| 75 | + promptData.SeederWorkerID = project.Bootstrap.SeederWorker.WorkflowID |
| 76 | + promptData.SeederMaxItems = project.Bootstrap.SeederWorker.MaxItems |
| 77 | + } |
| 78 | + if project.Bootstrap.ProjectTodos != nil { |
| 79 | + promptData.StatusField = project.Bootstrap.ProjectTodos.StatusField |
| 80 | + if promptData.StatusField == "" { |
| 81 | + promptData.StatusField = "Status" |
| 82 | + } |
| 83 | + promptData.TodoValue = project.Bootstrap.ProjectTodos.TodoValue |
| 84 | + if promptData.TodoValue == "" { |
| 85 | + promptData.TodoValue = "Todo" |
| 86 | + } |
| 87 | + promptData.TodoMaxItems = project.Bootstrap.ProjectTodos.MaxItems |
| 88 | + promptData.RequireFields = project.Bootstrap.ProjectTodos.RequireFields |
| 89 | + } |
| 90 | + } |
| 91 | + |
| 92 | + if len(project.Workers) > 0 { |
| 93 | + promptData.WorkerMetadata = make([]WorkerMetadata, len(project.Workers)) |
| 94 | + for i, w := range project.Workers { |
| 95 | + promptData.WorkerMetadata[i] = WorkerMetadata{ |
| 96 | + ID: w.ID, |
| 97 | + Name: w.Name, |
| 98 | + Description: w.Description, |
| 99 | + Capabilities: w.Capabilities, |
| 100 | + IdempotencyStrategy: w.IdempotencyStrategy, |
| 101 | + Priority: w.Priority, |
| 102 | + } |
| 103 | + // Convert payload schema |
| 104 | + if len(w.PayloadSchema) > 0 { |
| 105 | + promptData.WorkerMetadata[i].PayloadSchema = make(map[string]WorkerPayloadField) |
| 106 | + for key, field := range w.PayloadSchema { |
| 107 | + promptData.WorkerMetadata[i].PayloadSchema[key] = WorkerPayloadField{ |
| 108 | + Type: field.Type, |
| 109 | + Description: field.Description, |
| 110 | + Required: field.Required, |
| 111 | + Example: field.Example, |
| 112 | + } |
| 113 | + } |
| 114 | + } |
| 115 | + // Convert output labeling |
| 116 | + promptData.WorkerMetadata[i].OutputLabeling = WorkerOutputLabeling{ |
| 117 | + Labels: w.OutputLabeling.Labels, |
| 118 | + KeyInTitle: w.OutputLabeling.KeyInTitle, |
| 119 | + KeyFormat: w.OutputLabeling.KeyFormat, |
| 120 | + MetadataFields: w.OutputLabeling.MetadataFields, |
| 121 | + } |
| 122 | + } |
| 123 | + } |
| 124 | + |
| 125 | + // Append orchestrator instructions to markdown content |
| 126 | + markdownBuilder := &strings.Builder{} |
| 127 | + markdownBuilder.WriteString(workflowData.MarkdownContent) |
| 128 | + markdownBuilder.WriteString("\n\n") |
| 129 | + |
| 130 | + // Add bootstrap instructions if configured |
| 131 | + if project.Bootstrap != nil && project.Bootstrap.Mode != "" { |
| 132 | + bootstrapInstructions := RenderBootstrapInstructions(promptData) |
| 133 | + if bootstrapInstructions != "" { |
| 134 | + AppendPromptSection(markdownBuilder, "BOOTSTRAP INSTRUCTIONS (PHASE 0)", bootstrapInstructions) |
| 135 | + } |
| 136 | + } |
| 137 | + |
| 138 | + // Add workflow execution instructions |
| 139 | + workflowExecution := RenderWorkflowExecution(promptData) |
| 140 | + if workflowExecution != "" { |
| 141 | + AppendPromptSection(markdownBuilder, "WORKFLOW EXECUTION (PHASE 0)", workflowExecution) |
| 142 | + } |
| 143 | + |
| 144 | + // Add orchestrator instructions |
| 145 | + orchestratorInstructions := RenderOrchestratorInstructions(promptData) |
| 146 | + if orchestratorInstructions != "" { |
| 147 | + AppendPromptSection(markdownBuilder, "ORCHESTRATOR INSTRUCTIONS", orchestratorInstructions) |
| 148 | + } |
| 149 | + |
| 150 | + // Add project update instructions |
| 151 | + projectInstructions := RenderProjectUpdateInstructions(promptData) |
| 152 | + if projectInstructions != "" { |
| 153 | + AppendPromptSection(markdownBuilder, "PROJECT UPDATE INSTRUCTIONS (AUTHORITATIVE FOR WRITES)", projectInstructions) |
| 154 | + } |
| 155 | + |
| 156 | + // Add closing instructions |
| 157 | + closingInstructions := RenderClosingInstructions() |
| 158 | + if closingInstructions != "" { |
| 159 | + AppendPromptSection(markdownBuilder, "CLOSING INSTRUCTIONS (HIGHEST PRIORITY)", closingInstructions) |
| 160 | + } |
| 161 | + |
| 162 | + // Update the workflow markdown content with injected instructions |
| 163 | + workflowData.MarkdownContent = markdownBuilder.String() |
| 164 | + injectionLog.Printf("Injected campaign orchestrator instructions into workflow markdown") |
| 165 | + |
| 166 | + // Configure safe-outputs for campaign orchestration |
| 167 | + if workflowData.SafeOutputs == nil { |
| 168 | + workflowData.SafeOutputs = &workflow.SafeOutputsConfig{} |
| 169 | + } |
| 170 | + |
| 171 | + // Configure dispatch-workflow for worker coordination (optional - only if workflows are specified) |
| 172 | + if len(project.Workflows) > 0 && workflowData.SafeOutputs.DispatchWorkflow == nil { |
| 173 | + workflowData.SafeOutputs.DispatchWorkflow = &workflow.DispatchWorkflowConfig{ |
| 174 | + BaseSafeOutputConfig: workflow.BaseSafeOutputConfig{Max: 3}, |
| 175 | + Workflows: project.Workflows, |
| 176 | + } |
| 177 | + injectionLog.Printf("Configured dispatch-workflow safe-output for %d workflows", len(project.Workflows)) |
| 178 | + } else if len(project.Workflows) == 0 { |
| 179 | + injectionLog.Print("No workflows specified - campaign will use custom discovery and dispatch logic") |
| 180 | + } |
| 181 | + |
| 182 | + // Configure update-project (already handled by applyProjectSafeOutputs, but ensure governance max is applied) |
| 183 | + if project.Governance != nil && project.Governance.MaxProjectUpdatesPerRun > 0 { |
| 184 | + if workflowData.SafeOutputs.UpdateProjects != nil { |
| 185 | + workflowData.SafeOutputs.UpdateProjects.Max = project.Governance.MaxProjectUpdatesPerRun |
| 186 | + injectionLog.Printf("Applied governance max-project-updates-per-run: %d", project.Governance.MaxProjectUpdatesPerRun) |
| 187 | + } |
| 188 | + } |
| 189 | + |
| 190 | + // Add concurrency control for campaigns if not already set |
| 191 | + if workflowData.Concurrency == "" { |
| 192 | + workflowData.Concurrency = fmt.Sprintf("concurrency:\n group: \"campaign-%s-orchestrator-${{ github.ref }}\"\n cancel-in-progress: false", campaignID) |
| 193 | + injectionLog.Printf("Added campaign concurrency control") |
| 194 | + } |
| 195 | + |
| 196 | + injectionLog.Printf("Successfully injected campaign orchestrator features for: %s", campaignID) |
| 197 | + return nil |
| 198 | +} |
0 commit comments