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
3 changes: 3 additions & 0 deletions src/commands/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,9 @@ export const cloudCommand = defineCommand({
excludeFlows,
configFile,
debug,
// Not warnOut: config problems are worth surfacing even under --json,
// and logger.warn writes to stderr so stdout stays parseable.
warn: (m: string) => logger.warn(m),
});

if (debug) {
Expand Down
2 changes: 2 additions & 0 deletions src/mcp/tools/run-cloud-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ export function registerRunCloudTest(server: McpServer): void {
excludeTags: args.excludeTags ?? [],
excludeFlows: args.excludeFlows,
configFile: args.configFile,
// stdout is the JSON-RPC channel — config warnings must go to stderr.
warn: logStderr,
});

const commonRoot = computeCommonRoot(
Expand Down
80 changes: 32 additions & 48 deletions src/services/execution-plan.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,45 +5,12 @@ import {
getFlowsToRunInSequence,
isFlowFile,
isWorkspaceConfigFile,
loadWorkspaceConfig,
processDependencies,
readDirectory,
readTestYamlFileAsJson,
readYamlFileAsJson,
} from './execution-plan.utils.js';

/** Email notification configuration */
interface INotificationsConfig {
email?: {
enabled?: boolean;
onSuccess?: boolean;
recipients?: string[];
};
}

/** Workspace configuration from config.yaml */
interface IWorkspaceConfig {
excludeTags?: null | string[];
executionOrder?: IExecutionOrder | null;
flows?: null | string[];
includeTags?: null | string[];
local?: ILocal | null;
notifications?: INotificationsConfig;
platform?: {
android?: { disableAnimations?: boolean };
ios?: { disableAnimations?: boolean };
};
}

/** Local execution configuration */
interface ILocal {
deterministicOrder: boolean | null;
}

/** Sequential execution configuration */
interface IExecutionOrder {
continueOnFailure: boolean;
flowsOrder: string[];
}
import { IWorkspaceConfig } from './workspace-config.schema.js';

/** Options for execution plan generation */
export interface PlanOptions {
Expand All @@ -53,6 +20,12 @@ export interface PlanOptions {
excludeTags?: string[];
includeTags?: string[];
input: string;
/**
* Sink for non-fatal config problems. Injected rather than imported so the
* MCP server can route warnings to stderr — its stdout is the JSON-RPC
* channel.
*/
warn?: (message: string) => void;
}

/** Execution plan containing all flows to run with metadata and dependencies */
Expand Down Expand Up @@ -146,11 +119,13 @@ function filterFlowFiles(
* Load workspace configuration from config.yaml/yml if present
* @param input - Input directory path
* @param unfilteredFlowFiles - List of discovered flow files
* @param warn - Sink for non-fatal config problems
* @returns Workspace configuration object (empty if no config file found)
*/
function getWorkspaceConfig(
input: string,
unfilteredFlowFiles: string[],
warn: (message: string) => void,
): IWorkspaceConfig {
const possibleConfigPaths = new Set(
[path.join(input, 'config.yaml'), path.join(input, 'config.yml')].map((p) =>
Expand All @@ -162,11 +137,7 @@ function getWorkspaceConfig(
possibleConfigPaths.has(path.normalize(file)),
);

const config = configFilePath
? (readYamlFileAsJson(configFilePath) as IWorkspaceConfig)
: {};

return config;
return configFilePath ? loadWorkspaceConfig(configFilePath, warn) : {};
}

/**
Expand Down Expand Up @@ -199,11 +170,13 @@ function extractDeviceCloudOverrides(
/**
* Generate execution plan for a single flow file
* @param normalizedInput - Normalized path to the flow file
* @param warn - Sink for non-fatal config problems
* @param resolvedConfigFile - Optional absolute path to a custom config file
* @returns Execution plan for the single file with dependencies
*/
async function planSingleFile(
normalizedInput: string,
warn: (message: string) => void,
resolvedConfigFile?: string,
): Promise<IExecutionPlan> {
const inputBasename = path.basename(normalizedInput);
Expand Down Expand Up @@ -232,9 +205,17 @@ async function planSingleFile(
throw new Error(`Config file does not exist: ${resolvedConfigFile}`);
}

workspaceConfig = readYamlFileAsJson(
resolvedConfigFile,
) as IWorkspaceConfig;
workspaceConfig = loadWorkspaceConfig(resolvedConfigFile, warn);

// Sequencing is resolved against a workspace's discovered flows, which a
// single-file input doesn't have — so executionOrder is ignored here. Say so
// rather than accepting a config that reads as if it applied (dcd-cli#110).
if (workspaceConfig.executionOrder?.flowsOrder.length) {
warn(
`Warning: \`executionOrder\` in ${resolvedConfigFile} is ignored when a single flow file is passed.\n` +
`Pass the workspace folder instead so the named flows can be discovered and sequenced.`,
);
}
}

const checkedDependancies = await checkDependencies(normalizedInput);
Expand Down Expand Up @@ -386,6 +367,7 @@ export async function plan(options: PlanOptions): Promise<IExecutionPlan> {
excludeFlows,
configFile,
debug = false,
warn = (message: string) => console.warn(message),
} = options;
const normalizedInput = path.normalize(input);
const flowMetadata: Record<string, Record<string, unknown>> = {};
Expand All @@ -400,7 +382,7 @@ export async function plan(options: PlanOptions): Promise<IExecutionPlan> {
}

if (fs.lstatSync(normalizedInput).isFile()) {
return planSingleFile(normalizedInput, resolvedConfigFile);
return planSingleFile(normalizedInput, warn, resolvedConfigFile);
}

let unfilteredFlowFiles = await readDirectory(normalizedInput, isFlowFile);
Expand All @@ -420,11 +402,13 @@ export async function plan(options: PlanOptions): Promise<IExecutionPlan> {
throw new Error(`Config file does not exist: ${resolvedConfigFile}`);
}

workspaceConfig = readYamlFileAsJson(
resolvedConfigFile,
) as IWorkspaceConfig;
workspaceConfig = loadWorkspaceConfig(resolvedConfigFile, warn);
} else {
workspaceConfig = getWorkspaceConfig(normalizedInput, unfilteredFlowFiles);
workspaceConfig = getWorkspaceConfig(
normalizedInput,
unfilteredFlowFiles,
warn,
);
}

unfilteredFlowFiles = await applyFlowGlobs(
Expand Down
57 changes: 27 additions & 30 deletions src/services/execution-plan.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import * as yaml from 'js-yaml';
import * as fs from 'node:fs';
import * as path from 'node:path';

import {
IWorkspaceConfig,
parseWorkspaceConfig,
WORKSPACE_CONFIG_KEYS,
} from './workspace-config.schema.js';

const commandsThatRequireFiles = new Set(['addMedia', 'runFlow', 'runScript']);

export function getFlowsToRunInSequence(
Expand Down Expand Up @@ -60,22 +66,6 @@ export function isFlowFile(filePath: string): boolean {
return filePath.endsWith('.yaml') || filePath.endsWith('.yml');
}

/**
* Top-level keys that only ever appear in a workspace config (see
* IWorkspaceConfig in execution-plan.service.ts). Deliberately excludes keys
* Maestro also allows in flow front matter — appId, name, tags, env,
* onFlowStart, onFlowComplete, jsEngine.
*/
const WORKSPACE_CONFIG_KEYS = new Set([
'excludeTags',
'executionOrder',
'flows',
'includeTags',
'local',
'notifications',
'platform',
]);

/**
* True when a YAML file is a workspace config rather than a runnable flow.
*
Expand Down Expand Up @@ -112,27 +102,34 @@ export const readYamlFileAsJson = (filePath: string) => {
const normalizedPath = path.normalize(filePath);
const yamlText = fs.readFileSync(normalizedPath, 'utf8');

const result = yaml.load(yamlText);

// Ensure includeTags and excludeTags are always arrays if present
if (result && typeof result === 'object') {
if ('includeTags' in result && !Array.isArray(result.includeTags)) {
result.includeTags = result.includeTags ? [result.includeTags] : [];
}

if ('excludeTags' in result && !Array.isArray(result.excludeTags)) {
result.excludeTags = result.excludeTags ? [result.excludeTags] : [];
}
}

return result;
return yaml.load(yamlText);
} catch (error) {
throw new Error(`Error parsing YAML file ${filePath}: ${error}`, {
cause: error,
});
}
};

/**
* Load and validate a workspace config file.
*
* The single chokepoint for reading a config: every caller gets a
* runtime-validated object instead of an unchecked `as IWorkspaceConfig` cast.
* Scalar-to-array coercion for `includeTags`/`excludeTags` lives in the schema,
* so `readYamlFileAsJson` stays a plain YAML read.
*
* @param filePath - Path to the config file
* @param warn - Sink for non-fatal problems (unrecognised keys)
* @returns The validated workspace config
* @throws Error if the file is unparseable or the config is invalid
*/
export function loadWorkspaceConfig(
filePath: string,
warn: (message: string) => void,
): IWorkspaceConfig {
return parseWorkspaceConfig(readYamlFileAsJson(filePath), { filePath, warn });
}

export const readTestYamlFileAsJson = (filePath: string) => {
try {
const normalizedPath = path.normalize(filePath);
Expand Down
Loading
Loading