Skip to content

Base Config workingDir Cannot Be Inherited #1656

@pdoronila

Description

@pdoronila

Base Config workingDir Cannot Be Inherited

Issue Summary

Affected Versions: 1.30.3 (likely all versions with base config support)
Severity: Minor — easy workaround, but surprising behavior

Problem

Setting workingDir in the base config file (DAGU_BASE_CONFIG) has no effect. Every workflow gets its own workingDir regardless of the base config value.

# base.yaml (pointed to by DAGU_BASE_CONFIG)
workingDir: ${PROJECT_ROOT}
# workflows/bootstrap.yaml — no workingDir set
name: bootstrap
steps:
  - name: check
    command: nu -c "print (pwd)"
    # Expected: C:\project (from base config)
    # Actual:   C:\project\workflows (workflow file's parent directory)

Root Cause

buildWorkingDir in internal/core/spec/dag.go always returns a non-empty string. When the workflow doesn't set workingDir, the builder falls back to the workflow file's parent directory:

func buildWorkingDir(ctx BuildContext, d *dag) (string, error) {
    switch {
    case d.WorkingDir != "":
        // Workflow explicitly sets workingDir — use it
        wd := d.WorkingDir
        // ... resolve and return

    case ctx.opts.DefaultWorkingDir != "":
        // Sub-DAG execution inherits parent's workingDir
        return ctx.opts.DefaultWorkingDir, nil

    case ctx.file != "":
        // Fallback: use workflow file's parent directory
        return filepath.Dir(ctx.file), nil  // <-- always non-empty

    default:
        dir, _ := os.Getwd()
        // ... return cwd or home
    }
}

The merge in loadDAGsFromFile uses mergo.Merge(dest, src, mergo.WithOverride) where dest is the base DAG and src is the workflow DAG. With WithOverride, non-zero src values overwrite dst values. Since the workflow's WorkingDir is always non-empty (from the fallback above), it always overwrites the base config's value.

// In loadDAGsFromFile:
dest = baseDAG    // workingDir: "C:\project" (from base config)
dag = spec.build(ctx)  // workingDir: "C:\project\workflows" (fallback to file dir)
merge(dest, dag)  // result: "C:\project\workflows" — workflow wins

Why Other Fields Work

Fields like dotenv work because the mergeTransformer has special handling for []string — it appends instead of overwriting. And fields that aren't set in the workflow spec get zero values from the builder, so the base config's values survive the merge.

workingDir is unique in that it always gets a non-zero fallback value from the builder, even when the user didn't set it.

Suggested Fix

The fix is to distinguish between "user explicitly set workingDir" and "builder filled in a default". The simplest approach: return empty from buildWorkingDir when the user didn't set it, and apply the fallback after the merge.

Option A: Return empty when not explicitly set

In buildWorkingDir, return empty when falling through to the file-directory fallback. Then apply the fallback in InitializeDefaults (or a new post-merge step):

func buildWorkingDir(ctx BuildContext, d *dag) (string, error) {
    switch {
    case d.WorkingDir != "":
        wd := d.WorkingDir
        if !ctx.opts.Has(BuildFlagNoEval) {
            wd = os.ExpandEnv(wd)
            switch {
            case filepath.IsAbs(wd) || strings.HasPrefix(wd, "~"):
                wd = fileutil.ResolvePathOrBlank(wd)
            case ctx.file != "":
                wd = filepath.Join(filepath.Dir(ctx.file), wd)
            default:
                wd = fileutil.ResolvePathOrBlank(wd)
            }
        }
        return wd, nil

    case ctx.opts.DefaultWorkingDir != "":
        return ctx.opts.DefaultWorkingDir, nil

    default:
        // Don't fill in a default — let the merge preserve the base config value.
        // The fallback is applied after merge in InitializeDefaults.
        return "", nil
    }
}

Then in InitializeDefaults:

func (d *DAG) initializeDefaults() {
    // ... existing defaults ...

    // Apply workingDir fallback after merge
    if d.WorkingDir == "" {
        if d.Location != "" {
            d.WorkingDir = filepath.Dir(d.Location)
        } else {
            d.WorkingDir, _ = os.Getwd()
        }
    }
}

This way, a workflow that doesn't set workingDir gets an empty value from the builder, the merge preserves the base config's value, and InitializeDefaults applies the file-directory fallback only if neither the base nor the workflow set it.

Option B: Track explicit vs. implicit in a flag

Add a WorkingDirExplicit bool field to core.DAG that's set to true only when the user provides workingDir in the YAML. The merge logic can then skip overwriting when the workflow's WorkingDirExplicit is false.

This is more complex but avoids changing the builder's return semantics.

Workaround

Core workflows that need workingDir set to the piper root can use:

workingDir: ${PROJECT_ROOT}

${PROJECT_ROOT} is set on the dagu service process environment (not from dotenv), so it expands correctly at build time via os.ExpandEnv.

Bundle workflows can omit workingDir entirely — the default (workflow file's parent directory) is correct for them since scripts are relative to the bundle root.

References

  • Source: internal/core/spec/dag.gobuildWorkingDir function
  • Source: internal/core/spec/loader.goloadDAGsFromFile, merge function
  • Discovered: 2026-02-10

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions