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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
271 changes: 268 additions & 3 deletions .github/workflows/security-alert-burndown.campaign.lock.yml

Large diffs are not rendered by default.

32 changes: 26 additions & 6 deletions pkg/campaign/orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,8 @@ func BuildOrchestrator(spec *CampaignSpec, campaignFilePath string) (*workflow.W
description = fmt.Sprintf("Orchestrator workflow for campaign '%s'", spec.ID)
}

// Default triggers: daily schedule plus manual workflow_dispatch.
onSection := "on:\n schedule:\n - cron: \"0 18 * * *\"\n workflow_dispatch:\n"
// Default triggers: hourly schedule plus manual workflow_dispatch.
onSection := "on:\n schedule:\n - cron: \"0 * * * *\"\n workflow_dispatch:\n"

// Prevent overlapping runs. This reduces sustained automated traffic on GitHub's
// infrastructure by ensuring only one orchestrator run executes at a time per ref.
Expand Down Expand Up @@ -375,13 +375,15 @@ func BuildOrchestrator(spec *CampaignSpec, campaignFilePath string) (*workflow.W
appendPromptSection(markdownBuilder, "CLOSING INSTRUCTIONS (HIGHEST PRIORITY)", closingInstructions)
}

// Campaign orchestrators are dispatch-only: they may only dispatch allowlisted
// workflows via the dispatch-workflow safe output. All side effects (Projects,
// issues/PRs, comments) must be performed by dispatched worker workflows.
// Campaign orchestrators can dispatch workflows and perform limited Project operations.
// Project writes (update-project, create-project-status-update) are allowed to enable
// orchestrators to maintain campaign dashboards and status updates.
//
// Note: Campaign orchestrators intentionally omit explicit `permissions:` from
// the generated markdown; safe-output jobs have their own scoped permissions.
safeOutputs := &workflow.SafeOutputsConfig{}

