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
109 changes: 68 additions & 41 deletions pkg/workflow/compiler_jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -285,95 +285,122 @@ func (c *Compiler) getActivationRenderedEngineEnvValues(data *WorkflowData) []st
func (c *Compiler) buildJobs(data *WorkflowData, markdownPath string) error {
compilerJobsLog.Printf("Building jobs for workflow: %s", markdownPath)

// Use the already-parsed frontmatter from WorkflowData (populated by ParseWorkflowFile /
// ParseWorkflowString) instead of re-reading and re-parsing the file on every compilation.
// Note: RawFrontmatter has already been through preprocessScheduleFields, so shorthand
// triggers (e.g. "on: daily") are already expanded into their structured form.
// The consumers (needsRoleCheck, hasWorkflowRunTrigger) only inspect event keys in the
// "on" field, which is exactly what we need here.
// Use the already-parsed frontmatter from WorkflowData; consumers below only
// inspect the processed "on" field for activation decisions.
frontmatter := data.RawFrontmatter

// Extract lock filename for timestamp check
lockFilename := filepath.Base(stringutil.MarkdownToLockFile(markdownPath))

c.resolveSafeOutputActionSchemas(data, markdownPath)

activationJobCreated, err := c.buildActivationAndMainJobs(data, frontmatter, lockFilename)
if err != nil {
return err
}

if err := c.buildSafeOutputsAndEvalsJobs(data, markdownPath); err != nil {
return err
}

if err := c.applyBuiltinJobPreSteps(data); err != nil {
return fmt.Errorf("built-in job pre-steps could not be applied: %w. Check that pre-steps is an array of valid step objects", err)
}

if err := c.buildFrontmatterCustomJobs(data, activationJobCreated); err != nil {
return err
}

if err := c.buildMemoryManagementJobs(data); err != nil {
return err
}

if err := c.finalizeBuiltJobs(data); err != nil {
return err
}

compilerJobsLog.Print("Successfully built all jobs for workflow")
return nil
}

func (c *Compiler) resolveSafeOutputActionSchemas(data *WorkflowData, markdownPath string) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] resolveSafeOutputActionSchemas is the only extracted helper without a doc comment, making it inconsistent with the other new methods and slightly harder to navigate.

💡 Suggested doc comment
// resolveSafeOutputActionSchemas resolves custom safe-output action schemas early
// so that tool schemas derived from action.yml are available when buildMainJobWrapper
// generates MCP tool metadata. No-op when no custom safe-output actions are configured.
func (c *Compiler) resolveSafeOutputActionSchemas(data *WorkflowData, markdownPath string) {

@copilot please address this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

L324-332: yagni: 9-line helper with a single caller wrapping one if check. Inline the body into buildJobs.

// Resolve custom safe-output actions early so that tool schemas (derived from action.yml)
// are available when buildMainJobWrapper → generateMCPSetup → generateToolsMetaJSON →
// generateDynamicTools runs. Without this early resolution the dynamic_tools entry for
// each action tool would have an empty schema because Inputs/ActionDescription are nil.
if data.SafeOutputs != nil && len(data.SafeOutputs.Actions) > 0 {
c.resolveAllActions(data, markdownPath)
}
}

// Build pre-activation and activation jobs
// buildActivationAndMainJobs builds the activation chain and the primary agent job.
func (c *Compiler) buildActivationAndMainJobs(data *WorkflowData, frontmatter map[string]any, lockFilename string) (bool, error) {
_, activationJobCreated, err := c.buildPreActivationAndActivationJobs(data, frontmatter, lockFilename)
if err != nil {
return err
return false, err
}

// Build main workflow job
if err := c.buildMainJobWrapper(data, activationJobCreated); err != nil {
return err
return false, err
}

// Build safe outputs jobs if configured
return activationJobCreated, nil
}

