diff --git a/.github/aw/bootstrap-agentic-campaign.md b/.github/aw/bootstrap-agentic-campaign.md new file mode 100644 index 00000000000..16b2e180237 --- /dev/null +++ b/.github/aw/bootstrap-agentic-campaign.md @@ -0,0 +1,154 @@ +# Bootstrap Instructions (Phase 0) + +This phase runs when discovery returns zero work items, providing initial work for the campaign to begin. + +{{ if .BootstrapMode }} +## Bootstrap Strategy: {{ .BootstrapMode }} + +{{ if eq .BootstrapMode "seeder-worker" }} +### Seeder Worker Dispatch + +When no work items are discovered, dispatch a seeder/scanner worker to discover initial work: + +**Worker ID**: `{{ .SeederWorkerID }}` + +**Payload**: +```json +{{ .SeederPayload }} +``` + +{{ if gt .SeederMaxItems 0 }} +**Max Items**: {{ .SeederMaxItems }} (limit how many items the seeder returns) +{{ end }} + +**Implementation Steps**: + +1. Check if discovery returned zero items (no worker outputs found) +2. If zero items: + - Use the `dispatch_workflow` safe output to trigger the seeder worker + - Pass the campaign_id and configured payload + - Wait for the seeder worker to complete and create initial work items +3. On next orchestrator run, the discovery step will find the seeder's outputs + +**Seeder Worker Contract**: +- MUST accept `campaign_id` and `payload` inputs (standard worker contract) +- MUST create discoverable outputs (issues, PRs, or discussions) +- MUST apply the tracker label: `campaign:{{ .CampaignID }}` +- SHOULD limit output count to configured max-items if provided +- SHOULD use deterministic work item keys for idempotency + +{{ else if eq .BootstrapMode "project-todos" }} +### Project Board Todo Items + +When no work items are discovered, read from the Project board's "{{ .TodoValue }}" column: + +{{ if .StatusField }} +**Status Field**: `{{ .StatusField }}` +{{ else }} +**Status Field**: `Status` (default) +{{ end }} + +{{ if .TodoValue }} +**Todo Value**: `{{ .TodoValue }}` +{{ else }} +**Todo Value**: `Todo` (default) +{{ end }} + +{{ if gt .TodoMaxItems 0 }} +**Max Items**: {{ .TodoMaxItems }} (limit how many Todo items to process) +{{ end }} + +{{ if .RequireFields }} +**Required Fields**: {{ range $index, $field := .RequireFields }}{{ if $index }}, {{ end }}`{{ $field }}`{{ end }} + - Skip Todo items where any of these fields are empty +{{ end }} + +**Implementation Steps**: + +1. Check if discovery returned zero items (no worker outputs found) +2. If zero items: + - Query the Project board at `{{ .ProjectURL }}` + - Filter items where Status = "{{ .TodoValue }}" + {{ if .RequireFields }}- Skip items missing required fields{{ end }} + {{ if gt .TodoMaxItems 0 }}- Limit to {{ .TodoMaxItems }} items{{ end }} + - Select workers based on item metadata (see Worker Selection below) + - Dispatch appropriate worker workflows for each Todo item +3. Update Project status to "In Progress" for dispatched items +4. On next orchestrator run, the discovery step will find the worker outputs + +**Project Item to Payload Mapping**: +- Read Project field values from the Todo item +- Map to worker payload schema based on worker metadata +- Include campaign_id in every worker dispatch +- Use the item's URL or number as the work_item_id + +{{ else if eq .BootstrapMode "manual" }} +### Manual Bootstrap + +No automatic bootstrap configured. Wait for manual work item creation: + +- Work items should be created manually (issues, PRs, or discussions) +- All items MUST have the tracker label: `campaign:{{ .CampaignID }}` +- Items MUST follow the worker output labeling contract +- Once items exist, the orchestrator will discover them normally + +{{ end }} +{{ end }} + +--- + +## Worker Selection + +{{ if .WorkerMetadata }} +When dispatching workers during bootstrap, use deterministic selection: + +{{ range $index, $worker := .WorkerMetadata }} +### Worker {{ add1 $index }}: {{ .ID }} + +**Capabilities**: {{ range $capIndex, $cap := .Capabilities }}{{ if $capIndex }}, {{ end }}`{{ $cap }}`{{ end }} + +**Payload Schema**: +{{ range $fieldName, $fieldDef := .PayloadSchema }}- `{{ $fieldName }}` ({{ .Type }}{{ if .Required }}, required{{ end }}): {{ .Description }} +{{ end }} + +**Output Labeling**: +{{ if .OutputLabeling.Labels }}- Labels: {{ range $labelIndex, $label := .OutputLabeling.Labels }}{{ if $labelIndex }}, {{ end }}`{{ $label }}`{{ end }} +{{ end }}- Key in Title: {{ .OutputLabeling.KeyInTitle }} +{{ if .OutputLabeling.KeyFormat }}- Key Format: `{{ .OutputLabeling.KeyFormat }}` +{{ end }} +- Campaign tracker label applied automatically: `campaign:{{ $.CampaignID }}` + +**Idempotency Strategy**: {{ .IdempotencyStrategy }} + +{{ if .Priority }}**Priority**: {{ .Priority }} (higher = preferred when multiple workers match) +{{ end }} + +{{ end }} + +**Selection Algorithm**: + +1. For each work item, check which workers can handle it: + - Match work item type/metadata to worker capabilities + - Check if worker's payload schema requirements can be satisfied +2. If multiple workers match: + - Select the worker with highest priority + - If priorities are equal, select first alphabetically by ID +3. Build payload from work item metadata according to worker's payload schema +4. Dispatch worker with campaign_id and constructed payload + +{{ else }} +**Note**: No worker metadata configured. Use workflow IDs from campaign spec for dispatch. +{{ end }} + +--- + +## Bootstrap Success Criteria + +After bootstrap completes: + +1. ✅ At least one worker workflow dispatched (or manual items created) +2. ✅ All dispatched items will have proper tracker labels +3. ✅ Next orchestrator run will discover >= 1 work item +4. ✅ Campaign transitions from bootstrap phase to normal operation + +**Idempotency**: Bootstrap only runs when discovery = 0. Once work items exist, normal orchestration takes over. diff --git a/docs/bootstrap-implementation-summary.md b/docs/bootstrap-implementation-summary.md new file mode 100644 index 00000000000..8c61e24583f --- /dev/null +++ b/docs/bootstrap-implementation-summary.md @@ -0,0 +1,354 @@ +# Bootstrap + Planning Model: Implementation Summary + +This document provides a comprehensive overview of the bootstrap and planning model implementation for campaign generator/orchestrator workflows. + +## Problem Statement + +The generator/orchestrator needed an explicit bootstrap + planning model to address these scenarios: + +1. **Zero Discovery**: What happens when discovery returns 0 work items? +2. **Initial Work Creation**: How to create/select initial work items? +3. **Worker Selection**: How to deterministically choose which worker to run? +4. **Output Discoverability**: How to guarantee worker outputs are discoverable and attributable? + +## Solution Architecture + +### 1. Bootstrap Configuration + +When discovery returns 0 items, the orchestrator can use one of three bootstrap strategies: + +#### Mode: seeder-worker + +Dispatch a specialized worker to discover and create initial work: + +```yaml +bootstrap: + mode: seeder-worker + seeder-worker: + workflow-id: security-scanner + payload: + scan-type: full + ecosystems: [npm, pip, go] + max-items: 50 +``` + +**Flow**: +1. Orchestrator detects `discovery_count == 0` +2. Orchestrator dispatches seeder worker via `dispatch_workflow` safe output +3. Seeder scans repositories/systems and creates issues/PRs +4. Seeder applies tracker labels to all created items +5. Next orchestrator run discovers seeder's outputs (discovery > 0) + +#### Mode: project-todos + +Read work items from Project board's "Todo" column: + +```yaml +bootstrap: + mode: project-todos + project-todos: + status-field: Status + todo-value: Backlog + max-items: 10 + require-fields: [Priority, Assignee] +``` + +**Flow**: +1. Orchestrator detects `discovery_count == 0` +2. Orchestrator queries Project board for Status = "Backlog" +3. Orchestrator filters items with missing required fields +4. Orchestrator uses worker metadata to select appropriate worker +5. Orchestrator builds payload from Project field values +6. Orchestrator dispatches workers for each Todo item +7. Workers create issues/PRs with proper tracker labels +8. Next orchestrator run discovers worker outputs + +#### Mode: manual + +Wait for manual work item creation: + +```yaml +bootstrap: + mode: manual +``` + +**Flow**: +1. Orchestrator detects `discovery_count == 0` +2. Orchestrator reports waiting for manual work items +3. Users manually create issues/PRs with tracker labels +4. Next orchestrator run discovers manual items + +### 2. Worker Metadata + +Worker metadata provides a standardized payload schema and output contract: + +```yaml +workers: + - id: security-fixer + name: Security Fix Worker + description: Fixes security vulnerabilities + + # What this worker can do + capabilities: + - fix-security-alerts + - create-pull-requests + + # Expected payload structure + payload-schema: + repository: + type: string + description: Target repository (owner/repo) + required: true + example: owner/repo + alert_id: + type: string + description: Alert identifier + required: true + example: alert-123 + severity: + type: string + description: Alert severity + required: false + example: high + + # Output labeling contract + output-labeling: + labels: [security, automated] + key-in-title: true + key-format: "campaign-{campaign_id}-{repository}-{alert_id}" + metadata-fields: [Campaign Id, Worker Workflow, Alert ID] + + # Idempotency strategy + idempotency-strategy: pr-title-based + + # Selection priority (higher = preferred) + priority: 10 +``` + +Note: The campaign's tracker-label (defined at the campaign level) is automatically applied to all worker outputs. + +### 3. Deterministic Worker Selection + +When multiple workers exist, the orchestrator selects deterministically: + +**Algorithm**: +```python +def select_worker(work_item, workers): + # Step 1: Filter by capabilities + matching_workers = [w for w in workers + if can_handle(w.capabilities, work_item.type)] + + # Step 2: Validate payload requirements + valid_workers = [w for w in matching_workers + if can_build_payload(w.payload_schema, work_item)] + + # Step 3: Sort by priority (descending) + valid_workers.sort(key=lambda w: w.priority, reverse=True) + + # Step 4: Select highest priority (or first alphabetically) + if len(valid_workers) > 0: + return valid_workers[0] + + return None +``` + +**Example**: Given a security alert work item: + +1. **Match capabilities**: Both `security-scanner` and `security-fixer` match +2. **Validate payload**: Both can satisfy required fields +3. **Sort by priority**: `security-fixer` (priority 10) > `security-scanner` (priority 5) +4. **Select**: Dispatch `security-fixer` + +### 4. Output Labeling Contract + +Workers guarantee outputs are discoverable via: + +#### Tracker Label + +Format: `campaign:{campaign_id}` + +- Applied to ALL worker-created items +- Enables discovery by campaign orchestrator +- Isolates campaign items from other workflows + +#### Deterministic Keys + +Format defined by `key-format` in worker metadata: + +``` +campaign-{campaign_id}-{repository}-{work_item_id} +``` + +- Included in issue/PR titles: `[{key}] {description}` +- Enables idempotency checks before creation +- Allows duplicate detection across runs + +#### Metadata Fields + +Workers populate Project fields for tracking: + +- `Campaign Id`: Links to campaign +- `Worker Workflow`: Which worker created this +- Custom fields: Alert ID, Severity, Package Name, etc. + +## End-to-End Example + +### Scenario: Security Alert Burndown Campaign + +**Campaign Configuration**: +```yaml +id: security-q1-2025 +name: Security Alert Burndown +project-url: https://github.com/orgs/example/projects/1 + +bootstrap: + mode: seeder-worker + seeder-worker: + workflow-id: security-scanner + payload: + severity: high + max-alerts: 20 + +workers: + - id: security-scanner + capabilities: [scan-security-alerts] + payload-schema: + severity: {type: string, required: true} + max-alerts: {type: number, required: false} + output-labeling: + key-in-title: true + key-format: "scan-{repository}" + idempotency-strategy: issue-title-based + priority: 5 + + - id: security-fixer + capabilities: [fix-security-alerts, create-pull-requests] + payload-schema: + repository: {type: string, required: true} + alert_id: {type: string, required: true} + output-labeling: + key-in-title: true + key-format: "campaign-{campaign_id}-{repository}-{alert_id}" + idempotency-strategy: pr-title-based + priority: 10 + +tracker-label: campaign:security-q1-2025 +``` + +Note: The tracker-label is defined once at the campaign level and automatically applied by all workers. + +**Execution Flow**: + +1. **Run 1: Bootstrap (discovery = 0)** + - Orchestrator detects no work items + - Dispatches `security-scanner` with `{severity: "high", max-alerts: 20}` + - Scanner finds 15 high-severity alerts + - Scanner creates 15 issues with: + - Label: `campaign:security-q1-2025` + - Title: `[scan-owner-repo] High severity alerts found` + +2. **Run 2: Discovery (discovery = 15)** + - Discovery finds 15 issues with tracker label + - Orchestrator reads issue metadata + - For each issue, orchestrator: + - Parses alert details from issue body + - Selects `security-fixer` (highest priority with matching capabilities) + - Builds payload: `{repository: "owner/repo", alert_id: "alert-123"}` + - Dispatches `security-fixer` + - Each fixer worker: + - Checks for existing PR with key in title + - Creates PR if not exists + - Applies labels and metadata + +3. **Run 3+: Normal Operation (discovery > 0)** + - Discovery finds PRs created by workers + - Orchestrator updates Project board + - Tracks progress via KPIs + - Reports on completion + +## Implementation Details + +### Code Structure + +``` +pkg/campaign/ +├── spec.go # CampaignBootstrapConfig, WorkerMetadata types +├── orchestrator.go # Bootstrap integration +├── template.go # RenderBootstrapInstructions() +├── schemas/ +│ └── campaign_spec_schema.json # JSON schema validation +└── bootstrap_test.go # Unit tests + +.github/aw/ +└── bootstrap-agentic-campaign.md # Bootstrap template + +docs/ +├── campaign-workers.md # Updated documentation +└── src/content/docs/examples/campaigns/ + └── dependency-upgrade-example.campaign.md # Example +``` + +### Key Types + +```go +type CampaignBootstrapConfig struct { + Mode string + SeederWorker *SeederWorkerConfig + ProjectTodos *ProjectTodosConfig +} + +type WorkerMetadata struct { + ID string + Capabilities []string + PayloadSchema map[string]WorkerPayloadField + OutputLabeling WorkerOutputLabeling + IdempotencyStrategy string + Priority int +} + +type WorkerOutputLabeling struct { + Labels []string + KeyInTitle bool + KeyFormat string + MetadataFields []string +} +``` + +## Testing + +13 unit tests covering: +- ✅ Bootstrap config parsing (3 modes) +- ✅ Worker metadata parsing +- ✅ Payload schema validation +- ✅ Output labeling contracts +- ✅ JSON/YAML serialization +- ✅ Combined bootstrap + workers scenarios + +All tests passing in `pkg/campaign/bootstrap_test.go`. + +## Benefits + +1. **Explicit Bootstrap**: No ambiguity about how campaigns start +2. **Deterministic Selection**: Workers chosen by capabilities and priority +3. **Guaranteed Discoverability**: Output labeling contract ensures items are found +4. **Idempotency**: Keys and strategies prevent duplicate work +5. **Flexibility**: Three bootstrap modes for different scenarios +6. **Type Safety**: Payload schemas validated at campaign definition time + +## Migration Guide + +Existing campaigns continue to work without changes. To adopt the new features: + +1. **Add Bootstrap**: Choose a mode based on your scenario +2. **Define Workers**: Document capabilities and schemas +3. **Test**: Manually trigger with discovery = 0 to verify bootstrap +4. **Iterate**: Adjust worker priorities and payload schemas as needed + +## Future Enhancements + +Potential improvements: +- [ ] Runtime payload schema validation +- [ ] Worker capability matching with semantic rules +- [ ] Auto-generation of worker metadata from workflow analysis +- [ ] Bootstrap mode: `hybrid` (try project-todos, fallback to seeder-worker) +- [ ] Worker selection telemetry and optimization diff --git a/docs/campaign-workers.md b/docs/campaign-workers.md index 5a9774f2932..99088d32f72 100644 --- a/docs/campaign-workers.md +++ b/docs/campaign-workers.md @@ -1,6 +1,6 @@ # Campaign Workers -Campaign workers are first-class workflows designed to be orchestrated by campaign orchestrators. This document describes the worker pattern, input contract, and idempotency requirements. +Campaign workers are first-class workflows designed to be orchestrated by campaign orchestrators. This document describes the worker pattern, input contract, idempotency requirements, and the bootstrap + worker metadata system. ## Overview @@ -10,6 +10,147 @@ Campaign workers follow these principles: 2. **Standardized contract**: All workers accept `campaign_id` and `payload` inputs 3. **Idempotent**: Workers use deterministic keys to avoid duplicate work 4. **Orchestration-agnostic**: Workers don't encode orchestration policy +5. **Discoverable**: Workers produce outputs with guaranteed labeling contracts + +## Bootstrap + Planning Model + +When a campaign starts with zero discovered work items (discovery = 0), the orchestrator needs a way to create initial work. The bootstrap configuration provides three strategies: + +### 1. Seeder Worker Mode + +Dispatch a specialized worker to discover and create initial work items: + +```yaml +bootstrap: + mode: seeder-worker + seeder-worker: + workflow-id: security-scanner + payload: + scan-type: full + max-findings: 100 + max-items: 50 +``` + +**Flow**: +1. Orchestrator detects discovery = 0 +2. Orchestrator dispatches the seeder worker with configured payload +3. Seeder worker scans for work and creates issues/PRs with tracker labels +4. Next orchestrator run discovers the seeder's outputs + +### 2. Project Todos Mode + +Read work items from the Project board's "Todo" column: + +```yaml +bootstrap: + mode: project-todos + project-todos: + status-field: Status + todo-value: Backlog + max-items: 10 + require-fields: + - Priority + - Assignee +``` + +**Flow**: +1. Orchestrator detects discovery = 0 +2. Orchestrator queries Project board for items with Status = "Backlog" +3. Orchestrator uses worker metadata to select appropriate worker for each item +4. Orchestrator dispatches workers with payloads built from Project field values + +### 3. Manual Mode + +Wait for manual work item creation: + +```yaml +bootstrap: + mode: manual +``` + +**Flow**: +1. Orchestrator detects discovery = 0 +2. Orchestrator reports waiting for manual work item creation +3. Users manually create issues/PRs with proper tracker labels +4. Next orchestrator run discovers the manual items + +## Worker Metadata + +Worker metadata enables deterministic worker selection and ensures worker outputs are discoverable. Define worker metadata in your campaign spec: + +```yaml +workers: + - id: security-fixer + name: Security Fix Worker + description: Fixes security vulnerabilities + capabilities: + - fix-security-alerts + - create-pull-requests + payload-schema: + repository: + type: string + description: Target repository in owner/repo format + required: true + example: owner/repo + alert_id: + type: string + description: Security alert identifier + required: true + example: alert-123 + severity: + type: string + description: Alert severity level + required: false + example: high + output-labeling: + labels: + - security + - automated + key-in-title: true + key-format: "campaign-{campaign_id}-{repository}-{alert_id}" + metadata-fields: + - Campaign Id + - Worker Workflow + - Alert ID + - Severity + idempotency-strategy: pr-title-based + priority: 10 +``` + +### Worker Metadata Fields + +- **id**: Workflow identifier (basename without .md) +- **name**: Human-readable worker name +- **description**: What the worker does +- **capabilities**: List of work types this worker can handle +- **payload-schema**: Expected payload structure with types and descriptions +- **output-labeling**: Guaranteed labeling contract for worker outputs +- **idempotency-strategy**: How the worker ensures idempotent execution +- **priority**: Worker selection priority (higher = preferred) + +### Deterministic Worker Selection + +When the orchestrator needs to dispatch a worker (e.g., during bootstrap from Project todos): + +1. **Match capabilities**: Find workers whose capabilities match the work item type +2. **Validate payload**: Check if worker's payload schema can be satisfied from available data +3. **Select by priority**: If multiple workers match, select the one with highest priority +4. **Build payload**: Construct payload according to worker's payload schema +5. **Dispatch**: Call worker with campaign_id and constructed payload + +### Output Labeling Contract + +The `output-labeling` section guarantees how worker outputs are labeled and formatted: + +- **labels**: Labels the worker applies to created items (in addition to the campaign's tracker-label) +- **key-in-title**: Whether worker includes a deterministic key in item titles +- **key-format**: Format of the key when `key-in-title` is true +- **metadata-fields**: Project fields the worker populates + +Workers automatically apply the campaign's tracker label (defined at the campaign level) to all created items, ensuring: +- **Discoverable**: Can be found via tracker label searches +- **Attributable**: Can be traced back to the campaign and worker +- **Idempotent**: Can be checked for duplicates via deterministic keys ## Why Dispatch-Only? diff --git a/go.mod b/go.mod index 8189ed8d50a..e73d99feac9 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/sourcegraph/conc v0.3.0 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 + go.yaml.in/yaml/v3 v3.0.4 golang.org/x/crypto v0.47.0 golang.org/x/mod v0.32.0 golang.org/x/term v0.39.0 @@ -264,7 +265,6 @@ require ( go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/net v0.48.0 // indirect diff --git a/pkg/campaign/bootstrap_test.go b/pkg/campaign/bootstrap_test.go new file mode 100644 index 00000000000..e7daa9ca327 --- /dev/null +++ b/pkg/campaign/bootstrap_test.go @@ -0,0 +1,456 @@ +package campaign + +import ( + "encoding/json" + "testing" + + "go.yaml.in/yaml/v3" +) + +// TestBootstrapConfig_SeederWorker tests parsing bootstrap config with seeder-worker mode +func TestBootstrapConfig_SeederWorker(t *testing.T) { + yamlContent := `--- +id: test-bootstrap-seeder +name: Test Bootstrap Seeder +project-url: https://github.com/orgs/test/projects/1 +bootstrap: + mode: seeder-worker + seeder-worker: + workflow-id: security-scanner + payload: + scan-type: full + max-findings: 100 + max-items: 50 +--- + +# Test Campaign` + + var spec CampaignSpec + err := yaml.Unmarshal([]byte(yamlContent), &spec) + if err != nil { + t.Fatalf("Failed to parse bootstrap seeder config: %v", err) + } + + if spec.Bootstrap == nil { + t.Fatal("Bootstrap config should not be nil") + } + if spec.Bootstrap.Mode != "seeder-worker" { + t.Errorf("Expected mode 'seeder-worker', got '%s'", spec.Bootstrap.Mode) + } + if spec.Bootstrap.SeederWorker == nil { + t.Fatal("SeederWorker config should not be nil") + } + if spec.Bootstrap.SeederWorker.WorkflowID != "security-scanner" { + t.Errorf("Expected workflow-id 'security-scanner', got '%s'", spec.Bootstrap.SeederWorker.WorkflowID) + } + if spec.Bootstrap.SeederWorker.MaxItems != 50 { + t.Errorf("Expected max-items 50, got %d", spec.Bootstrap.SeederWorker.MaxItems) + } + if len(spec.Bootstrap.SeederWorker.Payload) == 0 { + t.Error("Payload should not be empty") + } +} + +// TestBootstrapConfig_ProjectTodos tests parsing bootstrap config with project-todos mode +func TestBootstrapConfig_ProjectTodos(t *testing.T) { + yamlContent := `--- +id: test-bootstrap-todos +name: Test Bootstrap Todos +project-url: https://github.com/orgs/test/projects/1 +bootstrap: + mode: project-todos + project-todos: + status-field: Status + todo-value: Backlog + max-items: 10 + require-fields: + - Priority + - Assignee +--- + +# Test Campaign` + + var spec CampaignSpec + err := yaml.Unmarshal([]byte(yamlContent), &spec) + if err != nil { + t.Fatalf("Failed to parse bootstrap todos config: %v", err) + } + + if spec.Bootstrap == nil { + t.Fatal("Bootstrap config should not be nil") + } + if spec.Bootstrap.Mode != "project-todos" { + t.Errorf("Expected mode 'project-todos', got '%s'", spec.Bootstrap.Mode) + } + if spec.Bootstrap.ProjectTodos == nil { + t.Fatal("ProjectTodos config should not be nil") + } + if spec.Bootstrap.ProjectTodos.StatusField != "Status" { + t.Errorf("Expected status-field 'Status', got '%s'", spec.Bootstrap.ProjectTodos.StatusField) + } + if spec.Bootstrap.ProjectTodos.TodoValue != "Backlog" { + t.Errorf("Expected todo-value 'Backlog', got '%s'", spec.Bootstrap.ProjectTodos.TodoValue) + } + if spec.Bootstrap.ProjectTodos.MaxItems != 10 { + t.Errorf("Expected max-items 10, got %d", spec.Bootstrap.ProjectTodos.MaxItems) + } + if len(spec.Bootstrap.ProjectTodos.RequireFields) != 2 { + t.Errorf("Expected 2 require-fields, got %d", len(spec.Bootstrap.ProjectTodos.RequireFields)) + } +} + +// TestBootstrapConfig_Manual tests parsing bootstrap config with manual mode +func TestBootstrapConfig_Manual(t *testing.T) { + yamlContent := `--- +id: test-bootstrap-manual +name: Test Bootstrap Manual +project-url: https://github.com/orgs/test/projects/1 +bootstrap: + mode: manual +--- + +# Test Campaign` + + var spec CampaignSpec + err := yaml.Unmarshal([]byte(yamlContent), &spec) + if err != nil { + t.Fatalf("Failed to parse bootstrap manual config: %v", err) + } + + if spec.Bootstrap == nil { + t.Fatal("Bootstrap config should not be nil") + } + if spec.Bootstrap.Mode != "manual" { + t.Errorf("Expected mode 'manual', got '%s'", spec.Bootstrap.Mode) + } +} + +// TestWorkerMetadata_Basic tests parsing basic worker metadata +func TestWorkerMetadata_Basic(t *testing.T) { + yamlContent := `--- +id: test-workers +name: Test Workers +project-url: https://github.com/orgs/test/projects/1 +workers: + - id: security-fixer + name: Security Fix Worker + description: Fixes security vulnerabilities + capabilities: + - fix-security-alerts + - create-pull-requests + payload-schema: + repository: + type: string + description: Target repository in owner/repo format + required: true + example: owner/repo + work_item_id: + type: string + description: Unique work item identifier + required: true + example: alert-123 + severity: + type: string + description: Alert severity level + required: false + example: high + output-labeling: + labels: + - security + - automated + key-in-title: true + key-format: "campaign-{campaign_id}-{repository}-{work_item_id}" + metadata-fields: + - Campaign Id + - Worker Workflow + idempotency-strategy: pr-title-based + priority: 10 +--- + +# Test Campaign` + + var spec CampaignSpec + err := yaml.Unmarshal([]byte(yamlContent), &spec) + if err != nil { + t.Fatalf("Failed to parse worker metadata: %v", err) + } + + if len(spec.Workers) == 0 { + t.Fatal("Workers should not be empty") + } + + worker := spec.Workers[0] + if worker.ID != "security-fixer" { + t.Errorf("Expected worker ID 'security-fixer', got '%s'", worker.ID) + } + if worker.Name != "Security Fix Worker" { + t.Errorf("Expected worker name 'Security Fix Worker', got '%s'", worker.Name) + } + if len(worker.Capabilities) != 2 { + t.Errorf("Expected 2 capabilities, got %d", len(worker.Capabilities)) + } + if len(worker.PayloadSchema) != 3 { + t.Errorf("Expected 3 payload schema fields, got %d", len(worker.PayloadSchema)) + } + + // Check payload schema field + repoField, exists := worker.PayloadSchema["repository"] + if !exists { + t.Error("Expected 'repository' field in payload schema") + } + if repoField.Type != "string" { + t.Errorf("Expected repository type 'string', got '%s'", repoField.Type) + } + if !repoField.Required { + t.Error("Expected repository field to be required") + } + + // Check output labeling + if !worker.OutputLabeling.KeyInTitle { + t.Error("Expected key-in-title to be true") + } + if len(worker.OutputLabeling.Labels) != 2 { + t.Errorf("Expected 2 labels, got %d", len(worker.OutputLabeling.Labels)) + } + if len(worker.OutputLabeling.MetadataFields) != 2 { + t.Errorf("Expected 2 metadata fields, got %d", len(worker.OutputLabeling.MetadataFields)) + } + + // Check idempotency strategy + if worker.IdempotencyStrategy != "pr-title-based" { + t.Errorf("Expected idempotency strategy 'pr-title-based', got '%s'", worker.IdempotencyStrategy) + } + + // Check priority + if worker.Priority != 10 { + t.Errorf("Expected priority 10, got %d", worker.Priority) + } +} + +// TestWorkerMetadata_Multiple tests parsing multiple workers +func TestWorkerMetadata_Multiple(t *testing.T) { + yamlContent := `--- +id: test-multi-workers +name: Test Multiple Workers +project-url: https://github.com/orgs/test/projects/1 +workers: + - id: worker-one + capabilities: [scan] + payload-schema: + target: + type: string + description: Target to scan + required: true + output-labeling: + key-in-title: false + idempotency-strategy: branch-based + - id: worker-two + capabilities: [fix] + payload-schema: + issue: + type: number + description: Issue number + required: true + output-labeling: + key-in-title: true + key-format: "fix-{issue}" + idempotency-strategy: issue-title-based + priority: 5 +--- + +# Test Campaign` + + var spec CampaignSpec + err := yaml.Unmarshal([]byte(yamlContent), &spec) + if err != nil { + t.Fatalf("Failed to parse multiple workers: %v", err) + } + + if len(spec.Workers) != 2 { + t.Fatalf("Expected 2 workers, got %d", len(spec.Workers)) + } + + // Check first worker + if spec.Workers[0].ID != "worker-one" { + t.Errorf("Expected first worker ID 'worker-one', got '%s'", spec.Workers[0].ID) + } + if spec.Workers[0].IdempotencyStrategy != "branch-based" { + t.Errorf("Expected first worker idempotency 'branch-based', got '%s'", spec.Workers[0].IdempotencyStrategy) + } + + // Check second worker + if spec.Workers[1].ID != "worker-two" { + t.Errorf("Expected second worker ID 'worker-two', got '%s'", spec.Workers[1].ID) + } + if spec.Workers[1].Priority != 5 { + t.Errorf("Expected second worker priority 5, got %d", spec.Workers[1].Priority) + } +} + +// TestBootstrapAndWorkers_Combined tests campaign with both bootstrap and workers +func TestBootstrapAndWorkers_Combined(t *testing.T) { + yamlContent := `--- +id: test-combined +name: Test Combined +project-url: https://github.com/orgs/test/projects/1 +bootstrap: + mode: seeder-worker + seeder-worker: + workflow-id: scanner + payload: + mode: discovery +workers: + - id: scanner + capabilities: [scan] + payload-schema: + mode: + type: string + description: Scan mode + required: true + output-labeling: + key-in-title: true + idempotency-strategy: cursor-based + - id: fixer + capabilities: [fix] + payload-schema: + alert_id: + type: string + description: Alert ID + required: true + output-labeling: + key-in-title: true + idempotency-strategy: pr-title-based + priority: 10 +--- + +# Test Campaign` + + var spec CampaignSpec + err := yaml.Unmarshal([]byte(yamlContent), &spec) + if err != nil { + t.Fatalf("Failed to parse combined config: %v", err) + } + + // Verify bootstrap + if spec.Bootstrap == nil { + t.Fatal("Bootstrap should not be nil") + } + if spec.Bootstrap.Mode != "seeder-worker" { + t.Errorf("Expected bootstrap mode 'seeder-worker', got '%s'", spec.Bootstrap.Mode) + } + if spec.Bootstrap.SeederWorker.WorkflowID != "scanner" { + t.Errorf("Expected seeder workflow-id 'scanner', got '%s'", spec.Bootstrap.SeederWorker.WorkflowID) + } + + // Verify workers + if len(spec.Workers) != 2 { + t.Fatalf("Expected 2 workers, got %d", len(spec.Workers)) + } + + // Check that seeder worker exists + var scannerFound bool + for _, worker := range spec.Workers { + if worker.ID == "scanner" { + scannerFound = true + if len(worker.Capabilities) != 1 || worker.Capabilities[0] != "scan" { + t.Error("Scanner worker should have 'scan' capability") + } + } + } + if !scannerFound { + t.Error("Scanner worker not found in workers list") + } +} + +// TestBootstrapConfig_JSONSerialization tests JSON marshaling/unmarshaling +func TestBootstrapConfig_JSONSerialization(t *testing.T) { + original := CampaignSpec{ + ID: "test-json", + Name: "Test JSON", + ProjectURL: "https://github.com/orgs/test/projects/1", + Bootstrap: &CampaignBootstrapConfig{ + Mode: "seeder-worker", + SeederWorker: &SeederWorkerConfig{ + WorkflowID: "scanner", + Payload: map[string]any{ + "type": "full-scan", + "max": 100, + }, + MaxItems: 50, + }, + }, + Workers: []WorkerMetadata{ + { + ID: "test-worker", + Capabilities: []string{"scan"}, + PayloadSchema: map[string]WorkerPayloadField{ + "target": { + Type: "string", + Description: "Target to scan", + Required: true, + }, + }, + OutputLabeling: WorkerOutputLabeling{ + KeyInTitle: true, + }, + IdempotencyStrategy: "branch-based", + }, + }, + } + + // Marshal to JSON + jsonData, err := json.Marshal(original) + if err != nil { + t.Fatalf("Failed to marshal to JSON: %v", err) + } + + // Unmarshal from JSON + var restored CampaignSpec + err = json.Unmarshal(jsonData, &restored) + if err != nil { + t.Fatalf("Failed to unmarshal from JSON: %v", err) + } + + // Verify fields + if restored.Bootstrap == nil { + t.Fatal("Restored bootstrap should not be nil") + } + if restored.Bootstrap.Mode != original.Bootstrap.Mode { + t.Errorf("Bootstrap mode mismatch: got '%s', want '%s'", restored.Bootstrap.Mode, original.Bootstrap.Mode) + } + if len(restored.Workers) != len(original.Workers) { + t.Errorf("Worker count mismatch: got %d, want %d", len(restored.Workers), len(original.Workers)) + } +} + +// TestWorkerPayloadField_RequiredDefaultsFalse tests that Required defaults to false +func TestWorkerPayloadField_RequiredDefaultsFalse(t *testing.T) { + yamlContent := `--- +id: test-required +name: Test Required Default +project-url: https://github.com/orgs/test/projects/1 +workers: + - id: test + capabilities: [test] + payload-schema: + optional_field: + type: string + description: Optional field without explicit required + output-labeling: + key-in-title: false + idempotency-strategy: branch-based +--- + +# Test` + + var spec CampaignSpec + err := yaml.Unmarshal([]byte(yamlContent), &spec) + if err != nil { + t.Fatalf("Failed to parse: %v", err) + } + + field := spec.Workers[0].PayloadSchema["optional_field"] + if field.Required { + t.Error("Expected Required to default to false") + } +} diff --git a/pkg/campaign/orchestrator.go b/pkg/campaign/orchestrator.go index 212223511b8..7d9898e292d 100644 --- a/pkg/campaign/orchestrator.go +++ b/pkg/campaign/orchestrator.go @@ -325,6 +325,50 @@ func BuildOrchestrator(spec *CampaignSpec, campaignFilePath string) (*workflow.W promptData.MaxProjectCommentsPerRun = spec.Governance.MaxCommentsPerRun } + // Add bootstrap configuration if present + if spec.Bootstrap != nil { + promptData.BootstrapMode = spec.Bootstrap.Mode + if spec.Bootstrap.SeederWorker != nil { + promptData.SeederWorkerID = spec.Bootstrap.SeederWorker.WorkflowID + // Convert payload map to JSON string for template rendering + if len(spec.Bootstrap.SeederWorker.Payload) > 0 { + payloadBytes, err := yaml.Marshal(spec.Bootstrap.SeederWorker.Payload) + if err == nil { + promptData.SeederPayload = string(payloadBytes) + } + } + promptData.SeederMaxItems = spec.Bootstrap.SeederWorker.MaxItems + } + if spec.Bootstrap.ProjectTodos != nil { + promptData.StatusField = spec.Bootstrap.ProjectTodos.StatusField + if promptData.StatusField == "" { + promptData.StatusField = "Status" + } + promptData.TodoValue = spec.Bootstrap.ProjectTodos.TodoValue + if promptData.TodoValue == "" { + promptData.TodoValue = "Todo" + } + promptData.TodoMaxItems = spec.Bootstrap.ProjectTodos.MaxItems + promptData.RequireFields = spec.Bootstrap.ProjectTodos.RequireFields + } + } + + // Add worker metadata if present + if len(spec.Workers) > 0 { + promptData.WorkerMetadata = spec.Workers + } + + // Render bootstrap instructions if bootstrap is configured + if spec.Bootstrap != nil && spec.Bootstrap.Mode != "" { + bootstrapInstructions := RenderBootstrapInstructions(promptData) + if bootstrapInstructions == "" { + orchestratorLog.Print("Warning: Failed to render bootstrap instructions, template may be missing") + } else { + appendPromptSection(markdownBuilder, "BOOTSTRAP INSTRUCTIONS (PHASE 0)", bootstrapInstructions) + orchestratorLog.Printf("Campaign '%s' orchestrator includes bootstrap mode: %s", spec.ID, spec.Bootstrap.Mode) + } + } + // All campaigns include workflow execution capabilities // The orchestrator can dispatch workflows and make decisions regardless of initial configuration workflowExecution := RenderWorkflowExecution(promptData) diff --git a/pkg/campaign/schemas/campaign_spec_schema.json b/pkg/campaign/schemas/campaign_spec_schema.json index aebdf1638b8..1b128ce517f 100644 --- a/pkg/campaign/schemas/campaign_spec_schema.json +++ b/pkg/campaign/schemas/campaign_spec_schema.json @@ -242,6 +242,182 @@ }, "additionalProperties": false }, + "engine": { + "type": "string", + "description": "AI engine to use for the campaign orchestrator", + "enum": ["copilot", "claude", "codex", "custom"] + }, + "bootstrap": { + "type": "object", + "description": "Initial work item creation strategy when discovery returns zero items", + "properties": { + "mode": { + "type": "string", + "description": "Bootstrap strategy: seeder-worker (dispatch scanner), project-todos (read from Project board), manual (skip bootstrap)", + "enum": ["seeder-worker", "project-todos", "manual"] + }, + "seeder-worker": { + "type": "object", + "description": "Configuration for dispatching a seeder/scanner worker. Only used when mode is 'seeder-worker'.", + "properties": { + "workflow-id": { + "type": "string", + "description": "Workflow identifier (basename without .md) to dispatch", + "minLength": 1 + }, + "payload": { + "type": "object", + "description": "JSON payload to send to the seeder worker" + }, + "max-items": { + "type": "integer", + "description": "Maximum number of work items the seeder should return (0 = worker default)", + "minimum": 0 + } + }, + "required": ["workflow-id", "payload"], + "additionalProperties": false + }, + "project-todos": { + "type": "object", + "description": "Configuration for reading from Project board's Todo column. Only used when mode is 'project-todos'.", + "properties": { + "status-field": { + "type": "string", + "description": "Name of the Project status field to filter on (default: 'Status')", + "minLength": 1 + }, + "todo-value": { + "type": "string", + "description": "Status value indicating a work item is ready to start (default: 'Todo')", + "minLength": 1 + }, + "max-items": { + "type": "integer", + "description": "Maximum number of Todo items to process per run (0 = governance default)", + "minimum": 0 + }, + "require-fields": { + "type": "array", + "description": "Project field names that must be populated for an item to be valid work", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": false + } + }, + "required": ["mode"], + "additionalProperties": false + }, + "workers": { + "type": "array", + "description": "Worker workflow metadata including capabilities, payload schemas, and output labeling contracts", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Workflow identifier (basename without .md)", + "minLength": 1 + }, + "name": { + "type": "string", + "description": "Human-readable worker name", + "minLength": 1 + }, + "description": { + "type": "string", + "description": "Description of what the worker does", + "minLength": 1 + }, + "capabilities": { + "type": "array", + "description": "Types of work this worker can perform", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1 + }, + "payload-schema": { + "type": "object", + "description": "Expected structure of the JSON payload", + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Field data type", + "enum": ["string", "number", "boolean", "array", "object"] + }, + "description": { + "type": "string", + "description": "Field description", + "minLength": 1 + }, + "required": { + "type": "boolean", + "description": "Whether this field is required" + }, + "example": { + "description": "Sample value for this field" + } + }, + "required": ["type", "description"], + "additionalProperties": false + } + }, + "output-labeling": { + "type": "object", + "description": "Labeling and metadata contract for worker outputs", + "properties": { + "labels": { + "type": "array", + "description": "Labels the worker applies to created items (in addition to the campaign's tracker-label)", + "items": { + "type": "string", + "minLength": 1 + } + }, + "key-in-title": { + "type": "boolean", + "description": "Whether worker includes deterministic key in title" + }, + "key-format": { + "type": "string", + "description": "Key format when key-in-title is true", + "minLength": 1 + }, + "metadata-fields": { + "type": "array", + "description": "Project fields the worker populates", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "required": ["key-in-title"], + "additionalProperties": false + }, + "idempotency-strategy": { + "type": "string", + "description": "How the worker ensures idempotent execution", + "enum": ["branch-based", "pr-title-based", "issue-title-based", "cursor-based"] + }, + "priority": { + "type": "integer", + "description": "Worker priority for deterministic selection (higher = higher priority)", + "minimum": 0 + } + }, + "required": ["id", "capabilities", "payload-schema", "output-labeling", "idempotency-strategy"], + "additionalProperties": false + } + }, "governance": { "type": "object", "description": "Lightweight pacing and opt-out policies for campaign coordinator workflows (launcher/orchestrator)", diff --git a/pkg/campaign/spec.go b/pkg/campaign/spec.go index 57df25e425a..9553adcafdc 100644 --- a/pkg/campaign/spec.go +++ b/pkg/campaign/spec.go @@ -121,6 +121,16 @@ type CampaignSpec struct { // Default: copilot (when not specified). Engine string `yaml:"engine,omitempty" json:"engine,omitempty" console:"header:Engine,omitempty"` + // Bootstrap defines the initial work item creation strategy when discovery + // returns zero items. This provides a way to seed the campaign with initial + // work when there are no worker-created items to discover. + Bootstrap *CampaignBootstrapConfig `yaml:"bootstrap,omitempty" json:"bootstrap,omitempty"` + + // Workers defines metadata for worker workflows, including their capabilities, + // payload schemas, and output labeling contracts. This enables deterministic + // worker selection and ensures worker outputs are discoverable. + Workers []WorkerMetadata `yaml:"workers,omitempty" json:"workers,omitempty"` + // ConfigPath is populated at load time with the relative path of // the YAML file on disk, to help users locate definitions. ConfigPath string `yaml:"-" json:"config_path" console:"header:Config Path,maxlen:60"` @@ -257,3 +267,124 @@ type CampaignValidationResult struct { ConfigPath string `json:"config_path" console:"header:Config Path"` Problems []string `json:"problems,omitempty" console:"header:Problems,omitempty"` } + +// CampaignBootstrapConfig defines how to create initial work items when discovery +// returns zero items. This provides multiple strategies for bootstrapping a campaign. +type CampaignBootstrapConfig struct { + // Mode determines the bootstrap strategy when no work items are discovered. + // Valid values: + // - "seeder-worker": Dispatch a seeder/scanner worker workflow to discover work + // - "project-todos": Read items from Project board's "Todo" column + // - "manual": Skip bootstrap, wait for manual work item creation + Mode string `yaml:"mode" json:"mode"` + + // SeederWorker specifies which worker workflow to dispatch for initial scanning. + // Only used when Mode is "seeder-worker". + SeederWorker *SeederWorkerConfig `yaml:"seeder-worker,omitempty" json:"seeder_worker,omitempty"` + + // ProjectTodos specifies configuration for reading from Project board. + // Only used when Mode is "project-todos". + ProjectTodos *ProjectTodosConfig `yaml:"project-todos,omitempty" json:"project_todos,omitempty"` +} + +// SeederWorkerConfig defines configuration for dispatching a seeder/scanner worker. +type SeederWorkerConfig struct { + // WorkflowID is the workflow identifier (basename without .md) to dispatch. + WorkflowID string `yaml:"workflow-id" json:"workflow_id"` + + // Payload is the JSON payload to send to the seeder worker. + // This should conform to the worker's payload schema. + Payload map[string]any `yaml:"payload" json:"payload"` + + // MaxItems caps the number of work items the seeder should return. + // 0 means use the worker's default. + MaxItems int `yaml:"max-items,omitempty" json:"max_items,omitempty"` +} + +// ProjectTodosConfig defines configuration for reading from a Project board's Todo column. +type ProjectTodosConfig struct { + // StatusField is the name of the Project status field to filter on. + // Defaults to "Status". + StatusField string `yaml:"status-field,omitempty" json:"status_field,omitempty"` + + // TodoValue is the status value that indicates a work item is ready to start. + // Defaults to "Todo". + TodoValue string `yaml:"todo-value,omitempty" json:"todo_value,omitempty"` + + // MaxItems caps the number of Todo items to process per orchestrator run. + // 0 means use the governance default. + MaxItems int `yaml:"max-items,omitempty" json:"max_items,omitempty"` + + // RequireFields lists Project field names that must be populated for an + // item to be considered valid work. Empty items are skipped. + RequireFields []string `yaml:"require-fields,omitempty" json:"require_fields,omitempty"` +} + +// WorkerMetadata defines metadata for a worker workflow, including its capabilities, +// payload schema, and output labeling contract. +type WorkerMetadata struct { + // ID is the workflow identifier (basename without .md). + ID string `yaml:"id" json:"id"` + + // Name is a human-readable name for the worker. + Name string `yaml:"name,omitempty" json:"name,omitempty"` + + // Description describes what the worker does. + Description string `yaml:"description,omitempty" json:"description,omitempty"` + + // Capabilities lists what types of work this worker can perform. + // Examples: "scan-security-alerts", "fix-dependencies", "update-docs" + Capabilities []string `yaml:"capabilities" json:"capabilities"` + + // PayloadSchema defines the expected structure of the JSON payload. + // This is a map of field names to field types/descriptions. + PayloadSchema map[string]WorkerPayloadField `yaml:"payload-schema" json:"payload_schema"` + + // OutputLabeling defines the labeling contract for worker outputs. + // This guarantees outputs are discoverable and attributable. + OutputLabeling WorkerOutputLabeling `yaml:"output-labeling" json:"output_labeling"` + + // IdempotencyStrategy describes how the worker ensures idempotent execution. + // Valid values: "branch-based", "pr-title-based", "issue-title-based", "cursor-based" + IdempotencyStrategy string `yaml:"idempotency-strategy" json:"idempotency_strategy"` + + // Priority indicates worker priority for deterministic selection when multiple + // workers can handle the same work item. Higher numbers = higher priority. + Priority int `yaml:"priority,omitempty" json:"priority,omitempty"` +} + +// WorkerPayloadField defines a single field in the worker payload schema. +type WorkerPayloadField struct { + // Type is the field's data type: "string", "number", "boolean", "array", "object" + Type string `yaml:"type" json:"type"` + + // Description explains what this field contains and how it's used. + Description string `yaml:"description" json:"description"` + + // Required indicates whether this field must be present in the payload. + Required bool `yaml:"required,omitempty" json:"required,omitempty"` + + // Example provides a sample value for this field. + Example any `yaml:"example,omitempty" json:"example,omitempty"` +} + +// WorkerOutputLabeling defines the labeling and metadata contract for worker outputs. +type WorkerOutputLabeling struct { + // Labels are the labels the worker applies to created items. + // The campaign's tracker-label is automatically applied by the worker + // in addition to these labels. + // Examples: "security", "automated", "dependencies" + Labels []string `yaml:"labels,omitempty" json:"labels,omitempty"` + + // KeyInTitle indicates whether the worker includes a deterministic key in + // the title of created items (format: "[{key}] {title}"). + KeyInTitle bool `yaml:"key-in-title" json:"key_in_title"` + + // KeyFormat describes the key format when KeyInTitle is true. + // Example: "campaign-{campaign_id}-{repository}-{work_item_id}" + KeyFormat string `yaml:"key-format,omitempty" json:"key_format,omitempty"` + + // MetadataFields lists Project fields the worker populates with work metadata. + // Examples: "Campaign Id", "Worker Workflow", "Priority" + MetadataFields []string `yaml:"metadata-fields,omitempty" json:"metadata_fields,omitempty"` +} diff --git a/pkg/campaign/template.go b/pkg/campaign/template.go index 1bfba4c6acb..0bea43a29d1 100644 --- a/pkg/campaign/template.go +++ b/pkg/campaign/template.go @@ -79,6 +79,17 @@ type CampaignPromptData struct { // Workflows is the list of worker workflow IDs associated with this campaign. Workflows []string + + // Bootstrap configuration + BootstrapMode string + SeederWorkerID string + SeederPayload string + SeederMaxItems int + StatusField string + TodoValue string + TodoMaxItems int + RequireFields []string + WorkerMetadata []WorkerMetadata } // renderTemplate renders a template string with the given data. @@ -177,3 +188,19 @@ func RenderClosingInstructions() string { } return strings.TrimSpace(result) } + +// RenderBootstrapInstructions renders the bootstrap instructions with the given data. +func RenderBootstrapInstructions(data CampaignPromptData) string { + tmplStr, err := loadTemplate("bootstrap-agentic-campaign.md") + if err != nil { + templateLog.Printf("Failed to load bootstrap instructions template: %v", err) + return "" + } + + result, err := renderTemplate(tmplStr, data) + if err != nil { + templateLog.Printf("Failed to render bootstrap instructions: %v", err) + return "" + } + return strings.TrimSpace(result) +}