feat: workflowやconfig指定をオプションで受け入れpath対応にする (#81)

This commit is contained in:
nrslib 2026-02-01 07:45:04 +00:00
parent 05bf51cfbb
commit 14130ee958
9 changed files with 349 additions and 31 deletions

View File

@ -150,6 +150,7 @@ describe('executePipeline', () => {
'Fix the bug', 'Fix the bug',
'/tmp/test', '/tmp/test',
'default', 'default',
false,
undefined, undefined,
undefined, undefined,
); );
@ -172,6 +173,7 @@ describe('executePipeline', () => {
'Fix the bug', 'Fix the bug',
'/tmp/test', '/tmp/test',
'default', 'default',
false,
undefined, undefined,
{ provider: 'codex', model: 'codex-model' }, { provider: 'codex', model: 'codex-model' },
); );
@ -229,6 +231,7 @@ describe('executePipeline', () => {
'From --task flag', 'From --task flag',
'/tmp/test', '/tmp/test',
'magi', 'magi',
false,
undefined, undefined,
undefined, undefined,
); );
@ -389,6 +392,7 @@ describe('executePipeline', () => {
'Fix the bug', 'Fix the bug',
'/tmp/test', '/tmp/test',
'default', 'default',
false,
undefined, undefined,
undefined, undefined,
); );

View File

@ -0,0 +1,189 @@
/**
* Tests for path-based workflow loading
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir, homedir } from 'node:os';
import { loadWorkflow, loadWorkflowFromPath } from '../config/workflowLoader.js';
describe('Path-based workflow loading', () => {
let tempDir: string;
let projectDir: string;
beforeEach(() => {
// Create temporary directories for testing
tempDir = mkdtempSync(join(tmpdir(), 'takt-test-'));
projectDir = mkdtempSync(join(tmpdir(), 'takt-project-'));
// Create a test workflow in temp directory
writeFileSync(
join(tempDir, 'test-workflow.yaml'),
`name: test-path-workflow
description: Test workflow for path-based loading
initial_step: plan
max_iterations: 5
steps:
- name: plan
agent: planner
instruction: "Plan the task"
rules:
- condition: ai("Ready?")
next: implement
- name: implement
agent: coder
instruction: "Implement"
`,
);
// Create project-local workflow directory
const projectWorkflowsDir = join(projectDir, '.takt', 'workflows');
rmSync(projectWorkflowsDir, { recursive: true, force: true });
writeFileSync(
join(tempDir, 'project-local.yaml'),
`name: project-local-workflow
description: Project-local workflow
initial_step: test
max_iterations: 1
steps:
- name: test
agent: tester
instruction: "Run tests"
`,
);
});
afterEach(() => {
// Clean up temporary directories
rmSync(tempDir, { recursive: true, force: true });
rmSync(projectDir, { recursive: true, force: true });
});
it('should load workflow by absolute path', () => {
const absolutePath = join(tempDir, 'test-workflow.yaml');
const workflow = loadWorkflowFromPath(absolutePath);
expect(workflow).not.toBeNull();
expect(workflow!.name).toBe('test-path-workflow');
expect(workflow!.description).toBe('Test workflow for path-based loading');
});
it('should load workflow by relative path', () => {
const originalCwd = process.cwd();
try {
process.chdir(tempDir);
const relativePath = './test-workflow.yaml';
const workflow = loadWorkflowFromPath(relativePath, tempDir);
expect(workflow).not.toBeNull();
expect(workflow!.name).toBe('test-path-workflow');
} finally {
process.chdir(originalCwd);
}
});
it('should load workflow with .yaml extension in name', () => {
const pathWithExtension = join(tempDir, 'test-workflow.yaml');
const workflow = loadWorkflowFromPath(pathWithExtension);
expect(workflow).not.toBeNull();
expect(workflow!.name).toBe('test-path-workflow');
});
it('should return null for non-existent path', () => {
const nonExistentPath = join(tempDir, 'non-existent.yaml');
const workflow = loadWorkflowFromPath(nonExistentPath);
expect(workflow).toBeNull();
});
it('should maintain backward compatibility with name-based loading', () => {
// Load builtin workflow by name
const workflow = loadWorkflow('default');
expect(workflow).not.toBeNull();
expect(workflow!.name).toBe('default');
});
it('should prioritize project-local workflows over global when loading by name', () => {
// Create project-local workflow directory
const projectWorkflowsDir = join(projectDir, '.takt', 'workflows');
mkdirSync(projectWorkflowsDir, { recursive: true });
// Create project-local workflow with same name as builtin
writeFileSync(
join(projectWorkflowsDir, 'default.yaml'),
`name: project-override
description: Project-local override of default workflow
initial_step: custom
max_iterations: 1
steps:
- name: custom
agent: custom
instruction: "Custom step"
`,
);
// Load by name with projectCwd - should get project-local version
const workflow = loadWorkflow('default', projectDir);
expect(workflow).not.toBeNull();
expect(workflow!.name).toBe('project-override');
expect(workflow!.description).toBe('Project-local override of default workflow');
});
it('should load workflows via loadWorkflowFromPath function', () => {
// Absolute paths
const pathWithSlash = join(tempDir, 'test-workflow.yaml');
const workflow1 = loadWorkflowFromPath(pathWithSlash);
expect(workflow1).not.toBeNull();
// Relative paths
const workflow2 = loadWorkflowFromPath('./test-workflow.yaml', tempDir);
expect(workflow2).not.toBeNull();
// Explicit path loading
const yamlFile = join(tempDir, 'test-workflow.yaml');
const workflow3 = loadWorkflowFromPath(yamlFile);
expect(workflow3).not.toBeNull();
});
it('should handle workflow files with .yml extension', () => {
// Create workflow with .yml extension
const ymlPath = join(tempDir, 'test-yml.yml');
writeFileSync(
ymlPath,
`name: yml-workflow
description: Workflow with .yml extension
initial_step: start
max_iterations: 1
steps:
- name: start
agent: starter
instruction: "Start"
`,
);
const workflow = loadWorkflowFromPath(ymlPath);
expect(workflow).not.toBeNull();
expect(workflow!.name).toBe('yml-workflow');
});
it('should resolve relative paths against provided base directory', () => {
const relativePath = 'test-workflow.yaml';
const workflow = loadWorkflowFromPath(relativePath, tempDir);
expect(workflow).not.toBeNull();
expect(workflow!.name).toBe('test-path-workflow');
});
});
// Import for test setup
import { mkdirSync } from 'node:fs';

View File

@ -111,6 +111,7 @@ export interface SelectAndExecuteOptions {
autoPr?: boolean; autoPr?: boolean;
repo?: string; repo?: string;
workflow?: string; workflow?: string;
workflowPath?: string;
createWorktree?: boolean | undefined; createWorktree?: boolean | undefined;
} }
@ -120,9 +121,24 @@ async function selectAndExecuteTask(
options?: SelectAndExecuteOptions, options?: SelectAndExecuteOptions,
agentOverrides?: TaskExecutionOptions, agentOverrides?: TaskExecutionOptions,
): Promise<void> { ): Promise<void> {
const selectedWorkflow = await determineWorkflow(cwd, options?.workflow); // Validate that only one workflow option is specified
if (options?.workflow && options?.workflowPath) {
error('Cannot specify both --workflow and --workflow-path');
process.exit(1);
}
if (selectedWorkflow === null) { let workflowIdentifier: string | null;
let isPathBased = false;
if (options?.workflowPath) {
workflowIdentifier = await determineWorkflowPath(cwd, options.workflowPath);
isPathBased = true;
} else {
workflowIdentifier = await determineWorkflow(cwd, options?.workflow);
isPathBased = false;
}
if (workflowIdentifier === null) {
info('Cancelled'); info('Cancelled');
return; return;
} }
@ -133,8 +149,8 @@ async function selectAndExecuteTask(
options?.createWorktree, options?.createWorktree,
); );
log.info('Starting task execution', { workflow: selectedWorkflow, worktree: isWorktree }); log.info('Starting task execution', { workflow: workflowIdentifier, worktree: isWorktree, pathBased: isPathBased });
const taskSuccess = await executeTask(task, execCwd, selectedWorkflow, cwd, agentOverrides); const taskSuccess = await executeTask(task, execCwd, workflowIdentifier, isPathBased, cwd, agentOverrides);
if (taskSuccess && isWorktree) { if (taskSuccess && isWorktree) {
const commitResult = autoCommitAndPush(execCwd, task, cwd); const commitResult = autoCommitAndPush(execCwd, task, cwd);
@ -149,7 +165,7 @@ async function selectAndExecuteTask(
const shouldCreatePr = options?.autoPr === true || await confirm('Create pull request?', false); const shouldCreatePr = options?.autoPr === true || await confirm('Create pull request?', false);
if (shouldCreatePr) { if (shouldCreatePr) {
info('Creating pull request...'); info('Creating pull request...');
const prBody = buildPrBody(undefined, `Workflow \`${selectedWorkflow}\` completed successfully.`); const prBody = buildPrBody(undefined, `Workflow \`${workflowIdentifier}\` completed successfully.`);
const prResult = createPullRequest(execCwd, { const prResult = createPullRequest(execCwd, {
branch, branch,
title: task.length > 100 ? `${task.slice(0, 97)}...` : task, title: task.length > 100 ? `${task.slice(0, 97)}...` : task,
@ -171,12 +187,17 @@ async function selectAndExecuteTask(
} }
/** /**
* Ask user whether to create a shared clone, and create one if confirmed. * Determine workflow to use.
* Returns the execution directory and whether a clone was created. * If override is provided, validate it (either as a name or path).
* Task name is summarized to English by AI for use in branch/clone names. * Otherwise, prompt user to select interactively.
*/
/**
* Determine workflow to use (name-based only).
* For path-based loading, use determineWorkflowPath() instead.
*/ */
async function determineWorkflow(cwd: string, override?: string): Promise<string | null> { async function determineWorkflow(cwd: string, override?: string): Promise<string | null> {
if (override) { if (override) {
// Validate workflow name exists
const availableWorkflows = listWorkflows(); const availableWorkflows = listWorkflows();
const knownWorkflows = availableWorkflows.length === 0 ? [DEFAULT_WORKFLOW_NAME] : availableWorkflows; const knownWorkflows = availableWorkflows.length === 0 ? [DEFAULT_WORKFLOW_NAME] : availableWorkflows;
if (!knownWorkflows.includes(override)) { if (!knownWorkflows.includes(override)) {
@ -188,6 +209,34 @@ async function determineWorkflow(cwd: string, override?: string): Promise<string
return selectWorkflow(cwd); return selectWorkflow(cwd);
} }
/**
* Determine workflow path (path-based loading).
* Validates that the file exists.
*/
async function determineWorkflowPath(cwd: string, workflowPath: string): Promise<string | null> {
const { existsSync } = await import('node:fs');
const { resolve: resolvePath, isAbsolute } = await import('node:path');
const { homedir } = await import('node:os');
let resolvedPath = workflowPath;
// Handle home directory
if (workflowPath.startsWith('~')) {
const home = homedir();
resolvedPath = resolvePath(home, workflowPath.slice(1).replace(/^\//, ''));
} else if (!isAbsolute(workflowPath)) {
// Relative path
resolvedPath = resolvePath(cwd, workflowPath);
}
if (!existsSync(resolvedPath)) {
error(`Workflow file not found: ${workflowPath}`);
return null;
}
return workflowPath; // Return original path (loader will resolve it)
}
export async function confirmAndCreateWorktree( export async function confirmAndCreateWorktree(
cwd: string, cwd: string,
task: string, task: string,
@ -254,7 +303,8 @@ program
// --- Global options --- // --- Global options ---
program program
.option('-i, --issue <number>', 'GitHub issue number (equivalent to #N)', (val: string) => parseInt(val, 10)) .option('-i, --issue <number>', 'GitHub issue number (equivalent to #N)', (val: string) => parseInt(val, 10))
.option('-w, --workflow <name>', 'Workflow to use') .option('-w, --workflow <name>', 'Workflow name to use')
.option('--workflow-path <path>', 'Path to workflow file (alternative to --workflow)')
.option('-b, --branch <name>', 'Branch name (auto-generated if omitted)') .option('-b, --branch <name>', 'Branch name (auto-generated if omitted)')
.option('--auto-pr', 'Create PR after successful execution') .option('--auto-pr', 'Create PR after successful execution')
.option('--repo <owner/repo>', 'Repository (defaults to current)') .option('--repo <owner/repo>', 'Repository (defaults to current)')
@ -388,6 +438,7 @@ program
autoPr: opts.autoPr === true, autoPr: opts.autoPr === true,
repo: opts.repo as string | undefined, repo: opts.repo as string | undefined,
workflow: opts.workflow as string | undefined, workflow: opts.workflow as string | undefined,
workflowPath: opts.workflowPath as string | undefined,
createWorktree: createWorktreeOverride, createWorktree: createWorktreeOverride,
}; };

View File

@ -324,7 +324,7 @@ export async function instructBranch(
: instruction; : instruction;
// 5. Execute task on temp clone // 5. Execute task on temp clone
const taskSuccess = await executeTask(fullInstruction, clone.path, selectedWorkflow, projectDir, options); const taskSuccess = await executeTask(fullInstruction, clone.path, selectedWorkflow, false, projectDir, options);
// 6. Auto-commit+push if successful // 6. Auto-commit+push if successful
if (taskSuccess) { if (taskSuccess) {

View File

@ -191,7 +191,7 @@ export async function executePipeline(options: PipelineExecutionOptions): Promis
? { provider: options.provider, model: options.model } ? { provider: options.provider, model: options.model }
: undefined; : undefined;
const taskSuccess = await executeTask(task, cwd, workflow, undefined, agentOverrides); const taskSuccess = await executeTask(task, cwd, workflow, false, undefined, agentOverrides);
if (!taskSuccess) { if (!taskSuccess) {
error(`Workflow '${workflow}' failed`); error(`Workflow '${workflow}' failed`);

View File

@ -2,7 +2,7 @@
* Task execution logic * Task execution logic
*/ */
import { loadWorkflow, loadGlobalConfig } from '../config/index.js'; import { loadWorkflow, loadWorkflowFromPath, loadGlobalConfig } from '../config/index.js';
import { TaskRunner, type TaskInfo } from '../task/index.js'; import { TaskRunner, type TaskInfo } from '../task/index.js';
import { createSharedClone } from '../task/clone.js'; import { createSharedClone } from '../task/clone.js';
import { autoCommitAndPush } from '../task/autoCommit.js'; import { autoCommitAndPush } from '../task/autoCommit.js';
@ -31,28 +31,38 @@ export interface TaskExecutionOptions {
* Execute a single task with workflow * Execute a single task with workflow
* @param task - Task content * @param task - Task content
* @param cwd - Working directory (may be a clone path) * @param cwd - Working directory (may be a clone path)
* @param workflowName - Workflow to use * @param workflowIdentifier - Workflow name or path
* @param isPathBased - True if workflowIdentifier is a file path, false if it's a name
* @param projectCwd - Project root (where .takt/ lives). Defaults to cwd. * @param projectCwd - Project root (where .takt/ lives). Defaults to cwd.
*/ */
export async function executeTask( export async function executeTask(
task: string, task: string,
cwd: string, cwd: string,
workflowName: string = DEFAULT_WORKFLOW_NAME, workflowIdentifier: string = DEFAULT_WORKFLOW_NAME,
isPathBased: boolean = false,
projectCwd?: string, projectCwd?: string,
options?: TaskExecutionOptions options?: TaskExecutionOptions
): Promise<boolean> { ): Promise<boolean> {
const workflowConfig = loadWorkflow(workflowName); const effectiveProjectCwd = projectCwd || cwd;
const workflowConfig = isPathBased
? loadWorkflowFromPath(workflowIdentifier, effectiveProjectCwd)
: loadWorkflow(workflowIdentifier, effectiveProjectCwd);
if (!workflowConfig) { if (!workflowConfig) {
error(`Workflow "${workflowName}" not found.`); if (isPathBased) {
info('Available workflows are in ~/.takt/workflows/'); error(`Workflow file not found: ${workflowIdentifier}`);
} else {
error(`Workflow "${workflowIdentifier}" not found.`);
info('Available workflows are in ~/.takt/workflows/ or .takt/workflows/');
info('Use "takt switch" to select a workflow.'); info('Use "takt switch" to select a workflow.');
}
return false; return false;
} }
log.debug('Running workflow', { log.debug('Running workflow', {
name: workflowConfig.name, name: workflowConfig.name,
steps: workflowConfig.steps.map(s => s.name), steps: workflowConfig.steps.map((s: { name: string }) => s.name),
}); });
const globalConfig = loadGlobalConfig(); const globalConfig = loadGlobalConfig();
@ -87,7 +97,7 @@ export async function executeAndCompleteTask(
const { execCwd, execWorkflow, isWorktree } = await resolveTaskExecution(task, cwd, workflowName); const { execCwd, execWorkflow, isWorktree } = await resolveTaskExecution(task, cwd, workflowName);
// cwd is always the project root; pass it as projectCwd so reports/sessions go there // cwd is always the project root; pass it as projectCwd so reports/sessions go there
const taskSuccess = await executeTask(task.content, execCwd, execWorkflow, cwd, options); const taskSuccess = await executeTask(task.content, execCwd, execWorkflow, false, cwd, options);
const completedAt = new Date().toISOString(); const completedAt = new Date().toISOString();
if (taskSuccess && isWorktree) { if (taskSuccess && isWorktree) {

View File

@ -48,7 +48,7 @@ export async function switchWorkflow(cwd: string, workflowName?: string): Promis
} }
// Check if workflow exists // Check if workflow exists
const config = getBuiltinWorkflow(workflowName) || loadWorkflow(workflowName); const config = getBuiltinWorkflow(workflowName) || loadWorkflow(workflowName, cwd);
if (!config) { if (!config) {
error(`Workflow "${workflowName}" not found`); error(`Workflow "${workflowName}" not found`);

View File

@ -8,6 +8,7 @@
export { export {
getBuiltinWorkflow, getBuiltinWorkflow,
loadWorkflowFromFile, loadWorkflowFromFile,
loadWorkflowFromPath,
loadWorkflow, loadWorkflow,
loadAllWorkflows, loadAllWorkflows,
listWorkflows, listWorkflows,

View File

@ -1,17 +1,20 @@
/** /**
* Workflow configuration loader * Workflow configuration loader
* *
* Loads workflows with user builtin fallback: * Loads workflows with the following priority:
* 1. User workflows: ~/.takt/workflows/{name}.yaml * 1. Path-based input (absolute, relative, or home-dir) load directly from file
* 2. Builtin workflows: resources/global/{lang}/workflows/{name}.yaml * 2. Project-local workflows: .takt/workflows/{name}.yaml
* 3. User workflows: ~/.takt/workflows/{name}.yaml
* 4. Builtin workflows: resources/global/{lang}/workflows/{name}.yaml
*/ */
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'; import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
import { join, dirname, basename } from 'node:path'; import { join, dirname, basename, resolve, isAbsolute } from 'node:path';
import { homedir } from 'node:os';
import { parse as parseYaml } from 'yaml'; import { parse as parseYaml } from 'yaml';
import { WorkflowConfigRawSchema } from '../models/schemas.js'; import { WorkflowConfigRawSchema } from '../models/schemas.js';
import type { WorkflowConfig, WorkflowStep, WorkflowRule, ReportConfig, ReportObjectConfig } from '../models/types.js'; import type { WorkflowConfig, WorkflowStep, WorkflowRule, ReportConfig, ReportObjectConfig } from '../models/types.js';
import { getGlobalWorkflowsDir, getBuiltinWorkflowsDir } from './paths.js'; import { getGlobalWorkflowsDir, getBuiltinWorkflowsDir, getProjectConfigDir } from './paths.js';
import { getLanguage, getDisabledBuiltins } from './globalConfig.js'; import { getLanguage, getDisabledBuiltins } from './globalConfig.js';
/** Get builtin workflow by name */ /** Get builtin workflow by name */
@ -241,18 +244,78 @@ export function loadWorkflowFromFile(filePath: string): WorkflowConfig {
} }
/** /**
* Load workflow by name. * Resolve a path that may be relative, absolute, or home-directory-relative.
* Priority: user (~/.takt/workflows/) builtin (resources/global/{lang}/workflows/) * @param pathInput Path to resolve
* @param basePath Base directory for relative paths (defaults to cwd)
* @returns Absolute resolved path
*/ */
export function loadWorkflow(name: string): WorkflowConfig | null { function resolvePath(pathInput: string, basePath: string = process.cwd()): string {
// 1. User workflow // Home directory expansion
if (pathInput.startsWith('~')) {
const home = homedir();
return resolve(home, pathInput.slice(1).replace(/^\//, ''));
}
// Absolute path
if (isAbsolute(pathInput)) {
return pathInput;
}
// Relative path
return resolve(basePath, pathInput);
}
/**
* Load workflow from a file path (explicit path-based loading).
* Use this when user explicitly specifies a workflow file path via --workflow-path.
*
* @param filePath Path to workflow file (absolute, relative, or home-dir prefixed with ~)
* @param basePath Base directory for resolving relative paths (default: cwd)
* @returns WorkflowConfig or null if file not found
*/
export function loadWorkflowFromPath(
filePath: string,
basePath: string = process.cwd()
): WorkflowConfig | null {
const resolvedPath = resolvePath(filePath, basePath);
if (!existsSync(resolvedPath)) {
return null;
}
return loadWorkflowFromFile(resolvedPath);
}
/**
* Load workflow by name (name-based loading only, no path detection).
*
* Priority:
* 1. Project-local workflows .takt/workflows/{name}.yaml
* 2. User workflows ~/.takt/workflows/{name}.yaml
* 3. Builtin workflows resources/global/{lang}/workflows/{name}.yaml
*
* @param name Workflow name (not a file path)
* @param projectCwd Project root directory (default: cwd, for project-local workflow resolution)
*/
export function loadWorkflow(
name: string,
projectCwd: string = process.cwd()
): WorkflowConfig | null {
// 1. Project-local workflow (.takt/workflows/{name}.yaml)
const projectWorkflowsDir = join(getProjectConfigDir(projectCwd), 'workflows');
const projectWorkflowPath = join(projectWorkflowsDir, `${name}.yaml`);
if (existsSync(projectWorkflowPath)) {
return loadWorkflowFromFile(projectWorkflowPath);
}
// 2. User workflow (~/.takt/workflows/{name}.yaml)
const globalWorkflowsDir = getGlobalWorkflowsDir(); const globalWorkflowsDir = getGlobalWorkflowsDir();
const workflowYamlPath = join(globalWorkflowsDir, `${name}.yaml`); const workflowYamlPath = join(globalWorkflowsDir, `${name}.yaml`);
if (existsSync(workflowYamlPath)) { if (existsSync(workflowYamlPath)) {
return loadWorkflowFromFile(workflowYamlPath); return loadWorkflowFromFile(workflowYamlPath);
} }
// 2. Builtin fallback // 3. Builtin fallback
return getBuiltinWorkflow(name); return getBuiltinWorkflow(name);
} }