// buildSafeOutputsAndEvalsJobs builds optional post-agent evaluation/output jobs.
func (c *Compiler) buildSafeOutputsAndEvalsJobs(data *WorkflowData, markdownPath string) error {
if err := c.buildSafeOutputsJobs(data, string(constants.AgentJobName), markdownPath); err != nil {
return fmt.Errorf("safe outputs jobs could not be built: %w. Check the safe-outputs configuration for valid job types", err)
}

// Build BinEval evals job if evals are declared in frontmatter.
if evalsJob, err := c.buildEvalsJob(data); err != nil {
return fmt.Errorf("evals job could not be built: %w. Check that the evals frontmatter section is a valid object", err)
} else if evalsJob != nil {
if err := c.jobManager.AddJob(evalsJob); err != nil {
return fmt.Errorf("evals job could not be added: %w. Check that no other job in the workflow reuses its name", err)
}
if err := c.buildEvalsJobWrapper(data); err != nil {
return err
}

// Apply jobs.<builtin-job>.pre-steps customizations to already-created built-in jobs
// before processing non-built-in custom jobs.
if err := c.applyBuiltinJobPreSteps(data); err != nil {
return fmt.Errorf("built-in job pre-steps could not be applied: %w. Check that pre-steps is an array of valid step objects", err)
return nil
}

// buildEvalsJobWrapper builds and adds the BinEval evals job if evals are declared in frontmatter.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

L361-374: yagni: single-caller helper duplicating buildEvalsJob's nil-check/AddJob dance that used to live inline in buildJobs. Fold back into buildSafeOutputsAndEvalsJobs and drop the dedicated wrapper test in evals_job_test.go.

func (c *Compiler) buildEvalsJobWrapper(data *WorkflowData) error {
evalsJob, err := c.buildEvalsJob(data)
if err != nil {
return fmt.Errorf("evals job could not be built: %w. Check that the evals frontmatter section is a valid object", err)
}
if evalsJob == nil {
return nil
}
if err := c.jobManager.AddJob(evalsJob); err != nil {
return fmt.Errorf("evals job could not be added: %w. Check that no other job in the workflow reuses its name", err)
}
return nil
}

// Build additional custom jobs from frontmatter jobs section
// buildFrontmatterCustomJobs builds additional jobs declared in the workflow frontmatter.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

L376-385: yagni: single-caller wrapper that only forwards to buildCustomJobs plus a log line. Inline into buildJobs, dropping one indirection layer.

func (c *Compiler) buildFrontmatterCustomJobs(data *WorkflowData, activationJobCreated bool) error {
if len(data.Jobs) > 0 {
compilerJobsLog.Printf("Building %d custom jobs from frontmatter", len(data.Jobs))
}
if err := c.buildCustomJobs(data, activationJobCreated); err != nil {
return fmt.Errorf("custom jobs could not be built: %w. Check the jobs section in frontmatter for valid job definitions", err)
}
return nil
}

// Build memory management jobs.
if err := c.buildMemoryManagementJobs(data); err != nil {
return err
}

// Apply additive jobs.<built-in>.needs augmentations once all jobs are created,
// so referenced custom/imported jobs can be validated against the final job set.
// finalizeBuiltJobs applies final dependency, permission, and token-reference passes.
func (c *Compiler) finalizeBuiltJobs(data *WorkflowData) error {
if err := c.applyBuiltinJobAugmentations(data); err != nil {
return fmt.Errorf("built-in job needs augmentations could not be applied: %w. Check that jobs referenced in needs actually exist in the workflow", err)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ordering constraints for the three finalization passes were removed along with their explanatory comments. These passes have non-obvious sequencing requirements:

  • ensureConclusionIsLastJob must run after all custom jobs exist
  • ensureOTLPOIDCJobPermissions / ensureDriveMemoryJobPermissions must run per-job after all jobs are created (job-level permissions override workflow-level)
  • validateSafeOutputStepTokenReferences catches a runtime-silent empty-token bug and must run last

Consider adding a brief doc comment on finalizeBuiltJobs explaining these ordering constraints so future contributors do not inadvertently reorder the passes.

@copilot please address this.

}

// Final pass: ensure conclusion job depends on ALL remaining workflow jobs.
// This guarantees conclusion always runs last, even for custom user-defined jobs
// (e.g. post-issue, super_linter) that were not explicitly added to its needs.
if err := c.ensureConclusionIsLastJob(); err != nil {
return err
}

// Final pass: every job that mints an OTLP OIDC token needs id-token: write.
// Job-level permissions override the workflow-level block, so this must be applied
// to each job individually after all jobs have been created.
c.ensureOTLPOIDCJobPermissions(data)
c.ensureDriveMemoryJobPermissions(data)

// Final pass: same-job `steps.<id>.outputs.*` token expressions must be produced by a
// step of the job that consumes them, otherwise the token is empty at runtime.
if err := c.validateSafeOutputStepTokenReferences(data); err != nil {
return err
}

compilerJobsLog.Print("Successfully built all jobs for workflow")
return nil
}

Expand Down
21 changes: 21 additions & 0 deletions pkg/workflow/evals_job_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,27 @@ func TestBuildEvalsJobNeedsWithDetection(t *testing.T) {
assert.Contains(t, strings.Join(job.Steps, ""), "id: parse-mcp-gateway\n")
}

func TestBuildEvalsJobWrapperAddsJob(t *testing.T) {
compiler := NewCompiler()
data := &WorkflowData{
AI: "copilot",
Evals: &EvalsConfig{
Questions: []EvalDefinition{
{ID: "q1", Question: "Does it build?"},
},
},
SafeOutputs: &SafeOutputsConfig{},
}

err := compiler.buildEvalsJobWrapper(data)
require.NoError(t, err)

job, exists := compiler.jobManager.GetJob(string(constants.EvalsJobName))
require.True(t, exists)
assert.Equal(t, string(constants.EvalsJobName), job.Name)
assert.Contains(t, job.Needs, string(constants.AgentJobName))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] TestBuildEvalsJobWrapperAddsJob tests the happy path well, but the error paths in buildEvalsJobWrapper — when buildEvalsJob returns an error, or when jobManager.AddJob fails due to a duplicate job name — have no coverage.

💡 Suggested additional tests
func TestBuildEvalsJobWrapperNilEvalsIsNoop(t *testing.T) {
    compiler := NewCompiler()
    data := &WorkflowData{AI: "copilot"} // no Evals set
    err := compiler.buildEvalsJobWrapper(data)
    require.NoError(t, err)
    _, exists := compiler.jobManager.GetJob(string(constants.EvalsJobName))
    assert.False(t, exists, "no evals job should be added when Evals is nil")
}

A nil-evals test locks in the early-return branch and documents the intended no-op behaviour.

@copilot please address this.


func TestBuildEvalsJobRestoresSetupActionInDevMode(t *testing.T) {
compiler := NewCompiler()
data := &WorkflowData{
Expand Down
Loading