// Configure dispatch-workflow for worker coordination
if len(spec.Workflows) > 0 {
dispatchWorkflowConfig := &workflow.DispatchWorkflowConfig{
BaseSafeOutputConfig: workflow.BaseSafeOutputConfig{Max: 3},
Expand All @@ -391,7 +393,25 @@ func BuildOrchestrator(spec *CampaignSpec, campaignFilePath string) (*workflow.W
orchestratorLog.Printf("Campaign orchestrator '%s' configured with dispatch_workflow for %d workflows", spec.ID, len(spec.Workflows))
}

orchestratorLog.Printf("Campaign orchestrator '%s' built successfully with dispatch-workflow safe output", spec.ID)
// Configure update-project for campaign dashboard maintenance
maxProjectUpdates := 100 // default - increased from 10 to handle larger discovery sets
if spec.Governance != nil && spec.Governance.MaxProjectUpdatesPerRun > 0 {
maxProjectUpdates = spec.Governance.MaxProjectUpdatesPerRun
}
updateProjectConfig := &workflow.UpdateProjectConfig{
BaseSafeOutputConfig: workflow.BaseSafeOutputConfig{Max: maxProjectUpdates},
}
safeOutputs.UpdateProjects = updateProjectConfig
orchestratorLog.Printf("Campaign orchestrator '%s' configured with update-project (max: %d)", spec.ID, maxProjectUpdates)

// Configure create-project-status-update for campaign summaries
statusUpdateConfig := &workflow.CreateProjectStatusUpdateConfig{
BaseSafeOutputConfig: workflow.BaseSafeOutputConfig{Max: 1},
}
safeOutputs.CreateProjectStatusUpdates = statusUpdateConfig
orchestratorLog.Printf("Campaign orchestrator '%s' configured with create-project-status-update", spec.ID)

orchestratorLog.Printf("Campaign orchestrator '%s' built successfully with dispatch-workflow, update-project, and create-project-status-update safe outputs", spec.ID)

// Extract file-glob patterns from memory-paths or metrics-glob to support
// multiple directory structures (e.g., both dated "campaign-id-*/**" and non-dated "campaign-id/**")
Expand Down
46 changes: 37 additions & 9 deletions pkg/campaign/orchestrator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ func TestBuildOrchestrator_BasicShape(t *testing.T) {
t.Fatalf("expected On section with workflow_dispatch trigger, got %q", data.On)
}

if !strings.Contains(data.On, "schedule:") || !strings.Contains(data.On, "0 18 * * *") {
t.Fatalf("expected On section with daily schedule cron, got %q", data.On)
if !strings.Contains(data.On, "schedule:") || !strings.Contains(data.On, "0 * * * *") {
t.Fatalf("expected On section with hourly schedule cron, got %q", data.On)
}

if strings.TrimSpace(data.Concurrency) == "" || !strings.Contains(data.Concurrency, "concurrency:") {
Expand Down Expand Up @@ -137,7 +137,7 @@ func TestBuildOrchestrator_DispatchOnlyPolicy(t *testing.T) {
spec := &CampaignSpec{
ID: "dispatch-only-campaign",
Name: "Dispatch Only Campaign",
Description: "Campaign orchestrator restricted to dispatch-workflow",
Description: "Campaign orchestrator with dispatch and project capabilities",
ProjectURL: "https://github.com/orgs/test/projects/1",
Workflows: []string{"worker-a", "worker-b"},
MemoryPaths: []string{"memory/campaigns/dispatch-only-campaign/**"},
Expand All @@ -158,14 +158,24 @@ func TestBuildOrchestrator_DispatchOnlyPolicy(t *testing.T) {
if len(data.SafeOutputs.DispatchWorkflow.Workflows) != 2 {
t.Fatalf("expected 2 allowlisted workflows, got %d", len(data.SafeOutputs.DispatchWorkflow.Workflows))
}
if data.SafeOutputs.CreateIssues != nil || data.SafeOutputs.AddComments != nil || data.SafeOutputs.UpdateProjects != nil || data.SafeOutputs.CreateProjectStatusUpdates != nil {
t.Fatalf("expected dispatch-only orchestrator to omit non-dispatch safe outputs")

// Orchestrators should have update-project and create-project-status-update for dashboard maintenance
if data.SafeOutputs.UpdateProjects == nil {
t.Fatalf("expected update-project safe output to be enabled")
}
if data.SafeOutputs.CreateProjectStatusUpdates == nil {
t.Fatalf("expected create-project-status-update safe output to be enabled")
}

// Orchestrators should NOT have create-issue or add-comment (workers handle those)
if data.SafeOutputs.CreateIssues != nil || data.SafeOutputs.AddComments != nil {
t.Fatalf("expected orchestrator to omit create-issue and add-comment safe outputs")
}

// Dispatch-only policy should not grant GitHub tool access to the agent.
// Orchestrators should not have GitHub tool access to the agent.
if data.Tools != nil {
if _, ok := data.Tools["github"]; ok {
t.Fatalf("expected dispatch-only orchestrator to omit github tools")
t.Fatalf("expected orchestrator to omit github tools")
}
}
})
Expand Down Expand Up @@ -253,8 +263,26 @@ func TestBuildOrchestrator_GovernanceDoesNotGrantWriteSafeOutputs(t *testing.T)
if data.SafeOutputs.DispatchWorkflow.Max != 3 {
t.Fatalf("unexpected dispatch-workflow max: got %d, want %d", data.SafeOutputs.DispatchWorkflow.Max, 3)
}
if data.SafeOutputs.CreateIssues != nil || data.SafeOutputs.AddComments != nil || data.SafeOutputs.UpdateProjects != nil || data.SafeOutputs.CreateProjectStatusUpdates != nil {
t.Fatalf("expected orchestrator to omit non-dispatch safe outputs regardless of governance")

// Governance should control update-project max
if data.SafeOutputs.UpdateProjects == nil {
t.Fatalf("expected update-project safe output to be enabled")
}
if data.SafeOutputs.UpdateProjects.Max != 4 {
t.Fatalf("unexpected update-project max: got %d, want %d", data.SafeOutputs.UpdateProjects.Max, 4)
}

// create-project-status-update should always be enabled
if data.SafeOutputs.CreateProjectStatusUpdates == nil {
t.Fatalf("expected create-project-status-update safe output to be enabled")
}
if data.SafeOutputs.CreateProjectStatusUpdates.Max != 1 {
t.Fatalf("unexpected create-project-status-update max: got %d, want %d", data.SafeOutputs.CreateProjectStatusUpdates.Max, 1)
}

// Orchestrators should NOT have create-issue or add-comment (governance MaxCommentsPerRun doesn't grant add-comment)
if data.SafeOutputs.CreateIssues != nil || data.SafeOutputs.AddComments != nil {
t.Fatalf("expected orchestrator to omit create-issue and add-comment safe outputs regardless of governance")
}
})
}
Expand Down
21 changes: 19 additions & 2 deletions pkg/cli/compile_orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,7 @@ func renderGeneratedCampaignOrchestratorMarkdown(data *workflow.WorkflowData, so
fmt.Fprintf(b, "engine: %s\n", engineID)

// Render safe-outputs if configured by the campaign orchestrator generator.
// Campaign orchestrators are dispatch-only: the only supported safe output is
// dispatch-workflow.
// Campaign orchestrators support dispatch-workflow, update-project, and create-project-status-update.
if data.SafeOutputs != nil {
// NOTE: We must emit the public frontmatter keys (e.g. "add-comment") rather
// than the internal struct YAML tags (e.g. "add-comments").
Expand All @@ -95,6 +94,24 @@ func renderGeneratedCampaignOrchestratorMarkdown(data *workflow.WorkflowData, so
}
outputs["dispatch-workflow"] = dispatchWorkflowConfig
}
if data.SafeOutputs.UpdateProjects != nil {
updateProjectConfig := map[string]any{
"max": data.SafeOutputs.UpdateProjects.Max,
}
if data.SafeOutputs.UpdateProjects.GitHubToken != "" {
updateProjectConfig["github-token"] = data.SafeOutputs.UpdateProjects.GitHubToken
}
outputs["update-project"] = updateProjectConfig
}
if data.SafeOutputs.CreateProjectStatusUpdates != nil {
createStatusUpdateConfig := map[string]any{
"max": data.SafeOutputs.CreateProjectStatusUpdates.Max,
}
if data.SafeOutputs.CreateProjectStatusUpdates.GitHubToken != "" {
createStatusUpdateConfig["github-token"] = data.SafeOutputs.CreateProjectStatusUpdates.GitHubToken
}
outputs["create-project-status-update"] = createStatusUpdateConfig
}
if len(outputs) > 0 {
payload := map[string]any{"safe-outputs": outputs}
if out, err := yaml.Marshal(payload); err == nil {
Expand Down
4 changes: 2 additions & 2 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -5849,9 +5849,9 @@
},
"max": {
"type": "integer",
"description": "Maximum number of workflow dispatch operations per run (default: 1, max: 3)",
"description": "Maximum number of workflow dispatch operations per run (default: 1, max: 50)",
"minimum": 1,
"maximum": 3,
"maximum": 50,
"default": 1
},
"github-token": {
Expand Down
8 changes: 4 additions & 4 deletions pkg/workflow/dispatch_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,10 @@ func (c *Compiler) parseDispatchWorkflowConfig(outputMap map[string]any) *Dispat
// Parse common base fields with default max of 1
c.parseBaseSafeOutputConfig(configMap, &dispatchWorkflowConfig.BaseSafeOutputConfig, 1)

// Cap max at 3 (absolute maximum allowed)
if dispatchWorkflowConfig.Max > 3 {
dispatchWorkflowLog.Printf("Max value %d exceeds limit, capping at 3", dispatchWorkflowConfig.Max)
dispatchWorkflowConfig.Max = 3
// Cap max at 50 (absolute maximum allowed)
if dispatchWorkflowConfig.Max > 50 {
dispatchWorkflowLog.Printf("Max value %d exceeds limit, capping at 50", dispatchWorkflowConfig.Max)
dispatchWorkflowConfig.Max = 50
}

dispatchWorkflowLog.Printf("Parsed dispatch-workflow config: max=%d, workflows=%v",
Expand Down
Loading
Loading