feat: workflowやconfig指定をオプションで受け入れpath対応にする (#81)
This commit is contained in:
parent
05bf51cfbb
commit
14130ee958
@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
189
src/__tests__/workflow-path-loading.test.ts
Normal file
189
src/__tests__/workflow-path-loading.test.ts
Normal 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';
|
||||||
69
src/cli.ts
69
src/cli.ts
@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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`);
|
||||||
|
|||||||
@ -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}`);
|
||||||
info('Use "takt switch" to select a workflow.');
|
} else {
|
||||||
|
error(`Workflow "${workflowIdentifier}" not found.`);
|
||||||
|
info('Available workflows are in ~/.takt/workflows/ or .takt/workflows/');
|
||||||
|
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) {
|
||||||
|
|||||||
@ -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`);
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
export {
|
export {
|
||||||
getBuiltinWorkflow,
|
getBuiltinWorkflow,
|
||||||
loadWorkflowFromFile,
|
loadWorkflowFromFile,
|
||||||
|
loadWorkflowFromPath,
|
||||||
loadWorkflow,
|
loadWorkflow,
|
||||||
loadAllWorkflows,
|
loadAllWorkflows,
|
||||||
listWorkflows,
|
listWorkflows,
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user