Skip to content

Commit bda74be

Browse files
authored
Campaign safe outputs: Update project with hourly schedule (#11994)
1 parent fa61b63 commit bda74be

7 files changed

Lines changed: 635 additions & 40 deletions

File tree

.github/workflows/security-alert-burndown.campaign.lock.yml

Lines changed: 268 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/campaign/orchestrator.go

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -202,8 +202,8 @@ func BuildOrchestrator(spec *CampaignSpec, campaignFilePath string) (*workflow.W
202202
description = fmt.Sprintf("Orchestrator workflow for campaign '%s'", spec.ID)
203203
}
204204

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

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

378-
// Campaign orchestrators are dispatch-only: they may only dispatch allowlisted
379-
// workflows via the dispatch-workflow safe output. All side effects (Projects,
380-
// issues/PRs, comments) must be performed by dispatched worker workflows.
378+
// Campaign orchestrators can dispatch workflows and perform limited Project operations.
379+
// Project writes (update-project, create-project-status-update) are allowed to enable
380+
// orchestrators to maintain campaign dashboards and status updates.
381381
//
382382
// Note: Campaign orchestrators intentionally omit explicit `permissions:` from
383383
// the generated markdown; safe-output jobs have their own scoped permissions.
384384
safeOutputs := &workflow.SafeOutputsConfig{}
385+
386+
// Configure dispatch-workflow for worker coordination
385387
if len(spec.Workflows) > 0 {
386388
dispatchWorkflowConfig := &workflow.DispatchWorkflowConfig{
387389
BaseSafeOutputConfig: workflow.BaseSafeOutputConfig{Max: 3},
@@ -391,7 +393,25 @@ func BuildOrchestrator(spec *CampaignSpec, campaignFilePath string) (*workflow.W
391393
orchestratorLog.Printf("Campaign orchestrator '%s' configured with dispatch_workflow for %d workflows", spec.ID, len(spec.Workflows))
392394
}
393395

394-
orchestratorLog.Printf("Campaign orchestrator '%s' built successfully with dispatch-workflow safe output", spec.ID)
396+
// Configure update-project for campaign dashboard maintenance
397+
maxProjectUpdates := 100 // default - increased from 10 to handle larger discovery sets
398+
if spec.Governance != nil && spec.Governance.MaxProjectUpdatesPerRun > 0 {
399+
maxProjectUpdates = spec.Governance.MaxProjectUpdatesPerRun
400+
}
401+
updateProjectConfig := &workflow.UpdateProjectConfig{
402+
BaseSafeOutputConfig: workflow.BaseSafeOutputConfig{Max: maxProjectUpdates},
403+
}
404+
safeOutputs.UpdateProjects = updateProjectConfig
405+
orchestratorLog.Printf("Campaign orchestrator '%s' configured with update-project (max: %d)", spec.ID, maxProjectUpdates)
406+
407+
// Configure create-project-status-update for campaign summaries
408+
statusUpdateConfig := &workflow.CreateProjectStatusUpdateConfig{
409+
BaseSafeOutputConfig: workflow.BaseSafeOutputConfig{Max: 1},
410+
}
411+
safeOutputs.CreateProjectStatusUpdates = statusUpdateConfig
412+
orchestratorLog.Printf("Campaign orchestrator '%s' configured with create-project-status-update", spec.ID)
413+
414+
orchestratorLog.Printf("Campaign orchestrator '%s' built successfully with dispatch-workflow, update-project, and create-project-status-update safe outputs", spec.ID)
395415

396416
// Extract file-glob patterns from memory-paths or metrics-glob to support
397417
// multiple directory structures (e.g., both dated "campaign-id-*/**" and non-dated "campaign-id/**")

pkg/campaign/orchestrator_test.go

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ func TestBuildOrchestrator_BasicShape(t *testing.T) {
3636
t.Fatalf("expected On section with workflow_dispatch trigger, got %q", data.On)
3737
}
3838

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

4343
if strings.TrimSpace(data.Concurrency) == "" || !strings.Contains(data.Concurrency, "concurrency:") {
@@ -137,7 +137,7 @@ func TestBuildOrchestrator_DispatchOnlyPolicy(t *testing.T) {
137137
spec := &CampaignSpec{
138138
ID: "dispatch-only-campaign",
139139
Name: "Dispatch Only Campaign",
140-
Description: "Campaign orchestrator restricted to dispatch-workflow",
140+
Description: "Campaign orchestrator with dispatch and project capabilities",
141141
ProjectURL: "https://github.com/orgs/test/projects/1",
142142
Workflows: []string{"worker-a", "worker-b"},
143143
MemoryPaths: []string{"memory/campaigns/dispatch-only-campaign/**"},
@@ -158,14 +158,24 @@ func TestBuildOrchestrator_DispatchOnlyPolicy(t *testing.T) {
158158
if len(data.SafeOutputs.DispatchWorkflow.Workflows) != 2 {
159159
t.Fatalf("expected 2 allowlisted workflows, got %d", len(data.SafeOutputs.DispatchWorkflow.Workflows))
160160
}
161-
if data.SafeOutputs.CreateIssues != nil || data.SafeOutputs.AddComments != nil || data.SafeOutputs.UpdateProjects != nil || data.SafeOutputs.CreateProjectStatusUpdates != nil {
162-
t.Fatalf("expected dispatch-only orchestrator to omit non-dispatch safe outputs")
161+
162+
// Orchestrators should have update-project and create-project-status-update for dashboard maintenance
163+
if data.SafeOutputs.UpdateProjects == nil {
164+
t.Fatalf("expected update-project safe output to be enabled")
165+
}
166+
if data.SafeOutputs.CreateProjectStatusUpdates == nil {
167+
t.Fatalf("expected create-project-status-update safe output to be enabled")
168+
}
169+
170+
// Orchestrators should NOT have create-issue or add-comment (workers handle those)
171+
if data.SafeOutputs.CreateIssues != nil || data.SafeOutputs.AddComments != nil {
172+
t.Fatalf("expected orchestrator to omit create-issue and add-comment safe outputs")
163173
}
164174

165-
// Dispatch-only policy should not grant GitHub tool access to the agent.
175+
// Orchestrators should not have GitHub tool access to the agent.
166176
if data.Tools != nil {
167177
if _, ok := data.Tools["github"]; ok {
168-
t.Fatalf("expected dispatch-only orchestrator to omit github tools")
178+
t.Fatalf("expected orchestrator to omit github tools")
169179
}
170180
}
171181
})
@@ -253,8 +263,26 @@ func TestBuildOrchestrator_GovernanceDoesNotGrantWriteSafeOutputs(t *testing.T)
253263
if data.SafeOutputs.DispatchWorkflow.Max != 3 {
254264
t.Fatalf("unexpected dispatch-workflow max: got %d, want %d", data.SafeOutputs.DispatchWorkflow.Max, 3)
255265
}
256-
if data.SafeOutputs.CreateIssues != nil || data.SafeOutputs.AddComments != nil || data.SafeOutputs.UpdateProjects != nil || data.SafeOutputs.CreateProjectStatusUpdates != nil {
257-
t.Fatalf("expected orchestrator to omit non-dispatch safe outputs regardless of governance")
266+
267+
// Governance should control update-project max
268+
if data.SafeOutputs.UpdateProjects == nil {
269+
t.Fatalf("expected update-project safe output to be enabled")
270+
}
271+
if data.SafeOutputs.UpdateProjects.Max != 4 {
272+
t.Fatalf("unexpected update-project max: got %d, want %d", data.SafeOutputs.UpdateProjects.Max, 4)
273+
}
274+
275+
// create-project-status-update should always be enabled
276+
if data.SafeOutputs.CreateProjectStatusUpdates == nil {
277+
t.Fatalf("expected create-project-status-update safe output to be enabled")
278+
}
279+
if data.SafeOutputs.CreateProjectStatusUpdates.Max != 1 {
280+
t.Fatalf("unexpected create-project-status-update max: got %d, want %d", data.SafeOutputs.CreateProjectStatusUpdates.Max, 1)
281+
}
282+
283+
// Orchestrators should NOT have create-issue or add-comment (governance MaxCommentsPerRun doesn't grant add-comment)
284+
if data.SafeOutputs.CreateIssues != nil || data.SafeOutputs.AddComments != nil {
285+
t.Fatalf("expected orchestrator to omit create-issue and add-comment safe outputs regardless of governance")
258286
}
259287
})
260288
}

pkg/cli/compile_orchestrator.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,7 @@ func renderGeneratedCampaignOrchestratorMarkdown(data *workflow.WorkflowData, so
8080
fmt.Fprintf(b, "engine: %s\n", engineID)
8181

8282
// Render safe-outputs if configured by the campaign orchestrator generator.
83-
// Campaign orchestrators are dispatch-only: the only supported safe output is
84-
// dispatch-workflow.
83+
// Campaign orchestrators support dispatch-workflow, update-project, and create-project-status-update.
8584
if data.SafeOutputs != nil {
8685
// NOTE: We must emit the public frontmatter keys (e.g. "add-comment") rather
8786
// than the internal struct YAML tags (e.g. "add-comments").
@@ -95,6 +94,24 @@ func renderGeneratedCampaignOrchestratorMarkdown(data *workflow.WorkflowData, so
9594
}
9695
outputs["dispatch-workflow"] = dispatchWorkflowConfig
9796
}
97+
if data.SafeOutputs.UpdateProjects != nil {
98+
updateProjectConfig := map[string]any{
99+
"max": data.SafeOutputs.UpdateProjects.Max,
100+
}
101+
if data.SafeOutputs.UpdateProjects.GitHubToken != "" {
102+
updateProjectConfig["github-token"] = data.SafeOutputs.UpdateProjects.GitHubToken
103+
}
104+
outputs["update-project"] = updateProjectConfig
105+
}
106+
if data.SafeOutputs.CreateProjectStatusUpdates != nil {
107+
createStatusUpdateConfig := map[string]any{
108+
"max": data.SafeOutputs.CreateProjectStatusUpdates.Max,
109+
}
110+
if data.SafeOutputs.CreateProjectStatusUpdates.GitHubToken != "" {
111+
createStatusUpdateConfig["github-token"] = data.SafeOutputs.CreateProjectStatusUpdates.GitHubToken
112+
}
113+
outputs["create-project-status-update"] = createStatusUpdateConfig
114+
}
98115
if len(outputs) > 0 {
99116
payload := map[string]any{"safe-outputs": outputs}
100117
if out, err := yaml.Marshal(payload); err == nil {

pkg/parser/schemas/main_workflow_schema.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5849,9 +5849,9 @@
58495849
},
58505850
"max": {
58515851
"type": "integer",
5852-
"description": "Maximum number of workflow dispatch operations per run (default: 1, max: 3)",
5852+
"description": "Maximum number of workflow dispatch operations per run (default: 1, max: 50)",
58535853
"minimum": 1,
5854-
"maximum": 3,
5854+
"maximum": 50,
58555855
"default": 1
58565856
},
58575857
"github-token": {

pkg/workflow/dispatch_workflow.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,10 @@ func (c *Compiler) parseDispatchWorkflowConfig(outputMap map[string]any) *Dispat
5050
// Parse common base fields with default max of 1
5151
c.parseBaseSafeOutputConfig(configMap, &dispatchWorkflowConfig.BaseSafeOutputConfig, 1)
5252

53-
// Cap max at 3 (absolute maximum allowed)
54-
if dispatchWorkflowConfig.Max > 3 {
55-
dispatchWorkflowLog.Printf("Max value %d exceeds limit, capping at 3", dispatchWorkflowConfig.Max)
56-
dispatchWorkflowConfig.Max = 3
53+
// Cap max at 50 (absolute maximum allowed)
54+
if dispatchWorkflowConfig.Max > 50 {
55+
dispatchWorkflowLog.Printf("Max value %d exceeds limit, capping at 50", dispatchWorkflowConfig.Max)
56+
dispatchWorkflowConfig.Max = 50
5757
}
5858

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

0 commit comments

Comments
 (0)