394 lines
12 KiB
TypeScript
394 lines
12 KiB
TypeScript
/**
|
|
* TAKT タスク実行モード
|
|
*
|
|
* .takt/tasks/ ディレクトリ内のタスクファイルを読み込み、
|
|
* 順番に実行してレポートを生成する。
|
|
*
|
|
* Supports both .md (plain text) and .yaml/.yml (structured) task files.
|
|
*
|
|
* 使用方法:
|
|
* /task # タスク一覧を表示
|
|
* /task run # 次のタスクを実行
|
|
* /task run <filename> # 指定したタスクを実行
|
|
* /task list # タスク一覧を表示
|
|
*/
|
|
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { parseTaskFiles, parseTaskFile, type ParsedTask } from './parser.js';
|
|
import type { TaskInfo, TaskResult, TaskListItem } from './types.js';
|
|
import { createLogger } from '../../shared/utils/index.js';
|
|
|
|
export type { TaskInfo, TaskResult, TaskListItem };
|
|
|
|
const log = createLogger('task-runner');
|
|
|
|
/**
|
|
* タスク実行管理クラス
|
|
*/
|
|
export class TaskRunner {
|
|
private projectDir: string;
|
|
private tasksDir: string;
|
|
private completedDir: string;
|
|
private failedDir: string;
|
|
private claimedPaths = new Set<string>();
|
|
|
|
constructor(projectDir: string) {
|
|
this.projectDir = projectDir;
|
|
this.tasksDir = path.join(projectDir, '.takt', 'tasks');
|
|
this.completedDir = path.join(projectDir, '.takt', 'completed');
|
|
this.failedDir = path.join(projectDir, '.takt', 'failed');
|
|
}
|
|
|
|
/** ディレクトリ構造を作成 */
|
|
ensureDirs(): void {
|
|
fs.mkdirSync(this.tasksDir, { recursive: true });
|
|
fs.mkdirSync(this.completedDir, { recursive: true });
|
|
fs.mkdirSync(this.failedDir, { recursive: true });
|
|
}
|
|
|
|
/** タスクディレクトリのパスを取得 */
|
|
getTasksDir(): string {
|
|
return this.tasksDir;
|
|
}
|
|
|
|
/**
|
|
* タスク一覧を取得
|
|
* @returns タスク情報のリスト(ファイル名順)
|
|
*/
|
|
listTasks(): TaskInfo[] {
|
|
this.ensureDirs();
|
|
|
|
try {
|
|
const parsed = parseTaskFiles(this.tasksDir);
|
|
return parsed.map(toTaskInfo);
|
|
} catch (err) {
|
|
const nodeErr = err as NodeJS.ErrnoException;
|
|
if (nodeErr.code !== 'ENOENT') {
|
|
throw err; // 予期しないエラーは再スロー
|
|
}
|
|
// ENOENT は許容(ディレクトリ未作成)
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 指定した名前のタスクを取得
|
|
* Searches for .yaml, .yml, and .md files in that order.
|
|
*/
|
|
getTask(name: string): TaskInfo | null {
|
|
this.ensureDirs();
|
|
|
|
const extensions = ['.yaml', '.yml', '.md'];
|
|
|
|
for (const ext of extensions) {
|
|
const filePath = path.join(this.tasksDir, `${name}${ext}`);
|
|
if (!fs.existsSync(filePath)) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const parsed = parseTaskFile(filePath);
|
|
return toTaskInfo(parsed);
|
|
} catch {
|
|
// Parse error: skip this extension
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 次に実行すべきタスクを取得(最初のタスク)
|
|
*/
|
|
getNextTask(): TaskInfo | null {
|
|
const tasks = this.listTasks();
|
|
return tasks[0] ?? null;
|
|
}
|
|
|
|
/**
|
|
* 予約付きタスク取得
|
|
*
|
|
* claimed 済みのタスクを除外して返し、返したタスクを claimed に追加する。
|
|
* 並列実行時に同一タスクが複数ワーカーに返されることを防ぐ。
|
|
*/
|
|
claimNextTasks(count: number): TaskInfo[] {
|
|
const allTasks = this.listTasks();
|
|
const unclaimed = allTasks.filter((t) => !this.claimedPaths.has(t.filePath));
|
|
const claimed = unclaimed.slice(0, count);
|
|
for (const task of claimed) {
|
|
this.claimedPaths.add(task.filePath);
|
|
}
|
|
return claimed;
|
|
}
|
|
|
|
/**
|
|
* タスクを完了としてマーク
|
|
*
|
|
* タスクファイルを .takt/completed に移動し、
|
|
* レポートファイルを作成する。
|
|
*
|
|
* @returns レポートファイルのパス
|
|
*/
|
|
completeTask(result: TaskResult): string {
|
|
if (!result.success) {
|
|
throw new Error('Cannot complete a failed task. Use failTask() instead.');
|
|
}
|
|
return this.moveTask(result, this.completedDir);
|
|
}
|
|
|
|
/**
|
|
* タスクを失敗としてマーク
|
|
*
|
|
* タスクファイルを .takt/failed に移動し、
|
|
* レポートファイルを作成する。
|
|
*
|
|
* @returns レポートファイルのパス
|
|
*/
|
|
failTask(result: TaskResult): string {
|
|
return this.moveTask(result, this.failedDir);
|
|
}
|
|
|
|
/**
|
|
* pendingタスクを TaskListItem 形式で取得
|
|
*/
|
|
listPendingTaskItems(): TaskListItem[] {
|
|
return this.listTasks().map((task) => ({
|
|
kind: 'pending' as const,
|
|
name: task.name,
|
|
createdAt: task.createdAt,
|
|
filePath: task.filePath,
|
|
content: task.content.trim().split('\n')[0]?.slice(0, 80) ?? '',
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* failedタスクの一覧を取得
|
|
* .takt/failed/ 内のサブディレクトリを走査し、TaskListItem を返す
|
|
*/
|
|
listFailedTasks(): TaskListItem[] {
|
|
this.ensureDirs();
|
|
|
|
const entries = fs.readdirSync(this.failedDir);
|
|
|
|
return entries
|
|
.filter((entry) => {
|
|
const entryPath = path.join(this.failedDir, entry);
|
|
return fs.statSync(entryPath).isDirectory() && entry.includes('_');
|
|
})
|
|
.map((entry) => {
|
|
const entryPath = path.join(this.failedDir, entry);
|
|
const underscoreIdx = entry.indexOf('_');
|
|
const timestampRaw = entry.slice(0, underscoreIdx);
|
|
const name = entry.slice(underscoreIdx + 1);
|
|
const createdAt = timestampRaw.replace(
|
|
/^(\d{4}-\d{2}-\d{2}T\d{2})-(\d{2})-(\d{2})$/,
|
|
'$1:$2:$3',
|
|
);
|
|
const content = this.readFailedTaskContent(entryPath);
|
|
return { kind: 'failed' as const, name, createdAt, filePath: entryPath, content };
|
|
})
|
|
.filter((item) => item.name !== '');
|
|
}
|
|
|
|
/**
|
|
* failedタスクディレクトリ内のタスクファイルから先頭1行を読み取る
|
|
*/
|
|
private readFailedTaskContent(dirPath: string): string {
|
|
const taskExtensions = ['.md', '.yaml', '.yml'];
|
|
let files: string[];
|
|
try {
|
|
files = fs.readdirSync(dirPath);
|
|
} catch (err) {
|
|
log.error('Failed to read failed task directory', { dirPath, error: String(err) });
|
|
return '';
|
|
}
|
|
|
|
for (const file of files) {
|
|
const ext = path.extname(file);
|
|
if (file === 'report.md' || file === 'log.json') continue;
|
|
if (!taskExtensions.includes(ext)) continue;
|
|
|
|
try {
|
|
const raw = fs.readFileSync(path.join(dirPath, file), 'utf-8');
|
|
return raw.trim().split('\n')[0]?.slice(0, 80) ?? '';
|
|
} catch (err) {
|
|
log.error('Failed to read failed task file', { file, dirPath, error: String(err) });
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Requeue a failed task back to .takt/tasks/
|
|
*
|
|
* Copies the task file from failed directory to tasks directory.
|
|
* If startMovement is specified and the task is YAML, adds start_movement field.
|
|
* If retryNote is specified and the task is YAML, adds retry_note field.
|
|
* Original failed directory is preserved for history.
|
|
*
|
|
* @param failedTaskDir - Path to failed task directory (e.g., .takt/failed/2026-01-31T12-00-00_my-task/)
|
|
* @param startMovement - Optional movement to start from (written to task file)
|
|
* @param retryNote - Optional note about why task is being retried (written to task file)
|
|
* @returns The path to the requeued task file
|
|
* @throws Error if task file not found or copy fails
|
|
*/
|
|
requeueFailedTask(failedTaskDir: string, startMovement?: string, retryNote?: string): string {
|
|
this.ensureDirs();
|
|
|
|
// Find task file in failed directory
|
|
const taskExtensions = ['.yaml', '.yml', '.md'];
|
|
let files: string[];
|
|
try {
|
|
files = fs.readdirSync(failedTaskDir);
|
|
} catch (err) {
|
|
throw new Error(`Failed to read failed task directory: ${failedTaskDir} - ${err}`);
|
|
}
|
|
|
|
let taskFile: string | null = null;
|
|
let taskExt: string | null = null;
|
|
|
|
for (const file of files) {
|
|
const ext = path.extname(file);
|
|
if (file === 'report.md' || file === 'log.json') continue;
|
|
if (!taskExtensions.includes(ext)) continue;
|
|
taskFile = path.join(failedTaskDir, file);
|
|
taskExt = ext;
|
|
break;
|
|
}
|
|
|
|
if (!taskFile || !taskExt) {
|
|
throw new Error(`No task file found in failed directory: ${failedTaskDir}`);
|
|
}
|
|
|
|
// Read task content
|
|
const taskContent = fs.readFileSync(taskFile, 'utf-8');
|
|
const taskName = path.basename(taskFile, taskExt);
|
|
|
|
// Destination path
|
|
const destFile = path.join(this.tasksDir, `${taskName}${taskExt}`);
|
|
|
|
// For YAML files, add start_movement and retry_note if specified
|
|
let finalContent = taskContent;
|
|
if (taskExt === '.yaml' || taskExt === '.yml') {
|
|
if (startMovement) {
|
|
// Check if start_movement already exists
|
|
if (!/^start_movement:/m.test(finalContent)) {
|
|
// Add start_movement field at the end
|
|
finalContent = finalContent.trimEnd() + `\nstart_movement: ${startMovement}\n`;
|
|
} else {
|
|
// Replace existing start_movement
|
|
finalContent = finalContent.replace(/^start_movement:.*$/m, `start_movement: ${startMovement}`);
|
|
}
|
|
}
|
|
|
|
if (retryNote) {
|
|
// Escape double quotes in retry note for YAML string
|
|
const escapedNote = retryNote.replace(/"/g, '\\"');
|
|
// Check if retry_note already exists
|
|
if (!/^retry_note:/m.test(finalContent)) {
|
|
// Add retry_note field at the end
|
|
finalContent = finalContent.trimEnd() + `\nretry_note: "${escapedNote}"\n`;
|
|
} else {
|
|
// Replace existing retry_note
|
|
finalContent = finalContent.replace(/^retry_note:.*$/m, `retry_note: "${escapedNote}"`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Write to tasks directory
|
|
fs.writeFileSync(destFile, finalContent, 'utf-8');
|
|
|
|
log.info('Requeued failed task', { from: failedTaskDir, to: destFile, startMovement });
|
|
|
|
return destFile;
|
|
}
|
|
|
|
/**
|
|
* タスクファイルを指定ディレクトリに移動し、レポート・ログを生成する
|
|
*/
|
|
private moveTask(result: TaskResult, targetDir: string): string {
|
|
this.ensureDirs();
|
|
|
|
// タイムスタンプを生成
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
|
|
// ターゲットディレクトリにサブディレクトリを作成
|
|
const taskTargetDir = path.join(
|
|
targetDir,
|
|
`${timestamp}_${result.task.name}`
|
|
);
|
|
fs.mkdirSync(taskTargetDir, { recursive: true });
|
|
|
|
// 元のタスクファイルを移動(元の拡張子を保持)
|
|
const originalExt = path.extname(result.task.filePath);
|
|
const movedTaskFile = path.join(taskTargetDir, `${result.task.name}${originalExt}`);
|
|
fs.renameSync(result.task.filePath, movedTaskFile);
|
|
|
|
this.claimedPaths.delete(result.task.filePath);
|
|
|
|
// レポートを生成
|
|
const reportFile = path.join(taskTargetDir, 'report.md');
|
|
const reportContent = this.generateReport(result);
|
|
fs.writeFileSync(reportFile, reportContent, 'utf-8');
|
|
|
|
// ログを保存
|
|
const logFile = path.join(taskTargetDir, 'log.json');
|
|
const logData = {
|
|
taskName: result.task.name,
|
|
success: result.success,
|
|
startedAt: result.startedAt,
|
|
completedAt: result.completedAt,
|
|
executionLog: result.executionLog,
|
|
response: result.response,
|
|
};
|
|
fs.writeFileSync(logFile, JSON.stringify(logData, null, 2), 'utf-8');
|
|
|
|
return reportFile;
|
|
}
|
|
|
|
/**
|
|
* レポートを生成
|
|
*/
|
|
private generateReport(result: TaskResult): string {
|
|
const status = result.success ? '成功' : '失敗';
|
|
|
|
return `# タスク実行レポート
|
|
|
|
## 基本情報
|
|
|
|
- タスク名: ${result.task.name}
|
|
- ステータス: ${status}
|
|
- 開始時刻: ${result.startedAt}
|
|
- 完了時刻: ${result.completedAt}
|
|
|
|
## 元のタスク
|
|
|
|
\`\`\`markdown
|
|
${result.task.content}
|
|
\`\`\`
|
|
|
|
## 実行結果
|
|
|
|
${result.response}
|
|
|
|
---
|
|
|
|
*Generated by TAKT Task Runner*
|
|
`;
|
|
}
|
|
}
|
|
|
|
/** Convert ParsedTask to TaskInfo */
|
|
function toTaskInfo(parsed: ParsedTask): TaskInfo {
|
|
return {
|
|
filePath: parsed.filePath,
|
|
name: parsed.name,
|
|
content: parsed.content,
|
|
createdAt: parsed.createdAt,
|
|
data: parsed.data,
|
|
};
|
|
}
|