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
101 changes: 101 additions & 0 deletions packages/core/src/__tests__/step-cwd.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { describe, it, expect, vi } from 'vitest';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';

vi.mock('@relaycast/sdk', () => ({
Expand Down Expand Up @@ -27,6 +29,27 @@ vi.mock('@agent-relay/harness-driver', async (importOriginal) => {
const { WorkflowRunner } = await import('../runner.js');

describe('WorkflowRunner step cwd resolution', () => {
const config = (cwdResolution?: 'workflow-file' | 'process') => ({
version: '1',
name: 'cwd-resolution',
...(cwdResolution ? { cwdResolution } : {}),
swarm: { pattern: 'dag' as const },
agents: [{ name: 'worker', cli: 'claude' as const, cwd: '../agent-workspace' }],
workflows: [
{
name: 'default',
steps: [
{
name: 'generate',
agent: 'worker',
task: 'Generate',
cwd: '../step-workspace',
},
],
},
],
});

it('prefers step.cwd over agent.cwd and runner cwd', () => {
const runnerRoot = '/runner-root';
const runner = new WorkflowRunner({ cwd: runnerRoot });
Expand Down Expand Up @@ -83,4 +106,82 @@ describe('WorkflowRunner step cwd resolution', () => {
(runner as any).resolveEffectiveCwd({ name: 's4', type: 'deterministic', command: 'pwd' }),
).toBe(runnerRoot);
});

it('resolves agent and step cwd from the parsed workflow file when opted in', async () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'relay-cwd-resolution-'));
const runnerRoot = path.join(root, 'process', 'project');
const workflowDir = path.join(root, 'workflow', 'config');
const yamlPath = path.join(workflowDir, 'relay.yaml');
mkdirSync(runnerRoot, { recursive: true });
mkdirSync(workflowDir, { recursive: true });
writeFileSync(
yamlPath,
[
'version: "1"',
'name: cwd-resolution',
'cwdResolution: workflow-file',
'swarm:',
' pattern: dag',
'agents:',
' - name: worker',
' cli: claude',
' cwd: ../agent-workspace',
'workflows:',
' - name: default',
' steps:',
' - name: generate',
' agent: worker',
' task: Generate',
' cwd: ../step-workspace',
].join('\n') + '\n'
);

try {
const runner = new WorkflowRunner({ cwd: runnerRoot });
const parsed = await runner.parseYamlFile(yamlPath);
(runner as any).configureCwdResolution(parsed);

expect((runner as any).resolveAgentCwd(parsed.agents[0])).toBe(
path.resolve(workflowDir, '../agent-workspace')
);
expect((runner as any).resolveEffectiveCwd(parsed.workflows![0].steps[0])).toBe(
path.resolve(workflowDir, '../step-workspace')
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});

it('preserves process-relative agent and step cwd when explicitly configured', () => {
const runnerRoot = '/process/project';
const runner = new WorkflowRunner({ cwd: runnerRoot });
(runner as any).workflowFileDir = '/workflow/config';
(runner as any).configureCwdResolution(config('process'));

expect((runner as any).resolveAgentCwd(config('process').agents[0])).toBe(
path.resolve(runnerRoot, '../agent-workspace')
);
expect((runner as any).resolveEffectiveCwd(config('process').workflows[0].steps[0])).toBe(
path.resolve(runnerRoot, '../step-workspace')
);
});

it('warns with both paths when the default is ambiguous', () => {
const runnerRoot = '/process/project';
const workflowDir = '/workflow/config';
const runner = new WorkflowRunner({ cwd: runnerRoot });
(runner as any).workflowFileDir = workflowDir;
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: warn.mockRestore() runs only on the success path. If an assertion fails, the console.warn spy leaks into the rest of the suite and silently swallows warnings from later tests. Wrap the spy registration and assertions in try/finally, or restore the spy after each test.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/src/__tests__/step-cwd.test.ts, line 174:

<comment>warn.mockRestore() runs only on the success path. If an assertion fails, the console.warn spy leaks into the rest of the suite and silently swallows warnings from later tests. Wrap the spy registration and assertions in try/finally, or restore the spy after each test.</comment>

<file context>
@@ -83,4 +106,82 @@ describe('WorkflowRunner step cwd resolution', () => {
+    const workflowDir = '/workflow/config';
+    const runner = new WorkflowRunner({ cwd: runnerRoot });
+    (runner as any).workflowFileDir = workflowDir;
+    const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+    (runner as any).configureCwdResolution(config());
</file context>


(runner as any).configureCwdResolution(config());

expect(warn).toHaveBeenCalledWith(
expect.stringContaining(path.resolve(runnerRoot, '../agent-workspace'))
);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining(path.resolve(workflowDir, '../agent-workspace'))
);
expect(warn).toHaveBeenCalledWith(expect.stringContaining('future major'));
warn.mockRestore();
});
});
68 changes: 65 additions & 3 deletions packages/core/src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -860,6 +860,8 @@ export class WorkflowRunner {
private readonly workspaceId: string;
private readonly relayOptions: RuntimeSpawnOptions;
private readonly cwd: string;
private workflowFileDir?: string;
private cwdResolution: NonNullable<RelayYamlConfig['cwdResolution']> = 'process';
private readonly summaryDir: string;
private executor?: RunnerStepExecutor;
private readonly envSecrets?: Record<string, string>;
Expand Down Expand Up @@ -1004,6 +1006,50 @@ export class WorkflowRunner {
});
}

private resolveConfiguredCwd(configuredCwd: string): string {
const base = this.cwdResolution === 'workflow-file' ? this.workflowFileDir : this.cwd;
return path.resolve(base ?? this.cwd, configuredCwd);
}

private configureCwdResolution(config: RelayYamlConfig): void {
const configuredMode = config.cwdResolution;
this.cwdResolution = configuredMode ?? 'process';

if (this.cwdResolution === 'workflow-file' && !this.workflowFileDir) {
throw new Error(
'cwdResolution: "workflow-file" requires a workflow loaded from a YAML file so its directory is known'
);
}
if (configuredMode || !this.workflowFileDir) return;

const candidates: Array<{ label: string; cwd: string }> = [];
for (const agent of config.agents ?? []) {
if (agent.cwd) candidates.push({ label: `Agent "${agent.name}"`, cwd: agent.cwd });
}
for (const workflow of config.workflows ?? []) {
for (const step of workflow.steps) {
if (step.cwd) {
candidates.push({
label: `Step "${workflow.name}.${step.name}"`,
cwd: step.cwd,
});
}
}
}

for (const candidate of candidates) {
const processPath = path.resolve(this.cwd, candidate.cwd);
const workflowPath = path.resolve(this.workflowFileDir, candidate.cwd);
if (processPath === workflowPath) continue;
console.warn(
`[WorkflowRunner] DEPRECATION WARNING: ${candidate.label} cwd "${candidate.cwd}" ` +
`currently resolves from the process cwd to "${processPath}"; workflow-file resolution ` +
`would use "${workflowPath}". Set top-level cwdResolution to "process" or ` +
`"workflow-file" explicitly. The default will flip to "workflow-file" in a future major release.`
);
}
}

/**
* Resolve and validate the top-level `paths` definitions from the config.
* Returns a map of name → absolute directory path.
Expand Down Expand Up @@ -1466,7 +1512,7 @@ export class WorkflowRunner {
return resolved;
}
if (agent.cwd) {
return path.resolve(this.cwd, agent.cwd);
return this.resolveConfiguredCwd(agent.cwd);
}
return this.cwd;
}
Expand All @@ -1488,7 +1534,7 @@ export class WorkflowRunner {

private resolveEffectiveCwd(step: WorkflowStep, agentDef?: AgentDefinition): string {
if (step.cwd) {
return path.resolve(this.cwd, step.cwd);
return this.resolveConfiguredCwd(step.cwd);
}
return this.resolveStepWorkdir(step) ?? (agentDef ? this.resolveAgentCwd(agentDef) : this.cwd);
}
Expand Down Expand Up @@ -2997,11 +3043,15 @@ export class WorkflowRunner {
async parseYamlFile(filePath: string): Promise<RelayYamlConfig> {
const absPath = path.resolve(this.cwd, filePath);
const raw = await readFile(absPath, 'utf-8');
this.workflowFileDir = path.dirname(absPath);
return this.parseYamlString(raw, absPath);
}

/** Parse a relay.yaml string. */
parseYamlString(raw: string, source = '<string>'): RelayYamlConfig {
if (source !== '<string>' && path.isAbsolute(source)) {
this.workflowFileDir = path.dirname(source);
}
Comment on lines +3052 to +3054

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: After a runner parses a YAML file, parsing a string config leaves the previous workflowFileDir in place, so cwdResolution: workflow-file resolves against the wrong file. Clear workflowFileDir when the source is not an absolute YAML path before configuring the mode.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/src/runner.ts, line 3052:

<comment>After a runner parses a YAML file, parsing a string config leaves the previous `workflowFileDir` in place, so `cwdResolution: workflow-file` resolves against the wrong file. Clear `workflowFileDir` when the source is not an absolute YAML path before configuring the mode.</comment>

<file context>
@@ -2997,11 +3043,15 @@ export class WorkflowRunner {
 
   /** Parse a relay.yaml string. */
   parseYamlString(raw: string, source = '<string>'): RelayYamlConfig {
+    if (source !== '<string>' && path.isAbsolute(source)) {
+      this.workflowFileDir = path.dirname(source);
+    }
</file context>
Suggested change
if (source !== '<string>' && path.isAbsolute(source)) {
this.workflowFileDir = path.dirname(source);
}
this.workflowFileDir = source !== '<string>' && path.isAbsolute(source) ? path.dirname(source) : undefined;

const parsed = parseYaml(raw);
this.validateConfig(parsed, source);
const config = this.normalizeLegacyPermissionConfig(parsed as RelayYamlConfig);
Expand Down Expand Up @@ -3085,6 +3135,15 @@ export class WorkflowRunner {
if (c.agents !== undefined && !Array.isArray(c.agents)) {
throw new Error(`${source}: "agents" must be an array when provided`);
}
if (
c.cwdResolution !== undefined &&
c.cwdResolution !== 'process' &&
c.cwdResolution !== 'workflow-file'
) {
throw new Error(
`${source}: "cwdResolution" must be either "process" or "workflow-file"`
);
}

// Approval gates that cannot reach a human fail OPEN, so they must be refused
// on every path — not only when someone happens to run `--validate` first.
Expand Down Expand Up @@ -3221,6 +3280,7 @@ export class WorkflowRunner {
this.validateConfig(config);
resolved = vars ? this.resolveVariables(config, vars) : config;
resolved = this.applyPermissionProfiles(resolved);
this.configureCwdResolution(resolved);
} catch (err) {
errors.push(err instanceof Error ? err.message : String(err));
return {
Expand Down Expand Up @@ -3335,7 +3395,7 @@ export class WorkflowRunner {
// Validate cwd paths
for (const agent of resolved.agents) {
if (agent.cwd) {
const resolvedCwd = path.resolve(this.cwd, agent.cwd);
const resolvedCwd = this.resolveConfiguredCwd(agent.cwd);
if (!existsSync(resolvedCwd)) {
warnings.push(
`Agent "${agent.name}" cwd "${agent.cwd}" resolves to "${resolvedCwd}" which does not exist`
Expand Down Expand Up @@ -3845,6 +3905,7 @@ export class WorkflowRunner {

// Validate config (catches cycles, missing deps, invalid steps, etc.)
this.validateConfig(resolved);
this.configureCwdResolution(resolved);
const runtimeConfig = this.applyReliabilityDefaults(resolved);

const permissionResult = this.validatePermissions(
Expand Down Expand Up @@ -4015,6 +4076,7 @@ export class WorkflowRunner {
const resolvedConfig = this.applyReliabilityDefaults(
vars ? this.resolveVariables(run.config, vars) : run.config
);
this.configureCwdResolution(resolvedConfig);

// Resolve path definitions (same as execute()) so workdir lookups work on resume
const pathResult = this.resolvePathDefinitions(resolvedConfig.paths, this.cwd);
Expand Down
16 changes: 15 additions & 1 deletion packages/core/src/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@
"type": "string",
"description": "Optional description of the configuration"
},
"cwdResolution": {
"type": "string",
"enum": ["workflow-file", "process"],
"default": "process",
"description": "Base directory for relative agent and step cwd values. process preserves the current behavior; workflow-file resolves from the directory containing this YAML file."
},
"permission_profiles": {
"type": "object",
"description": "Reusable permission profiles that agents can reference via permissions.profile.",
Expand Down Expand Up @@ -410,7 +416,7 @@
},
"cwd": {
"type": "string",
"description": "Working directory for this agent, resolved relative to the runner's configured working directory (the `cwd` option, defaulting to process.cwd()) — not the YAML file's location."
"description": "Working directory for this agent, resolved according to the top-level cwdResolution setting."
},
"workdir": {
"type": "string",
Expand Down Expand Up @@ -836,6 +842,10 @@
"type": "string",
"description": "Sets this step's working directory to a named entry from the top-level paths array."
},
"cwd": {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: WorktreeWorkflowStep, IntegrationWorkflowStep and WaitForWorkflowStep do not declare cwd and set additionalProperties: false, yet the runtime (resolveEffectiveCwd) and the WorkflowStep type (schema.ts) support cwd on those step types too. A worktree/integration/waitFor step with cwd is valid and honored at runtime but flagged invalid by JSON-Schema editor validation. Add cwd to those three step definitions to keep the schema aligned with the resolver and the shared WorkflowStep type.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/src/schema.json, line 845:

<comment>WorktreeWorkflowStep, IntegrationWorkflowStep and WaitForWorkflowStep do not declare `cwd` and set additionalProperties: false, yet the runtime (resolveEffectiveCwd) and the WorkflowStep type (schema.ts) support `cwd` on those step types too. A worktree/integration/waitFor step with `cwd` is valid and honored at runtime but flagged invalid by JSON-Schema editor validation. Add `cwd` to those three step definitions to keep the schema aligned with the resolver and the shared WorkflowStep type.</comment>

<file context>
@@ -836,6 +842,10 @@
           "type": "string",
           "description": "Sets this step's working directory to a named entry from the top-level paths array."
         },
+        "cwd": {
+          "type": "string",
+          "description": "Working directory for this step, resolved according to the top-level cwdResolution setting."
</file context>

"type": "string",
"description": "Working directory for this step, resolved according to the top-level cwdResolution setting."
},
"humanAssistance": {
"anyOf": [
{
Expand Down Expand Up @@ -892,6 +902,10 @@
"workdir": {
"type": "string",
"description": "Sets this step's working directory to a named entry from the top-level paths array."
},
"cwd": {
"type": "string",
"description": "Working directory for this step, resolved according to the top-level cwdResolution setting."
}
}
},
Expand Down
8 changes: 5 additions & 3 deletions packages/core/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,12 @@ interface AgentDefinitionBase {
* It receives its task as a CLI prompt argument and returns stdout as output.
* Default: true (interactive PTY mode). */
interactive?: boolean;
/** Working directory for this agent, resolved relative to the YAML file. */
/** Working directory for this agent, resolved according to the top-level
* `cwdResolution` setting. */
cwd?: string;
/** Sets this agent's working directory to a named entry from the top-level `paths` array.
* Mutually exclusive with `cwd`. If omitted, the agent runs in the runner's
* working directory (the directory containing the workflow YAML file). */
* working directory. */
workdir?: string;
/** Additional paths the agent needs read/write access to. */
additionalPaths?: string[];
Expand Down Expand Up @@ -397,7 +398,8 @@ export interface WorkflowStep {
retries?: number;
/** Maximum iterations for steps that may need to retry (e.g., fix-failures). */
maxIterations?: number;
/** Explicit working directory for this step. */
/** Explicit working directory for this step, resolved according to the
* top-level `cwdResolution` setting. */
cwd?: string;
/** Step-level human assistance override. Set false to disable swarm defaults. */
humanAssistance?: HumanAssistanceConfig | false;
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ export interface RelayYamlConfig {
version: string;
name: string;
description?: string;
/** Base directory used to resolve relative agent and step `cwd` values.
* Defaults to `process` for backwards compatibility. */
cwdResolution?: 'workflow-file' | 'process';
/** Reusable permission profiles that agents can reference via permissions.profile. */
permission_profiles?: Record<string, PermissionProfileDefinition>;
/** Named paths to external directories used by this workflow.
Expand Down
Loading