takt: consolidate-tasks-yaml (#187)
This commit is contained in:
parent
222560a96a
commit
4ca414be6b
@ -1,21 +1,13 @@
|
||||
/**
|
||||
* Tests for addTask command
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
|
||||
// Mock dependencies before importing the module under test
|
||||
vi.mock('../features/interactive/index.js', () => ({
|
||||
interactiveMode: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../infra/providers/index.js', () => ({
|
||||
getProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../infra/config/global/globalConfig.js', () => ({
|
||||
loadGlobalConfig: vi.fn(() => ({ provider: 'claude' })),
|
||||
getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true),
|
||||
@ -26,14 +18,11 @@ vi.mock('../shared/prompt/index.js', () => ({
|
||||
confirm: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../infra/task/summarize.js', () => ({
|
||||
summarizeTaskName: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/ui/index.js', () => ({
|
||||
success: vi.fn(),
|
||||
info: vi.fn(),
|
||||
blankLine: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
||||
@ -76,42 +65,27 @@ vi.mock('../infra/github/issue.js', () => ({
|
||||
|
||||
import { interactiveMode } from '../features/interactive/index.js';
|
||||
import { promptInput, confirm } from '../shared/prompt/index.js';
|
||||
import { summarizeTaskName } from '../infra/task/summarize.js';
|
||||
import { determinePiece } from '../features/tasks/execute/selectAndExecute.js';
|
||||
import { getPieceDescription } from '../infra/config/loaders/pieceResolver.js';
|
||||
import { resolveIssueTask, createIssue } from '../infra/github/issue.js';
|
||||
import { resolveIssueTask } from '../infra/github/issue.js';
|
||||
import { addTask } from '../features/tasks/index.js';
|
||||
|
||||
const mockResolveIssueTask = vi.mocked(resolveIssueTask);
|
||||
const mockCreateIssue = vi.mocked(createIssue);
|
||||
const mockInteractiveMode = vi.mocked(interactiveMode);
|
||||
const mockPromptInput = vi.mocked(promptInput);
|
||||
const mockConfirm = vi.mocked(confirm);
|
||||
const mockSummarizeTaskName = vi.mocked(summarizeTaskName);
|
||||
const mockDeterminePiece = vi.mocked(determinePiece);
|
||||
const mockGetPieceDescription = vi.mocked(getPieceDescription);
|
||||
|
||||
function setupFullFlowMocks(overrides?: {
|
||||
task?: string;
|
||||
slug?: string;
|
||||
}) {
|
||||
const task = overrides?.task ?? '# 認証機能追加\nJWT認証を実装する';
|
||||
const slug = overrides?.slug ?? 'add-auth';
|
||||
|
||||
mockDeterminePiece.mockResolvedValue('default');
|
||||
mockGetPieceDescription.mockReturnValue({ name: 'default', description: '', pieceStructure: '' });
|
||||
mockInteractiveMode.mockResolvedValue({ action: 'execute', task });
|
||||
mockSummarizeTaskName.mockResolvedValue(slug);
|
||||
mockConfirm.mockResolvedValue(false);
|
||||
}
|
||||
|
||||
let testDir: string;
|
||||
|
||||
function loadTasks(dir: string): { tasks: Array<Record<string, unknown>> } {
|
||||
const raw = fs.readFileSync(path.join(dir, '.takt', 'tasks.yaml'), 'utf-8');
|
||||
return parseYaml(raw) as { tasks: Array<Record<string, unknown>> };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testDir = fs.mkdtempSync(path.join(tmpdir(), 'takt-test-'));
|
||||
mockDeterminePiece.mockResolvedValue('default');
|
||||
mockGetPieceDescription.mockReturnValue({ name: 'default', description: '', pieceStructure: '' });
|
||||
mockConfirm.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
@ -122,332 +96,46 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('addTask', () => {
|
||||
it('should cancel when interactive mode is not confirmed', async () => {
|
||||
// Given: user cancels interactive mode
|
||||
mockDeterminePiece.mockResolvedValue('default');
|
||||
mockInteractiveMode.mockResolvedValue({ action: 'cancel', task: '' });
|
||||
it('should create task entry from interactive result', async () => {
|
||||
mockInteractiveMode.mockResolvedValue({ action: 'execute', task: '# 認証機能追加\nJWT認証を実装する' });
|
||||
|
||||
// When
|
||||
await addTask(testDir);
|
||||
|
||||
const tasksDir = path.join(testDir, '.takt', 'tasks');
|
||||
const files = fs.existsSync(tasksDir) ? fs.readdirSync(tasksDir) : [];
|
||||
expect(files.length).toBe(0);
|
||||
expect(mockSummarizeTaskName).not.toHaveBeenCalled();
|
||||
const tasks = loadTasks(testDir).tasks;
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0]?.content).toContain('JWT認証を実装する');
|
||||
expect(tasks[0]?.piece).toBe('default');
|
||||
});
|
||||
|
||||
it('should create task file with AI-summarized content', async () => {
|
||||
// Given: full flow setup
|
||||
setupFullFlowMocks();
|
||||
|
||||
// When
|
||||
await addTask(testDir);
|
||||
|
||||
// Then: task file created with summarized content
|
||||
const tasksDir = path.join(testDir, '.takt', 'tasks');
|
||||
const taskFile = path.join(tasksDir, 'add-auth.yaml');
|
||||
expect(fs.existsSync(taskFile)).toBe(true);
|
||||
|
||||
const content = fs.readFileSync(taskFile, 'utf-8');
|
||||
expect(content).toContain('# 認証機能追加');
|
||||
expect(content).toContain('JWT認証を実装する');
|
||||
});
|
||||
|
||||
it('should use first line of task for filename generation', async () => {
|
||||
setupFullFlowMocks({
|
||||
task: 'First line summary\nSecond line details',
|
||||
slug: 'first-line',
|
||||
});
|
||||
|
||||
await addTask(testDir);
|
||||
|
||||
expect(mockSummarizeTaskName).toHaveBeenCalledWith('First line summary', { cwd: testDir });
|
||||
});
|
||||
|
||||
it('should append counter for duplicate filenames', async () => {
|
||||
// Given: first task creates 'my-task.yaml'
|
||||
setupFullFlowMocks({ slug: 'my-task' });
|
||||
await addTask(testDir);
|
||||
|
||||
// When: create second task with same slug
|
||||
setupFullFlowMocks({ slug: 'my-task' });
|
||||
await addTask(testDir);
|
||||
|
||||
// Then: second file has counter
|
||||
const tasksDir = path.join(testDir, '.takt', 'tasks');
|
||||
expect(fs.existsSync(path.join(tasksDir, 'my-task.yaml'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tasksDir, 'my-task-1.yaml'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should include worktree option when confirmed', async () => {
|
||||
// Given: user confirms worktree
|
||||
setupFullFlowMocks({ slug: 'with-worktree' });
|
||||
it('should include worktree settings when enabled', async () => {
|
||||
mockInteractiveMode.mockResolvedValue({ action: 'execute', task: 'Task content' });
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
mockPromptInput.mockResolvedValue('');
|
||||
mockPromptInput.mockResolvedValueOnce('/custom/path').mockResolvedValueOnce('feat/branch');
|
||||
|
||||
// When
|
||||
await addTask(testDir);
|
||||
|
||||
// Then
|
||||
const taskFile = path.join(testDir, '.takt', 'tasks', 'with-worktree.yaml');
|
||||
const content = fs.readFileSync(taskFile, 'utf-8');
|
||||
expect(content).toContain('worktree: true');
|
||||
const task = loadTasks(testDir).tasks[0]!;
|
||||
expect(task.worktree).toBe('/custom/path');
|
||||
expect(task.branch).toBe('feat/branch');
|
||||
});
|
||||
|
||||
it('should include custom worktree path when provided', async () => {
|
||||
// Given: user provides custom worktree path
|
||||
setupFullFlowMocks({ slug: 'custom-path' });
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
mockPromptInput
|
||||
.mockResolvedValueOnce('/custom/path')
|
||||
.mockResolvedValueOnce('');
|
||||
|
||||
// When
|
||||
await addTask(testDir);
|
||||
|
||||
// Then
|
||||
const taskFile = path.join(testDir, '.takt', 'tasks', 'custom-path.yaml');
|
||||
const content = fs.readFileSync(taskFile, 'utf-8');
|
||||
expect(content).toContain('worktree: /custom/path');
|
||||
});
|
||||
|
||||
it('should include branch when provided', async () => {
|
||||
// Given: user provides custom branch
|
||||
setupFullFlowMocks({ slug: 'with-branch' });
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
mockPromptInput
|
||||
.mockResolvedValueOnce('')
|
||||
.mockResolvedValueOnce('feat/my-branch');
|
||||
|
||||
// When
|
||||
await addTask(testDir);
|
||||
|
||||
// Then
|
||||
const taskFile = path.join(testDir, '.takt', 'tasks', 'with-branch.yaml');
|
||||
const content = fs.readFileSync(taskFile, 'utf-8');
|
||||
expect(content).toContain('branch: feat/my-branch');
|
||||
});
|
||||
|
||||
it('should include piece selection in task file', async () => {
|
||||
// Given: determinePiece returns a non-default piece
|
||||
setupFullFlowMocks({ slug: 'with-piece' });
|
||||
mockDeterminePiece.mockResolvedValue('review');
|
||||
mockGetPieceDescription.mockReturnValue({ name: 'review', description: 'Code review piece', pieceStructure: '' });
|
||||
it('should create task from issue reference without interactive mode', async () => {
|
||||
mockResolveIssueTask.mockReturnValue('Issue #99: Fix login timeout');
|
||||
mockConfirm.mockResolvedValue(false);
|
||||
|
||||
// When
|
||||
await addTask(testDir);
|
||||
|
||||
// Then
|
||||
const taskFile = path.join(testDir, '.takt', 'tasks', 'with-piece.yaml');
|
||||
const content = fs.readFileSync(taskFile, 'utf-8');
|
||||
expect(content).toContain('piece: review');
|
||||
});
|
||||
|
||||
it('should cancel when piece selection returns null', async () => {
|
||||
// Given: user cancels piece selection
|
||||
mockDeterminePiece.mockResolvedValue(null);
|
||||
|
||||
// When
|
||||
await addTask(testDir);
|
||||
|
||||
// Then: no task file created (cancelled at piece selection)
|
||||
const tasksDir = path.join(testDir, '.takt', 'tasks');
|
||||
const files = fs.readdirSync(tasksDir);
|
||||
expect(files.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should always include piece from determinePiece', async () => {
|
||||
// Given: determinePiece returns 'default'
|
||||
setupFullFlowMocks({ slug: 'default-wf' });
|
||||
mockDeterminePiece.mockResolvedValue('default');
|
||||
mockConfirm.mockResolvedValue(false);
|
||||
|
||||
// When
|
||||
await addTask(testDir);
|
||||
|
||||
// Then: piece field is included
|
||||
const taskFile = path.join(testDir, '.takt', 'tasks', 'default-wf.yaml');
|
||||
const content = fs.readFileSync(taskFile, 'utf-8');
|
||||
expect(content).toContain('piece: default');
|
||||
});
|
||||
|
||||
it('should fetch issue and use directly as task content when given issue reference', async () => {
|
||||
// Given: issue reference "#99"
|
||||
const issueText = 'Issue #99: Fix login timeout\n\nThe login page times out after 30 seconds.';
|
||||
mockResolveIssueTask.mockReturnValue(issueText);
|
||||
mockDeterminePiece.mockResolvedValue('default');
|
||||
|
||||
mockSummarizeTaskName.mockResolvedValue('fix-login-timeout');
|
||||
mockConfirm.mockResolvedValue(false);
|
||||
|
||||
// When
|
||||
await addTask(testDir, '#99');
|
||||
|
||||
// Then: interactiveMode should NOT be called
|
||||
expect(mockInteractiveMode).not.toHaveBeenCalled();
|
||||
|
||||
// Then: resolveIssueTask was called
|
||||
expect(mockResolveIssueTask).toHaveBeenCalledWith('#99');
|
||||
|
||||
// Then: determinePiece was called for piece selection
|
||||
expect(mockDeterminePiece).toHaveBeenCalledWith(testDir);
|
||||
|
||||
// Then: task file created with issue text directly (no AI summarization)
|
||||
const taskFile = path.join(testDir, '.takt', 'tasks', 'fix-login-timeout.yaml');
|
||||
expect(fs.existsSync(taskFile)).toBe(true);
|
||||
const content = fs.readFileSync(taskFile, 'utf-8');
|
||||
expect(content).toContain('Fix login timeout');
|
||||
const task = loadTasks(testDir).tasks[0]!;
|
||||
expect(task.content).toContain('Fix login timeout');
|
||||
expect(task.issue).toBe(99);
|
||||
});
|
||||
|
||||
it('should proceed to worktree settings after issue fetch', async () => {
|
||||
// Given: issue with worktree enabled
|
||||
mockResolveIssueTask.mockReturnValue('Issue text');
|
||||
mockDeterminePiece.mockResolvedValue('default');
|
||||
mockSummarizeTaskName.mockResolvedValue('issue-task');
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
mockPromptInput
|
||||
.mockResolvedValueOnce('') // worktree path (auto)
|
||||
.mockResolvedValueOnce(''); // branch name (auto)
|
||||
|
||||
// When
|
||||
await addTask(testDir, '#42');
|
||||
|
||||
// Then: worktree settings applied
|
||||
const taskFile = path.join(testDir, '.takt', 'tasks', 'issue-task.yaml');
|
||||
const content = fs.readFileSync(taskFile, 'utf-8');
|
||||
expect(content).toContain('worktree: true');
|
||||
});
|
||||
|
||||
it('should handle GitHub API failure gracefully for issue reference', async () => {
|
||||
// Given: resolveIssueTask throws
|
||||
mockResolveIssueTask.mockImplementation(() => {
|
||||
throw new Error('GitHub API rate limit exceeded');
|
||||
});
|
||||
|
||||
// When
|
||||
await addTask(testDir, '#99');
|
||||
|
||||
const tasksDir = path.join(testDir, '.takt', 'tasks');
|
||||
const files = fs.readdirSync(tasksDir);
|
||||
expect(files.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should include issue number in task file when issue reference is used', async () => {
|
||||
// Given: issue reference "#99"
|
||||
const issueText = 'Issue #99: Fix login timeout';
|
||||
mockResolveIssueTask.mockReturnValue(issueText);
|
||||
mockDeterminePiece.mockResolvedValue('default');
|
||||
mockSummarizeTaskName.mockResolvedValue('fix-login-timeout');
|
||||
mockConfirm.mockResolvedValue(false);
|
||||
|
||||
// When
|
||||
await addTask(testDir, '#99');
|
||||
|
||||
// Then: task file contains issue field
|
||||
const taskFile = path.join(testDir, '.takt', 'tasks', 'fix-login-timeout.yaml');
|
||||
expect(fs.existsSync(taskFile)).toBe(true);
|
||||
const content = fs.readFileSync(taskFile, 'utf-8');
|
||||
expect(content).toContain('issue: 99');
|
||||
});
|
||||
|
||||
it('should include piece selection in task file when issue reference is used', async () => {
|
||||
// Given: issue reference "#99" with non-default piece selection
|
||||
const issueText = 'Issue #99: Fix login timeout';
|
||||
mockResolveIssueTask.mockReturnValue(issueText);
|
||||
mockDeterminePiece.mockResolvedValue('review');
|
||||
mockSummarizeTaskName.mockResolvedValue('fix-login-timeout');
|
||||
mockConfirm.mockResolvedValue(false);
|
||||
|
||||
// When
|
||||
await addTask(testDir, '#99');
|
||||
|
||||
// Then: task file contains piece field
|
||||
const taskFile = path.join(testDir, '.takt', 'tasks', 'fix-login-timeout.yaml');
|
||||
expect(fs.existsSync(taskFile)).toBe(true);
|
||||
const content = fs.readFileSync(taskFile, 'utf-8');
|
||||
expect(content).toContain('piece: review');
|
||||
});
|
||||
|
||||
it('should cancel when piece selection returns null for issue reference', async () => {
|
||||
// Given: issue fetched successfully but user cancels piece selection
|
||||
const issueText = 'Issue #99: Fix login timeout';
|
||||
mockResolveIssueTask.mockReturnValue(issueText);
|
||||
it('should not create task when piece selection is cancelled', async () => {
|
||||
mockDeterminePiece.mockResolvedValue(null);
|
||||
|
||||
// When
|
||||
await addTask(testDir, '#99');
|
||||
|
||||
// Then: no task file created (cancelled at piece selection)
|
||||
const tasksDir = path.join(testDir, '.takt', 'tasks');
|
||||
const files = fs.readdirSync(tasksDir);
|
||||
expect(files.length).toBe(0);
|
||||
|
||||
// Then: issue was fetched before cancellation
|
||||
expect(mockResolveIssueTask).toHaveBeenCalledWith('#99');
|
||||
});
|
||||
|
||||
it('should call auto-PR confirm with default true', async () => {
|
||||
// Given: worktree is confirmed so auto-PR prompt is reached
|
||||
setupFullFlowMocks({ slug: 'auto-pr-default' });
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
mockPromptInput.mockResolvedValue('');
|
||||
|
||||
// When
|
||||
await addTask(testDir);
|
||||
|
||||
// Then: second confirm call (Auto-create PR?) has defaultYes=true
|
||||
const autoPrCall = mockConfirm.mock.calls.find(
|
||||
(call) => call[0] === 'Auto-create PR?',
|
||||
);
|
||||
expect(autoPrCall).toBeDefined();
|
||||
expect(autoPrCall![1]).toBe(true);
|
||||
});
|
||||
|
||||
describe('create_issue action', () => {
|
||||
it('should call createIssue when create_issue action is selected', async () => {
|
||||
// Given: interactive mode returns create_issue action
|
||||
const task = 'Create a new feature\nWith detailed description';
|
||||
mockDeterminePiece.mockResolvedValue('default');
|
||||
mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task });
|
||||
mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' });
|
||||
|
||||
// When
|
||||
await addTask(testDir);
|
||||
|
||||
// Then: createIssue is called via createIssueFromTask
|
||||
expect(mockCreateIssue).toHaveBeenCalledWith({
|
||||
title: 'Create a new feature',
|
||||
body: task,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not create task file when create_issue action is selected', async () => {
|
||||
// Given: interactive mode returns create_issue action
|
||||
mockDeterminePiece.mockResolvedValue('default');
|
||||
mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'Some task' });
|
||||
mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' });
|
||||
|
||||
// When
|
||||
await addTask(testDir);
|
||||
|
||||
// Then: no task file created
|
||||
const tasksDir = path.join(testDir, '.takt', 'tasks');
|
||||
const files = fs.existsSync(tasksDir) ? fs.readdirSync(tasksDir) : [];
|
||||
expect(files.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should not prompt for worktree settings when create_issue action is selected', async () => {
|
||||
// Given: interactive mode returns create_issue action
|
||||
mockDeterminePiece.mockResolvedValue('default');
|
||||
mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'Some task' });
|
||||
mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' });
|
||||
|
||||
// When
|
||||
await addTask(testDir);
|
||||
|
||||
// Then: confirm (worktree prompt) is never called
|
||||
expect(mockConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,189 +1,80 @@
|
||||
/**
|
||||
* Tests for listNonInteractive — non-interactive list output and branch actions.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { listTasks } from '../features/tasks/list/index.js';
|
||||
import { stringify as stringifyYaml } from 'yaml';
|
||||
import { listTasksNonInteractive } from '../features/tasks/list/listNonInteractive.js';
|
||||
|
||||
describe('listTasks non-interactive text output', () => {
|
||||
let tmpDir: string;
|
||||
const mockInfo = vi.fn();
|
||||
vi.mock('../shared/ui/index.js', () => ({
|
||||
info: (...args: unknown[]) => mockInfo(...args),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-ni-'));
|
||||
execFileSync('git', ['init', '--initial-branch', 'main'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
vi.mock('../infra/task/branchList.js', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
detectDefaultBranch: vi.fn(() => 'main'),
|
||||
listTaktBranches: vi.fn(() => []),
|
||||
buildListItems: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-list-non-interactive-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeTasksFile(projectDir: string): void {
|
||||
const tasksFile = path.join(projectDir, '.takt', 'tasks.yaml');
|
||||
fs.mkdirSync(path.dirname(tasksFile), { recursive: true });
|
||||
fs.writeFileSync(tasksFile, stringifyYaml({
|
||||
tasks: [
|
||||
{
|
||||
name: 'pending-task',
|
||||
status: 'pending',
|
||||
content: 'Pending content',
|
||||
created_at: '2026-02-09T00:00:00.000Z',
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
},
|
||||
{
|
||||
name: 'failed-task',
|
||||
status: 'failed',
|
||||
content: 'Failed content',
|
||||
created_at: '2026-02-09T00:00:00.000Z',
|
||||
started_at: '2026-02-09T00:01:00.000Z',
|
||||
completed_at: '2026-02-09T00:02:00.000Z',
|
||||
failure: { error: 'Boom' },
|
||||
},
|
||||
],
|
||||
}), 'utf-8');
|
||||
}
|
||||
|
||||
describe('listTasksNonInteractive', () => {
|
||||
it('should output pending and failed tasks in text format', async () => {
|
||||
writeTasksFile(tmpDir);
|
||||
|
||||
await listTasksNonInteractive(tmpDir, { enabled: true, format: 'text' });
|
||||
|
||||
expect(mockInfo).toHaveBeenCalledWith(expect.stringContaining('[running] pending-task'));
|
||||
expect(mockInfo).toHaveBeenCalledWith(expect.stringContaining('[failed] failed-task'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
it('should output JSON when format=json', async () => {
|
||||
writeTasksFile(tmpDir);
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
|
||||
it('should output pending tasks in text format', async () => {
|
||||
// Given
|
||||
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
|
||||
fs.mkdirSync(tasksDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(tasksDir, 'my-task.md'), 'Fix the login bug');
|
||||
await listTasksNonInteractive(tmpDir, { enabled: true, format: 'json' });
|
||||
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
expect(logSpy).toHaveBeenCalledTimes(1);
|
||||
const payload = JSON.parse(logSpy.mock.calls[0]![0] as string) as { pendingTasks: Array<{ name: string }>; failedTasks: Array<{ name: string }> };
|
||||
expect(payload.pendingTasks[0]?.name).toBe('pending-task');
|
||||
expect(payload.failedTasks[0]?.name).toBe('failed-task');
|
||||
|
||||
// When
|
||||
await listTasks(tmpDir, undefined, { enabled: true });
|
||||
|
||||
// Then
|
||||
const calls = logSpy.mock.calls.map((c) => c[0] as string);
|
||||
expect(calls).toContainEqual(expect.stringContaining('[running] my-task'));
|
||||
expect(calls).not.toContainEqual(expect.stringContaining('[pending] my-task'));
|
||||
expect(calls).not.toContainEqual(expect.stringContaining('[pendig] my-task'));
|
||||
expect(calls).toContainEqual(expect.stringContaining('Fix the login bug'));
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should output failed tasks in text format', async () => {
|
||||
// Given
|
||||
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_failed-task');
|
||||
fs.mkdirSync(failedDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(failedDir, 'failed-task.md'), 'This failed');
|
||||
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
// When
|
||||
await listTasks(tmpDir, undefined, { enabled: true });
|
||||
|
||||
// Then
|
||||
const calls = logSpy.mock.calls.map((c) => c[0] as string);
|
||||
expect(calls).toContainEqual(expect.stringContaining('[failed] failed-task'));
|
||||
expect(calls).toContainEqual(expect.stringContaining('This failed'));
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should output both pending and failed tasks in text format', async () => {
|
||||
// Given
|
||||
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
|
||||
fs.mkdirSync(tasksDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(tasksDir, 'pending-one.md'), 'Pending task');
|
||||
|
||||
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_failed-one');
|
||||
fs.mkdirSync(failedDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(failedDir, 'failed-one.md'), 'Failed task');
|
||||
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
// When
|
||||
await listTasks(tmpDir, undefined, { enabled: true });
|
||||
|
||||
// Then
|
||||
const calls = logSpy.mock.calls.map((c) => c[0] as string);
|
||||
expect(calls).toContainEqual(expect.stringContaining('[running] pending-one'));
|
||||
expect(calls).not.toContainEqual(expect.stringContaining('[pending] pending-one'));
|
||||
expect(calls).not.toContainEqual(expect.stringContaining('[pendig] pending-one'));
|
||||
expect(calls).toContainEqual(expect.stringContaining('[failed] failed-one'));
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should show info message when no tasks exist', async () => {
|
||||
// Given: no tasks, no branches
|
||||
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
// When
|
||||
await listTasks(tmpDir, undefined, { enabled: true });
|
||||
|
||||
// Then
|
||||
const calls = logSpy.mock.calls.map((c) => c[0] as string);
|
||||
expect(calls.some((c) => c.includes('No tasks to list'))).toBe(true);
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listTasks non-interactive action errors', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-ni-err-'));
|
||||
execFileSync('git', ['init', '--initial-branch', 'main'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
// Create a pending task so the "no tasks" early return is not triggered
|
||||
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
|
||||
fs.mkdirSync(tasksDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(tasksDir, 'dummy.md'), 'dummy');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should exit with code 1 when --action specified without --branch', async () => {
|
||||
// Given
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); });
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
// When / Then
|
||||
await expect(
|
||||
listTasks(tmpDir, undefined, { enabled: true, action: 'diff' }),
|
||||
).rejects.toThrow('process.exit');
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
exitSpy.mockRestore();
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should exit with code 1 for invalid action', async () => {
|
||||
// Given
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); });
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
// When / Then
|
||||
await expect(
|
||||
listTasks(tmpDir, undefined, { enabled: true, action: 'invalid', branch: 'some-branch' }),
|
||||
).rejects.toThrow('process.exit');
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
exitSpy.mockRestore();
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should exit with code 1 when branch not found', async () => {
|
||||
// Given
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); });
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
// When / Then
|
||||
await expect(
|
||||
listTasks(tmpDir, undefined, { enabled: true, action: 'diff', branch: 'takt/nonexistent' }),
|
||||
).rejects.toThrow('process.exit');
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
exitSpy.mockRestore();
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should exit with code 1 for delete without --yes', async () => {
|
||||
// Given: create a branch so it's found
|
||||
execFileSync('git', ['checkout', '-b', 'takt/20250115-test-branch'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
execFileSync('git', ['checkout', 'main'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); });
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
// When / Then
|
||||
await expect(
|
||||
listTasks(tmpDir, undefined, {
|
||||
enabled: true,
|
||||
action: 'delete',
|
||||
branch: 'takt/20250115-test-branch',
|
||||
}),
|
||||
).rejects.toThrow('process.exit');
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
exitSpy.mockRestore();
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,391 +1,94 @@
|
||||
/**
|
||||
* Tests for list-tasks command
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
parseTaktBranches,
|
||||
extractTaskSlug,
|
||||
buildListItems,
|
||||
type BranchInfo,
|
||||
} from '../infra/task/branchList.js';
|
||||
import { stringify as stringifyYaml } from 'yaml';
|
||||
|
||||
vi.mock('../shared/ui/index.js', () => ({
|
||||
info: vi.fn(),
|
||||
header: vi.fn(),
|
||||
blankLine: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../infra/task/branchList.js', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
listTaktBranches: vi.fn(() => []),
|
||||
buildListItems: vi.fn(() => []),
|
||||
detectDefaultBranch: vi.fn(() => 'main'),
|
||||
}));
|
||||
|
||||
import { TaskRunner } from '../infra/task/runner.js';
|
||||
import type { TaskListItem } from '../infra/task/types.js';
|
||||
import { isBranchMerged, showFullDiff, type ListAction } from '../features/tasks/index.js';
|
||||
import { listTasks } from '../features/tasks/list/index.js';
|
||||
import { listTasksNonInteractive } from '../features/tasks/list/listNonInteractive.js';
|
||||
|
||||
describe('parseTaktBranches', () => {
|
||||
it('should parse takt/ branches from git branch output', () => {
|
||||
const output = [
|
||||
'takt/20260128-fix-auth def4567',
|
||||
'takt/20260128-add-search 789abcd',
|
||||
].join('\n');
|
||||
let tmpDir: string;
|
||||
|
||||
const result = parseTaktBranches(output);
|
||||
expect(result).toHaveLength(2);
|
||||
|
||||
expect(result[0]).toEqual({
|
||||
branch: 'takt/20260128-fix-auth',
|
||||
commit: 'def4567',
|
||||
});
|
||||
|
||||
expect(result[1]).toEqual({
|
||||
branch: 'takt/20260128-add-search',
|
||||
commit: '789abcd',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty output', () => {
|
||||
const result = parseTaktBranches('');
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle output with only whitespace lines', () => {
|
||||
const result = parseTaktBranches(' \n \n');
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle single branch', () => {
|
||||
const output = 'takt/20260128-fix-auth abc1234';
|
||||
|
||||
const result = parseTaktBranches(output);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
branch: 'takt/20260128-fix-auth',
|
||||
commit: 'abc1234',
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip lines without space separator', () => {
|
||||
const output = [
|
||||
'takt/20260128-fix-auth abc1234',
|
||||
'malformed-line',
|
||||
].join('\n');
|
||||
|
||||
const result = parseTaktBranches(output);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-list-test-'));
|
||||
});
|
||||
|
||||
describe('extractTaskSlug', () => {
|
||||
it('should extract slug from timestamped branch name', () => {
|
||||
expect(extractTaskSlug('takt/20260128T032800-fix-auth')).toBe('fix-auth');
|
||||
});
|
||||
|
||||
it('should extract slug from date-only timestamp', () => {
|
||||
expect(extractTaskSlug('takt/20260128-add-search')).toBe('add-search');
|
||||
});
|
||||
|
||||
it('should extract slug with long timestamp format', () => {
|
||||
expect(extractTaskSlug('takt/20260128T032800-refactor-api')).toBe('refactor-api');
|
||||
});
|
||||
|
||||
it('should handle branch without timestamp', () => {
|
||||
expect(extractTaskSlug('takt/my-task')).toBe('my-task');
|
||||
});
|
||||
|
||||
it('should handle branch with only timestamp', () => {
|
||||
const result = extractTaskSlug('takt/20260128T032800');
|
||||
// Timestamp is stripped, nothing left, falls back to original name
|
||||
expect(result).toBe('20260128T032800');
|
||||
});
|
||||
|
||||
it('should handle slug with multiple dashes', () => {
|
||||
expect(extractTaskSlug('takt/20260128-fix-auth-bug-in-login')).toBe('fix-auth-bug-in-login');
|
||||
});
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('buildListItems', () => {
|
||||
it('should build items with correct task slug and originalInstruction', () => {
|
||||
const branches: BranchInfo[] = [
|
||||
function writeTasksFile(projectDir: string): void {
|
||||
const tasksFile = path.join(projectDir, '.takt', 'tasks.yaml');
|
||||
fs.mkdirSync(path.dirname(tasksFile), { recursive: true });
|
||||
fs.writeFileSync(tasksFile, stringifyYaml({
|
||||
tasks: [
|
||||
{
|
||||
branch: 'takt/20260128-fix-auth',
|
||||
commit: 'abc123',
|
||||
},
|
||||
];
|
||||
|
||||
const items = buildListItems('/project', branches, 'main');
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]!.taskSlug).toBe('fix-auth');
|
||||
expect(items[0]!.info).toBe(branches[0]);
|
||||
// filesChanged will be 0 since we don't have a real git repo
|
||||
expect(items[0]!.filesChanged).toBe(0);
|
||||
// originalInstruction will be empty since git command fails on non-existent repo
|
||||
expect(items[0]!.originalInstruction).toBe('');
|
||||
});
|
||||
|
||||
it('should handle multiple branches', () => {
|
||||
const branches: BranchInfo[] = [
|
||||
{
|
||||
branch: 'takt/20260128-fix-auth',
|
||||
commit: 'abc123',
|
||||
name: 'pending-one',
|
||||
status: 'pending',
|
||||
content: 'Pending task',
|
||||
created_at: '2026-02-09T00:00:00.000Z',
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
},
|
||||
{
|
||||
branch: 'takt/20260128-add-search',
|
||||
commit: 'def456',
|
||||
name: 'failed-one',
|
||||
status: 'failed',
|
||||
content: 'Failed task',
|
||||
created_at: '2026-02-09T00:00:00.000Z',
|
||||
started_at: '2026-02-09T00:01:00.000Z',
|
||||
completed_at: '2026-02-09T00:02:00.000Z',
|
||||
failure: { error: 'boom' },
|
||||
},
|
||||
];
|
||||
],
|
||||
}), 'utf-8');
|
||||
}
|
||||
|
||||
const items = buildListItems('/project', branches, 'main');
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]!.taskSlug).toBe('fix-auth');
|
||||
expect(items[1]!.taskSlug).toBe('add-search');
|
||||
});
|
||||
|
||||
it('should handle empty branch list', () => {
|
||||
const items = buildListItems('/project', [], 'main');
|
||||
expect(items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ListAction type', () => {
|
||||
it('should include diff, instruct, try, merge, delete (no skip)', () => {
|
||||
const actions: ListAction[] = ['diff', 'instruct', 'try', 'merge', 'delete'];
|
||||
expect(actions).toHaveLength(5);
|
||||
expect(actions).toContain('diff');
|
||||
expect(actions).toContain('instruct');
|
||||
expect(actions).toContain('try');
|
||||
expect(actions).toContain('merge');
|
||||
expect(actions).toContain('delete');
|
||||
expect(actions).not.toContain('skip');
|
||||
});
|
||||
});
|
||||
|
||||
describe('showFullDiff', () => {
|
||||
it('should not throw for non-existent project dir', () => {
|
||||
// spawnSync will fail gracefully; showFullDiff catches errors
|
||||
expect(() => showFullDiff('/non-existent-dir', 'main', 'some-branch')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw for non-existent branch', () => {
|
||||
expect(() => showFullDiff('/tmp', 'main', 'non-existent-branch-xyz')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should warn when diff fails', () => {
|
||||
const warnSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
showFullDiff('/non-existent-dir', 'main', 'some-branch');
|
||||
warnSpy.mockRestore();
|
||||
// No assertion needed — the test verifies it doesn't throw
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBranchMerged', () => {
|
||||
it('should return false for non-existent project dir', () => {
|
||||
// git merge-base will fail on non-existent dir
|
||||
const result = isBranchMerged('/non-existent-dir', 'some-branch');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-existent branch', () => {
|
||||
const result = isBranchMerged('/tmp', 'non-existent-branch-xyz');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TaskRunner.listFailedTasks', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should return empty array for empty failed directory', () => {
|
||||
describe('TaskRunner list APIs', () => {
|
||||
it('should read pending and failed tasks from tasks.yaml', () => {
|
||||
writeTasksFile(tmpDir);
|
||||
const runner = new TaskRunner(tmpDir);
|
||||
const result = runner.listFailedTasks();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should parse failed task directories correctly', () => {
|
||||
const failedDir = path.join(tmpDir, '.takt', 'failed');
|
||||
const taskDir = path.join(failedDir, '2025-01-15T12-34-56_my-task');
|
||||
fs.mkdirSync(taskDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(taskDir, 'my-task.md'), 'Fix the login bug\nMore details here');
|
||||
const pending = runner.listPendingTaskItems();
|
||||
const failed = runner.listFailedTasks();
|
||||
|
||||
const runner = new TaskRunner(tmpDir);
|
||||
const result = runner.listFailedTasks();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
kind: 'failed',
|
||||
name: 'my-task',
|
||||
createdAt: '2025-01-15T12:34:56',
|
||||
filePath: taskDir,
|
||||
content: 'Fix the login bug',
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip malformed directory names', () => {
|
||||
const failedDir = path.join(tmpDir, '.takt', 'failed');
|
||||
// No underscore → malformed, should be skipped
|
||||
fs.mkdirSync(path.join(failedDir, 'malformed-name'), { recursive: true });
|
||||
// Valid one
|
||||
const validDir = path.join(failedDir, '2025-01-15T12-34-56_valid-task');
|
||||
fs.mkdirSync(validDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(validDir, 'valid-task.md'), 'Content');
|
||||
|
||||
const runner = new TaskRunner(tmpDir);
|
||||
const result = runner.listFailedTasks();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.name).toBe('valid-task');
|
||||
});
|
||||
|
||||
it('should extract task content from task file in directory', () => {
|
||||
const failedDir = path.join(tmpDir, '.takt', 'failed');
|
||||
const taskDir = path.join(failedDir, '2025-02-01T00-00-00_content-test');
|
||||
fs.mkdirSync(taskDir, { recursive: true });
|
||||
// report.md and log.json should be skipped; the actual task file should be read
|
||||
fs.writeFileSync(path.join(taskDir, 'report.md'), 'Report content');
|
||||
fs.writeFileSync(path.join(taskDir, 'log.json'), '{}');
|
||||
fs.writeFileSync(path.join(taskDir, 'content-test.yaml'), 'task: Do something important');
|
||||
|
||||
const runner = new TaskRunner(tmpDir);
|
||||
const result = runner.listFailedTasks();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.content).toBe('task: Do something important');
|
||||
});
|
||||
|
||||
it('should return empty content when no task file exists', () => {
|
||||
const failedDir = path.join(tmpDir, '.takt', 'failed');
|
||||
const taskDir = path.join(failedDir, '2025-02-01T00-00-00_no-task-file');
|
||||
fs.mkdirSync(taskDir, { recursive: true });
|
||||
// Only report.md and log.json, no actual task file
|
||||
fs.writeFileSync(path.join(taskDir, 'report.md'), 'Report content');
|
||||
fs.writeFileSync(path.join(taskDir, 'log.json'), '{}');
|
||||
|
||||
const runner = new TaskRunner(tmpDir);
|
||||
const result = runner.listFailedTasks();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.content).toBe('');
|
||||
});
|
||||
|
||||
it('should handle task name with underscores', () => {
|
||||
const failedDir = path.join(tmpDir, '.takt', 'failed');
|
||||
const taskDir = path.join(failedDir, '2025-01-15T12-34-56_my_task_name');
|
||||
fs.mkdirSync(taskDir, { recursive: true });
|
||||
|
||||
const runner = new TaskRunner(tmpDir);
|
||||
const result = runner.listFailedTasks();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.name).toBe('my_task_name');
|
||||
});
|
||||
|
||||
it('should skip non-directory entries', () => {
|
||||
const failedDir = path.join(tmpDir, '.takt', 'failed');
|
||||
fs.mkdirSync(failedDir, { recursive: true });
|
||||
// Create a file (not a directory) in the failed dir
|
||||
fs.writeFileSync(path.join(failedDir, '2025-01-15T12-34-56_file-task'), 'content');
|
||||
|
||||
const runner = new TaskRunner(tmpDir);
|
||||
const result = runner.listFailedTasks();
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TaskRunner.listPendingTaskItems', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should return empty array when no pending tasks', () => {
|
||||
const runner = new TaskRunner(tmpDir);
|
||||
const result = runner.listPendingTaskItems();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should convert TaskInfo to TaskListItem with kind=pending', () => {
|
||||
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
|
||||
fs.mkdirSync(tasksDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(tasksDir, 'my-task.md'), 'Fix the login bug\nMore details here');
|
||||
|
||||
const runner = new TaskRunner(tmpDir);
|
||||
const result = runner.listPendingTaskItems();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.kind).toBe('pending');
|
||||
expect(result[0]!.name).toBe('my-task');
|
||||
expect(result[0]!.content).toBe('Fix the login bug');
|
||||
});
|
||||
|
||||
it('should truncate content to first line (max 80 chars)', () => {
|
||||
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
|
||||
fs.mkdirSync(tasksDir, { recursive: true });
|
||||
const longLine = 'A'.repeat(120) + '\nSecond line';
|
||||
fs.writeFileSync(path.join(tasksDir, 'long-task.md'), longLine);
|
||||
|
||||
const runner = new TaskRunner(tmpDir);
|
||||
const result = runner.listPendingTaskItems();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.content).toBe('A'.repeat(80));
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0]?.name).toBe('pending-one');
|
||||
expect(failed).toHaveLength(1);
|
||||
expect(failed[0]?.name).toBe('failed-one');
|
||||
expect(failed[0]?.failure?.error).toBe('boom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listTasks non-interactive JSON output', () => {
|
||||
let tmpDir: string;
|
||||
it('should output JSON object with branches, pendingTasks, and failedTasks', async () => {
|
||||
writeTasksFile(tmpDir);
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-json-'));
|
||||
// Initialize as a git repo so detectDefaultBranch works
|
||||
execFileSync('git', ['init', '--initial-branch', 'main'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
});
|
||||
await listTasksNonInteractive(tmpDir, { enabled: true, format: 'json' });
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should output JSON as object with branches, pendingTasks, and failedTasks keys', async () => {
|
||||
// Given: a pending task and a failed task
|
||||
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
|
||||
fs.mkdirSync(tasksDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(tasksDir, 'my-task.md'), 'Do something');
|
||||
|
||||
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_failed-task');
|
||||
fs.mkdirSync(failedDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(failedDir, 'failed-task.md'), 'This failed');
|
||||
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
// When: listTasks is called in non-interactive JSON mode
|
||||
await listTasks(tmpDir, undefined, {
|
||||
enabled: true,
|
||||
format: 'json',
|
||||
});
|
||||
|
||||
// Then: output is an object with branches, pendingTasks, failedTasks
|
||||
expect(logSpy).toHaveBeenCalledTimes(1);
|
||||
const output = JSON.parse(logSpy.mock.calls[0]![0] as string);
|
||||
expect(output).toHaveProperty('branches');
|
||||
expect(output).toHaveProperty('pendingTasks');
|
||||
expect(output).toHaveProperty('failedTasks');
|
||||
expect(Array.isArray(output.branches)).toBe(true);
|
||||
expect(Array.isArray(output.pendingTasks)).toBe(true);
|
||||
expect(Array.isArray(output.failedTasks)).toBe(true);
|
||||
expect(output.pendingTasks).toHaveLength(1);
|
||||
expect(output.pendingTasks[0].name).toBe('my-task');
|
||||
expect(output.failedTasks).toHaveLength(1);
|
||||
expect(output.failedTasks[0].name).toBe('failed-task');
|
||||
const payload = JSON.parse(logSpy.mock.calls[0]![0] as string) as {
|
||||
branches: unknown[];
|
||||
pendingTasks: Array<{ name: string }>;
|
||||
failedTasks: Array<{ name: string }>;
|
||||
};
|
||||
expect(Array.isArray(payload.branches)).toBe(true);
|
||||
expect(payload.pendingTasks[0]?.name).toBe('pending-one');
|
||||
expect(payload.failedTasks[0]?.name).toBe('failed-one');
|
||||
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
@ -21,18 +21,18 @@ vi.mock('../infra/config/index.js', () => ({
|
||||
import { loadGlobalConfig } from '../infra/config/index.js';
|
||||
const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig);
|
||||
|
||||
const mockGetNextTask = vi.fn();
|
||||
const mockClaimNextTasks = vi.fn();
|
||||
const mockCompleteTask = vi.fn();
|
||||
const mockFailTask = vi.fn();
|
||||
const mockRecoverInterruptedRunningTasks = vi.fn();
|
||||
|
||||
vi.mock('../infra/task/index.js', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
TaskRunner: vi.fn().mockImplementation(() => ({
|
||||
getNextTask: mockGetNextTask,
|
||||
claimNextTasks: mockClaimNextTasks,
|
||||
completeTask: mockCompleteTask,
|
||||
failTask: mockFailTask,
|
||||
recoverInterruptedRunningTasks: mockRecoverInterruptedRunningTasks,
|
||||
})),
|
||||
}));
|
||||
|
||||
@ -128,11 +128,15 @@ function createTask(name: string): TaskInfo {
|
||||
name,
|
||||
content: `Task: ${name}`,
|
||||
filePath: `/tasks/${name}.yaml`,
|
||||
createdAt: '2026-02-09T00:00:00.000Z',
|
||||
status: 'pending',
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockRecoverInterruptedRunningTasks.mockReturnValue(0);
|
||||
});
|
||||
|
||||
describe('runAllTasks concurrency', () => {
|
||||
@ -155,7 +159,7 @@ describe('runAllTasks concurrency', () => {
|
||||
await runAllTasks('/project');
|
||||
|
||||
// Then
|
||||
expect(mockInfo).toHaveBeenCalledWith('No pending tasks in .takt/tasks/');
|
||||
expect(mockInfo).toHaveBeenCalledWith('No pending tasks in .takt/tasks.yaml');
|
||||
});
|
||||
|
||||
it('should execute tasks sequentially via worker pool when concurrency is 1', async () => {
|
||||
@ -401,6 +405,28 @@ describe('runAllTasks concurrency', () => {
|
||||
expect(mockStatus).toHaveBeenCalledWith('Failed', '1', 'red');
|
||||
});
|
||||
|
||||
it('should persist failure reason and movement when piece aborts', async () => {
|
||||
const task1 = createTask('fail-with-detail');
|
||||
|
||||
mockExecutePiece.mockResolvedValue({
|
||||
success: false,
|
||||
reason: 'blocked_by_review',
|
||||
lastMovement: 'review',
|
||||
lastMessage: 'security check failed',
|
||||
});
|
||||
mockClaimNextTasks
|
||||
.mockReturnValueOnce([task1])
|
||||
.mockReturnValueOnce([]);
|
||||
|
||||
await runAllTasks('/project');
|
||||
|
||||
expect(mockFailTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||
response: 'blocked_by_review',
|
||||
failureMovement: 'review',
|
||||
failureLastMessage: 'security check failed',
|
||||
}));
|
||||
});
|
||||
|
||||
it('should pass abortSignal and taskPrefix to executePiece in parallel mode', async () => {
|
||||
// Given: One task in parallel mode
|
||||
const task1 = createTask('parallel-task');
|
||||
|
||||
@ -1,15 +1,8 @@
|
||||
/**
|
||||
* Tests for saveTaskFile and saveTaskFromInteractive
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
vi.mock('../infra/task/summarize.js', () => ({
|
||||
summarizeTaskName: vi.fn(),
|
||||
}));
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
|
||||
vi.mock('../shared/ui/index.js', () => ({
|
||||
success: vi.fn(),
|
||||
@ -31,12 +24,10 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
import { summarizeTaskName } from '../infra/task/summarize.js';
|
||||
import { success, info } from '../shared/ui/index.js';
|
||||
import { confirm, promptInput } from '../shared/prompt/index.js';
|
||||
import { saveTaskFile, saveTaskFromInteractive } from '../features/tasks/add/index.js';
|
||||
|
||||
const mockSummarizeTaskName = vi.mocked(summarizeTaskName);
|
||||
const mockSuccess = vi.mocked(success);
|
||||
const mockInfo = vi.mocked(info);
|
||||
const mockConfirm = vi.mocked(confirm);
|
||||
@ -44,10 +35,14 @@ const mockPromptInput = vi.mocked(promptInput);
|
||||
|
||||
let testDir: string;
|
||||
|
||||
function loadTasks(testDir: string): { tasks: Array<Record<string, unknown>> } {
|
||||
const raw = fs.readFileSync(path.join(testDir, '.takt', 'tasks.yaml'), 'utf-8');
|
||||
return parseYaml(raw) as { tasks: Array<Record<string, unknown>> };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testDir = fs.mkdtempSync(path.join(tmpdir(), 'takt-test-save-'));
|
||||
mockSummarizeTaskName.mockResolvedValue('test-task');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@ -57,243 +52,74 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('saveTaskFile', () => {
|
||||
it('should create task file with correct YAML content', async () => {
|
||||
// Given
|
||||
const taskContent = 'Implement feature X\nDetails here';
|
||||
it('should append task to tasks.yaml', async () => {
|
||||
const created = await saveTaskFile(testDir, 'Implement feature X\nDetails here');
|
||||
|
||||
// When
|
||||
const filePath = await saveTaskFile(testDir, taskContent);
|
||||
expect(created.taskName).toContain('implement-feature-x');
|
||||
expect(created.tasksFile).toBe(path.join(testDir, '.takt', 'tasks.yaml'));
|
||||
expect(fs.existsSync(created.tasksFile)).toBe(true);
|
||||
|
||||
// Then
|
||||
expect(fs.existsSync(filePath)).toBe(true);
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
expect(content).toContain('Implement feature X');
|
||||
expect(content).toContain('Details here');
|
||||
const tasks = loadTasks(testDir).tasks;
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0]?.content).toContain('Implement feature X');
|
||||
});
|
||||
|
||||
it('should create .takt/tasks directory if it does not exist', async () => {
|
||||
// Given
|
||||
const tasksDir = path.join(testDir, '.takt', 'tasks');
|
||||
expect(fs.existsSync(tasksDir)).toBe(false);
|
||||
it('should include optional fields', async () => {
|
||||
await saveTaskFile(testDir, 'Task', {
|
||||
piece: 'review',
|
||||
issue: 42,
|
||||
worktree: true,
|
||||
branch: 'feat/my-branch',
|
||||
autoPr: false,
|
||||
});
|
||||
|
||||
// When
|
||||
await saveTaskFile(testDir, 'Task content');
|
||||
|
||||
// Then
|
||||
expect(fs.existsSync(tasksDir)).toBe(true);
|
||||
const task = loadTasks(testDir).tasks[0]!;
|
||||
expect(task.piece).toBe('review');
|
||||
expect(task.issue).toBe(42);
|
||||
expect(task.worktree).toBe(true);
|
||||
expect(task.branch).toBe('feat/my-branch');
|
||||
expect(task.auto_pr).toBe(false);
|
||||
});
|
||||
|
||||
it('should include piece in YAML when specified', async () => {
|
||||
// When
|
||||
const filePath = await saveTaskFile(testDir, 'Task', { piece: 'review' });
|
||||
it('should generate unique names on duplicates', async () => {
|
||||
const first = await saveTaskFile(testDir, 'Same title');
|
||||
const second = await saveTaskFile(testDir, 'Same title');
|
||||
|
||||
// Then
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
expect(content).toContain('piece: review');
|
||||
});
|
||||
|
||||
it('should include issue number in YAML when specified', async () => {
|
||||
// When
|
||||
const filePath = await saveTaskFile(testDir, 'Task', { issue: 42 });
|
||||
|
||||
// Then
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
expect(content).toContain('issue: 42');
|
||||
});
|
||||
|
||||
it('should include worktree in YAML when specified', async () => {
|
||||
// When
|
||||
const filePath = await saveTaskFile(testDir, 'Task', { worktree: true });
|
||||
|
||||
// Then
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
expect(content).toContain('worktree: true');
|
||||
});
|
||||
|
||||
it('should include branch in YAML when specified', async () => {
|
||||
// When
|
||||
const filePath = await saveTaskFile(testDir, 'Task', { branch: 'feat/my-branch' });
|
||||
|
||||
// Then
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
expect(content).toContain('branch: feat/my-branch');
|
||||
});
|
||||
|
||||
it('should not include optional fields when not specified', async () => {
|
||||
// When
|
||||
const filePath = await saveTaskFile(testDir, 'Simple task');
|
||||
|
||||
// Then
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
expect(content).not.toContain('piece:');
|
||||
expect(content).not.toContain('issue:');
|
||||
expect(content).not.toContain('worktree:');
|
||||
expect(content).not.toContain('branch:');
|
||||
expect(content).not.toContain('auto_pr:');
|
||||
});
|
||||
|
||||
it('should include auto_pr in YAML when specified', async () => {
|
||||
// When
|
||||
const filePath = await saveTaskFile(testDir, 'Task', { autoPr: true });
|
||||
|
||||
// Then
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
expect(content).toContain('auto_pr: true');
|
||||
});
|
||||
|
||||
it('should include auto_pr: false in YAML when specified as false', async () => {
|
||||
// When
|
||||
const filePath = await saveTaskFile(testDir, 'Task', { autoPr: false });
|
||||
|
||||
// Then
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
expect(content).toContain('auto_pr: false');
|
||||
});
|
||||
|
||||
it('should use first line for filename generation', async () => {
|
||||
// When
|
||||
await saveTaskFile(testDir, 'First line\nSecond line');
|
||||
|
||||
// Then
|
||||
expect(mockSummarizeTaskName).toHaveBeenCalledWith('First line', { cwd: testDir });
|
||||
});
|
||||
|
||||
it('should handle duplicate filenames with counter', async () => {
|
||||
// Given: first file already exists
|
||||
await saveTaskFile(testDir, 'Task 1');
|
||||
|
||||
// When: second file with same slug
|
||||
const filePath = await saveTaskFile(testDir, 'Task 2');
|
||||
|
||||
// Then
|
||||
expect(path.basename(filePath)).toBe('test-task-1.yaml');
|
||||
expect(first.taskName).not.toBe(second.taskName);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveTaskFromInteractive', () => {
|
||||
it('should save task with worktree settings when user confirms worktree', async () => {
|
||||
// Given: user confirms worktree, accepts defaults, confirms auto-PR
|
||||
mockConfirm.mockResolvedValueOnce(true); // Create worktree? → Yes
|
||||
mockPromptInput.mockResolvedValueOnce(''); // Worktree path → auto
|
||||
mockPromptInput.mockResolvedValueOnce(''); // Branch name → auto
|
||||
mockConfirm.mockResolvedValueOnce(true); // Auto-create PR? → Yes
|
||||
it('should save task with worktree settings when user confirms', async () => {
|
||||
mockConfirm.mockResolvedValueOnce(true);
|
||||
mockPromptInput.mockResolvedValueOnce('');
|
||||
mockPromptInput.mockResolvedValueOnce('');
|
||||
mockConfirm.mockResolvedValueOnce(true);
|
||||
|
||||
// When
|
||||
await saveTaskFromInteractive(testDir, 'Task content');
|
||||
|
||||
// Then
|
||||
expect(mockSuccess).toHaveBeenCalledWith('Task created: test-task.yaml');
|
||||
expect(mockInfo).toHaveBeenCalledWith(expect.stringContaining('Path:'));
|
||||
const tasksDir = path.join(testDir, '.takt', 'tasks');
|
||||
const files = fs.readdirSync(tasksDir);
|
||||
const content = fs.readFileSync(path.join(tasksDir, files[0]!), 'utf-8');
|
||||
expect(content).toContain('worktree: true');
|
||||
expect(content).toContain('auto_pr: true');
|
||||
expect(mockSuccess).toHaveBeenCalledWith(expect.stringContaining('Task created:'));
|
||||
const task = loadTasks(testDir).tasks[0]!;
|
||||
expect(task.worktree).toBe(true);
|
||||
expect(task.auto_pr).toBe(true);
|
||||
});
|
||||
|
||||
it('should save task without worktree settings when user declines worktree', async () => {
|
||||
// Given: user declines worktree
|
||||
mockConfirm.mockResolvedValueOnce(false); // Create worktree? → No
|
||||
it('should save task without worktree settings when declined', async () => {
|
||||
mockConfirm.mockResolvedValueOnce(false);
|
||||
|
||||
// When
|
||||
await saveTaskFromInteractive(testDir, 'Task content');
|
||||
|
||||
// Then
|
||||
expect(mockSuccess).toHaveBeenCalledWith('Task created: test-task.yaml');
|
||||
const tasksDir = path.join(testDir, '.takt', 'tasks');
|
||||
const files = fs.readdirSync(tasksDir);
|
||||
const content = fs.readFileSync(path.join(tasksDir, files[0]!), 'utf-8');
|
||||
expect(content).not.toContain('worktree:');
|
||||
expect(content).not.toContain('branch:');
|
||||
expect(content).not.toContain('auto_pr:');
|
||||
});
|
||||
|
||||
it('should save custom worktree path and branch when specified', async () => {
|
||||
// Given: user specifies custom path and branch
|
||||
mockConfirm.mockResolvedValueOnce(true); // Create worktree? → Yes
|
||||
mockPromptInput.mockResolvedValueOnce('/custom/path'); // Worktree path
|
||||
mockPromptInput.mockResolvedValueOnce('feat/branch'); // Branch name
|
||||
mockConfirm.mockResolvedValueOnce(false); // Auto-create PR? → No
|
||||
|
||||
// When
|
||||
await saveTaskFromInteractive(testDir, 'Task content');
|
||||
|
||||
// Then
|
||||
const tasksDir = path.join(testDir, '.takt', 'tasks');
|
||||
const files = fs.readdirSync(tasksDir);
|
||||
const content = fs.readFileSync(path.join(tasksDir, files[0]!), 'utf-8');
|
||||
expect(content).toContain('worktree: /custom/path');
|
||||
expect(content).toContain('branch: feat/branch');
|
||||
expect(content).toContain('auto_pr: false');
|
||||
});
|
||||
|
||||
it('should display worktree/branch/auto-PR info when settings are provided', async () => {
|
||||
// Given
|
||||
mockConfirm.mockResolvedValueOnce(true); // Create worktree? → Yes
|
||||
mockPromptInput.mockResolvedValueOnce('/my/path'); // Worktree path
|
||||
mockPromptInput.mockResolvedValueOnce('my-branch'); // Branch name
|
||||
mockConfirm.mockResolvedValueOnce(true); // Auto-create PR? → Yes
|
||||
|
||||
// When
|
||||
await saveTaskFromInteractive(testDir, 'Task content');
|
||||
|
||||
// Then
|
||||
expect(mockInfo).toHaveBeenCalledWith(' Worktree: /my/path');
|
||||
expect(mockInfo).toHaveBeenCalledWith(' Branch: my-branch');
|
||||
expect(mockInfo).toHaveBeenCalledWith(' Auto-PR: yes');
|
||||
const task = loadTasks(testDir).tasks[0]!;
|
||||
expect(task.worktree).toBeUndefined();
|
||||
expect(task.branch).toBeUndefined();
|
||||
expect(task.auto_pr).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should display piece info when specified', async () => {
|
||||
// Given
|
||||
mockConfirm.mockResolvedValueOnce(false); // Create worktree? → No
|
||||
mockConfirm.mockResolvedValueOnce(false);
|
||||
|
||||
// When
|
||||
await saveTaskFromInteractive(testDir, 'Task content', 'review');
|
||||
|
||||
// Then
|
||||
expect(mockInfo).toHaveBeenCalledWith(' Piece: review');
|
||||
});
|
||||
|
||||
it('should include piece in saved YAML', async () => {
|
||||
// Given
|
||||
mockConfirm.mockResolvedValueOnce(false); // Create worktree? → No
|
||||
|
||||
// When
|
||||
await saveTaskFromInteractive(testDir, 'Task content', 'custom');
|
||||
|
||||
// Then
|
||||
const tasksDir = path.join(testDir, '.takt', 'tasks');
|
||||
const files = fs.readdirSync(tasksDir);
|
||||
expect(files.length).toBe(1);
|
||||
const content = fs.readFileSync(path.join(tasksDir, files[0]!), 'utf-8');
|
||||
expect(content).toContain('piece: custom');
|
||||
});
|
||||
|
||||
it('should not display piece info when not specified', async () => {
|
||||
// Given
|
||||
mockConfirm.mockResolvedValueOnce(false); // Create worktree? → No
|
||||
|
||||
// When
|
||||
await saveTaskFromInteractive(testDir, 'Task content');
|
||||
|
||||
// Then
|
||||
const pieceInfoCalls = mockInfo.mock.calls.filter(
|
||||
(call) => typeof call[0] === 'string' && call[0].includes('Piece:')
|
||||
);
|
||||
expect(pieceInfoCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should display auto worktree info when no custom path', async () => {
|
||||
// Given
|
||||
mockConfirm.mockResolvedValueOnce(true); // Create worktree? → Yes
|
||||
mockPromptInput.mockResolvedValueOnce(''); // Worktree path → auto
|
||||
mockPromptInput.mockResolvedValueOnce(''); // Branch name → auto
|
||||
mockConfirm.mockResolvedValueOnce(true); // Auto-create PR? → Yes
|
||||
|
||||
// When
|
||||
await saveTaskFromInteractive(testDir, 'Task content');
|
||||
|
||||
// Then
|
||||
expect(mockInfo).toHaveBeenCalledWith(' Worktree: auto');
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,59 +1,34 @@
|
||||
/**
|
||||
* Task runner tests
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdirSync, writeFileSync, existsSync, rmSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { mkdirSync, writeFileSync, existsSync, rmSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
||||
import { TaskRunner } from '../infra/task/runner.js';
|
||||
import { isTaskFile, parseTaskFiles } from '../infra/task/parser.js';
|
||||
import { TaskRecordSchema } from '../infra/task/schema.js';
|
||||
|
||||
describe('isTaskFile', () => {
|
||||
it('should accept .yaml files', () => {
|
||||
expect(isTaskFile('task.yaml')).toBe(true);
|
||||
});
|
||||
function loadTasksFile(testDir: string): { tasks: Array<Record<string, unknown>> } {
|
||||
const raw = readFileSync(join(testDir, '.takt', 'tasks.yaml'), 'utf-8');
|
||||
return parseYaml(raw) as { tasks: Array<Record<string, unknown>> };
|
||||
}
|
||||
|
||||
it('should accept .yml files', () => {
|
||||
expect(isTaskFile('task.yml')).toBe(true);
|
||||
});
|
||||
function writeTasksFile(testDir: string, tasks: Array<Record<string, unknown>>): void {
|
||||
mkdirSync(join(testDir, '.takt'), { recursive: true });
|
||||
writeFileSync(join(testDir, '.takt', 'tasks.yaml'), stringifyYaml({ tasks }), 'utf-8');
|
||||
}
|
||||
|
||||
it('should accept .md files', () => {
|
||||
expect(isTaskFile('task.md')).toBe(true);
|
||||
});
|
||||
function createPendingRecord(overrides: Record<string, unknown>): Record<string, unknown> {
|
||||
return TaskRecordSchema.parse({
|
||||
name: 'task-a',
|
||||
status: 'pending',
|
||||
content: 'Do work',
|
||||
created_at: '2026-02-09T00:00:00.000Z',
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
owner_pid: null,
|
||||
...overrides,
|
||||
}) as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
it('should reject extensionless files like TASK-FORMAT', () => {
|
||||
expect(isTaskFile('TASK-FORMAT')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject .txt files', () => {
|
||||
expect(isTaskFile('readme.txt')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTaskFiles', () => {
|
||||
const testDir = `/tmp/takt-parse-test-${Date.now()}`;
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(testDir)) {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should ignore extensionless files like TASK-FORMAT', () => {
|
||||
writeFileSync(join(testDir, 'TASK-FORMAT'), 'Format documentation');
|
||||
writeFileSync(join(testDir, 'real-task.md'), 'Real task');
|
||||
|
||||
const tasks = parseTaskFiles(testDir);
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0]?.name).toBe('real-task');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TaskRunner', () => {
|
||||
describe('TaskRunner (tasks.yaml)', () => {
|
||||
const testDir = `/tmp/takt-task-test-${Date.now()}`;
|
||||
let runner: TaskRunner;
|
||||
|
||||
@ -68,465 +43,245 @@ describe('TaskRunner', () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('ensureDirs', () => {
|
||||
it('should create tasks, completed, and failed directories', () => {
|
||||
runner.ensureDirs();
|
||||
expect(existsSync(join(testDir, '.takt', 'tasks'))).toBe(true);
|
||||
expect(existsSync(join(testDir, '.takt', 'completed'))).toBe(true);
|
||||
expect(existsSync(join(testDir, '.takt', 'failed'))).toBe(true);
|
||||
});
|
||||
it('should add tasks to .takt/tasks.yaml', () => {
|
||||
const task = runner.addTask('Fix login flow', { piece: 'default' });
|
||||
expect(task.name).toContain('fix-login-flow');
|
||||
expect(existsSync(join(testDir, '.takt', 'tasks.yaml'))).toBe(true);
|
||||
});
|
||||
|
||||
describe('listTasks', () => {
|
||||
it('should return empty array when no tasks', () => {
|
||||
const tasks = runner.listTasks();
|
||||
expect(tasks).toEqual([]);
|
||||
});
|
||||
it('should list only pending tasks', () => {
|
||||
runner.addTask('Task A');
|
||||
runner.addTask('Task B');
|
||||
|
||||
it('should list tasks sorted by name', () => {
|
||||
const tasksDir = join(testDir, '.takt', 'tasks');
|
||||
mkdirSync(tasksDir, { recursive: true });
|
||||
writeFileSync(join(tasksDir, '02-second.md'), 'Second task');
|
||||
writeFileSync(join(tasksDir, '01-first.md'), 'First task');
|
||||
writeFileSync(join(tasksDir, '03-third.md'), 'Third task');
|
||||
|
||||
const tasks = runner.listTasks();
|
||||
expect(tasks).toHaveLength(3);
|
||||
expect(tasks[0]?.name).toBe('01-first');
|
||||
expect(tasks[1]?.name).toBe('02-second');
|
||||
expect(tasks[2]?.name).toBe('03-third');
|
||||
});
|
||||
|
||||
it('should only list .md files', () => {
|
||||
const tasksDir = join(testDir, '.takt', 'tasks');
|
||||
mkdirSync(tasksDir, { recursive: true });
|
||||
writeFileSync(join(tasksDir, 'task.md'), 'Task content');
|
||||
writeFileSync(join(tasksDir, 'readme.txt'), 'Not a task');
|
||||
|
||||
const tasks = runner.listTasks();
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0]?.name).toBe('task');
|
||||
});
|
||||
const tasks = runner.listTasks();
|
||||
expect(tasks).toHaveLength(2);
|
||||
expect(tasks.every((task) => task.status === 'pending')).toBe(true);
|
||||
});
|
||||
|
||||
describe('getTask', () => {
|
||||
it('should return null for non-existent task', () => {
|
||||
const task = runner.getTask('non-existent');
|
||||
expect(task).toBeNull();
|
||||
});
|
||||
it('should claim tasks and mark them running', () => {
|
||||
runner.addTask('Task A');
|
||||
runner.addTask('Task B');
|
||||
|
||||
it('should return task info for existing task', () => {
|
||||
const tasksDir = join(testDir, '.takt', 'tasks');
|
||||
mkdirSync(tasksDir, { recursive: true });
|
||||
writeFileSync(join(tasksDir, 'my-task.md'), 'Task content');
|
||||
const claimed = runner.claimNextTasks(1);
|
||||
expect(claimed).toHaveLength(1);
|
||||
expect(claimed[0]?.status).toBe('running');
|
||||
|
||||
const task = runner.getTask('my-task');
|
||||
expect(task).not.toBeNull();
|
||||
expect(task?.name).toBe('my-task');
|
||||
expect(task?.content).toBe('Task content');
|
||||
});
|
||||
const file = loadTasksFile(testDir);
|
||||
expect(file.tasks.some((task) => task.status === 'running')).toBe(true);
|
||||
});
|
||||
|
||||
describe('getNextTask', () => {
|
||||
it('should return null when no tasks', () => {
|
||||
const task = runner.getNextTask();
|
||||
expect(task).toBeNull();
|
||||
});
|
||||
it('should recover interrupted running tasks to pending', () => {
|
||||
runner.addTask('Task A');
|
||||
runner.claimNextTasks(1);
|
||||
const current = loadTasksFile(testDir);
|
||||
const running = current.tasks[0] as Record<string, unknown>;
|
||||
running.owner_pid = 999999999;
|
||||
writeFileSync(join(testDir, '.takt', 'tasks.yaml'), stringifyYaml(current), 'utf-8');
|
||||
|
||||
it('should return first task (alphabetically)', () => {
|
||||
const tasksDir = join(testDir, '.takt', 'tasks');
|
||||
mkdirSync(tasksDir, { recursive: true });
|
||||
writeFileSync(join(tasksDir, 'b-task.md'), 'B');
|
||||
writeFileSync(join(tasksDir, 'a-task.md'), 'A');
|
||||
const recovered = runner.recoverInterruptedRunningTasks();
|
||||
expect(recovered).toBe(1);
|
||||
|
||||
const task = runner.getNextTask();
|
||||
expect(task?.name).toBe('a-task');
|
||||
});
|
||||
const tasks = runner.listTasks();
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0]?.status).toBe('pending');
|
||||
});
|
||||
|
||||
describe('claimNextTasks', () => {
|
||||
it('should return empty array when no tasks', () => {
|
||||
const tasks = runner.claimNextTasks(3);
|
||||
expect(tasks).toEqual([]);
|
||||
});
|
||||
it('should keep running tasks owned by a live process', () => {
|
||||
runner.addTask('Task A');
|
||||
runner.claimNextTasks(1);
|
||||
|
||||
it('should return tasks up to the requested count', () => {
|
||||
const tasksDir = join(testDir, '.takt', 'tasks');
|
||||
mkdirSync(tasksDir, { recursive: true });
|
||||
writeFileSync(join(tasksDir, 'a-task.md'), 'A');
|
||||
writeFileSync(join(tasksDir, 'b-task.md'), 'B');
|
||||
writeFileSync(join(tasksDir, 'c-task.md'), 'C');
|
||||
|
||||
const tasks = runner.claimNextTasks(2);
|
||||
expect(tasks).toHaveLength(2);
|
||||
expect(tasks[0]?.name).toBe('a-task');
|
||||
expect(tasks[1]?.name).toBe('b-task');
|
||||
});
|
||||
|
||||
it('should not return already claimed tasks on subsequent calls', () => {
|
||||
const tasksDir = join(testDir, '.takt', 'tasks');
|
||||
mkdirSync(tasksDir, { recursive: true });
|
||||
writeFileSync(join(tasksDir, 'a-task.md'), 'A');
|
||||
writeFileSync(join(tasksDir, 'b-task.md'), 'B');
|
||||
writeFileSync(join(tasksDir, 'c-task.md'), 'C');
|
||||
|
||||
// Given: first call claims a-task
|
||||
const first = runner.claimNextTasks(1);
|
||||
expect(first).toHaveLength(1);
|
||||
expect(first[0]?.name).toBe('a-task');
|
||||
|
||||
// When: second call should skip a-task
|
||||
const second = runner.claimNextTasks(1);
|
||||
expect(second).toHaveLength(1);
|
||||
expect(second[0]?.name).toBe('b-task');
|
||||
|
||||
// When: third call should skip a-task and b-task
|
||||
const third = runner.claimNextTasks(1);
|
||||
expect(third).toHaveLength(1);
|
||||
expect(third[0]?.name).toBe('c-task');
|
||||
|
||||
// When: fourth call should return empty (all claimed)
|
||||
const fourth = runner.claimNextTasks(1);
|
||||
expect(fourth).toEqual([]);
|
||||
});
|
||||
|
||||
it('should release claim after completeTask', () => {
|
||||
const tasksDir = join(testDir, '.takt', 'tasks');
|
||||
mkdirSync(tasksDir, { recursive: true });
|
||||
writeFileSync(join(tasksDir, 'task-a.md'), 'Task A content');
|
||||
|
||||
// Given: claim the task
|
||||
const claimed = runner.claimNextTasks(1);
|
||||
expect(claimed).toHaveLength(1);
|
||||
|
||||
// When: complete the task (file is moved away)
|
||||
runner.completeTask({
|
||||
task: claimed[0]!,
|
||||
success: true,
|
||||
response: 'Done',
|
||||
executionLog: [],
|
||||
startedAt: '2024-01-01T00:00:00.000Z',
|
||||
completedAt: '2024-01-01T00:01:00.000Z',
|
||||
});
|
||||
|
||||
// Then: claim set no longer blocks (but file is moved, so no tasks anyway)
|
||||
const next = runner.claimNextTasks(1);
|
||||
expect(next).toEqual([]);
|
||||
});
|
||||
|
||||
it('should release claim after failTask', () => {
|
||||
const tasksDir = join(testDir, '.takt', 'tasks');
|
||||
mkdirSync(tasksDir, { recursive: true });
|
||||
writeFileSync(join(tasksDir, 'task-a.md'), 'Task A content');
|
||||
|
||||
// Given: claim the task
|
||||
const claimed = runner.claimNextTasks(1);
|
||||
expect(claimed).toHaveLength(1);
|
||||
|
||||
// When: fail the task (file is moved away)
|
||||
runner.failTask({
|
||||
task: claimed[0]!,
|
||||
success: false,
|
||||
response: 'Error',
|
||||
executionLog: [],
|
||||
startedAt: '2024-01-01T00:00:00.000Z',
|
||||
completedAt: '2024-01-01T00:01:00.000Z',
|
||||
});
|
||||
|
||||
// Then: claim set no longer blocks
|
||||
const next = runner.claimNextTasks(1);
|
||||
expect(next).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not affect getNextTask (unclaimed access)', () => {
|
||||
const tasksDir = join(testDir, '.takt', 'tasks');
|
||||
mkdirSync(tasksDir, { recursive: true });
|
||||
writeFileSync(join(tasksDir, 'a-task.md'), 'A');
|
||||
writeFileSync(join(tasksDir, 'b-task.md'), 'B');
|
||||
|
||||
// Given: claim a-task via claimNextTasks
|
||||
runner.claimNextTasks(1);
|
||||
|
||||
// When: getNextTask is called (no claim filtering)
|
||||
const task = runner.getNextTask();
|
||||
|
||||
// Then: getNextTask still returns first task (including claimed)
|
||||
expect(task?.name).toBe('a-task');
|
||||
});
|
||||
const recovered = runner.recoverInterruptedRunningTasks();
|
||||
expect(recovered).toBe(0);
|
||||
});
|
||||
|
||||
describe('completeTask', () => {
|
||||
it('should move task to completed directory', () => {
|
||||
const tasksDir = join(testDir, '.takt', 'tasks');
|
||||
mkdirSync(tasksDir, { recursive: true });
|
||||
const taskFile = join(tasksDir, 'test-task.md');
|
||||
writeFileSync(taskFile, 'Test task content');
|
||||
it('should take over stale lock file with invalid pid', () => {
|
||||
mkdirSync(join(testDir, '.takt'), { recursive: true });
|
||||
writeFileSync(join(testDir, '.takt', 'tasks.yaml.lock'), 'invalid-pid', 'utf-8');
|
||||
|
||||
const task = runner.getTask('test-task')!;
|
||||
const result = {
|
||||
task,
|
||||
success: true,
|
||||
response: 'Task completed successfully',
|
||||
executionLog: ['Started', 'Done'],
|
||||
startedAt: '2024-01-01T00:00:00.000Z',
|
||||
completedAt: '2024-01-01T00:01:00.000Z',
|
||||
};
|
||||
const task = runner.addTask('Task with stale lock');
|
||||
|
||||
const reportFile = runner.completeTask(result);
|
||||
|
||||
// Original task file should be moved
|
||||
expect(existsSync(taskFile)).toBe(false);
|
||||
|
||||
// Report should be created
|
||||
expect(existsSync(reportFile)).toBe(true);
|
||||
const reportContent = readFileSync(reportFile, 'utf-8');
|
||||
expect(reportContent).toContain('# タスク実行レポート');
|
||||
expect(reportContent).toContain('test-task');
|
||||
expect(reportContent).toContain('成功');
|
||||
|
||||
// Log file should be created
|
||||
const logFile = reportFile.replace('report.md', 'log.json');
|
||||
expect(existsSync(logFile)).toBe(true);
|
||||
const logData = JSON.parse(readFileSync(logFile, 'utf-8'));
|
||||
expect(logData.taskName).toBe('test-task');
|
||||
expect(logData.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw error when called with a failed result', () => {
|
||||
const tasksDir = join(testDir, '.takt', 'tasks');
|
||||
mkdirSync(tasksDir, { recursive: true });
|
||||
writeFileSync(join(tasksDir, 'fail-task.md'), 'Will fail');
|
||||
|
||||
const task = runner.getTask('fail-task')!;
|
||||
const result = {
|
||||
task,
|
||||
success: false,
|
||||
response: 'Error occurred',
|
||||
executionLog: ['Error'],
|
||||
startedAt: '2024-01-01T00:00:00.000Z',
|
||||
completedAt: '2024-01-01T00:01:00.000Z',
|
||||
};
|
||||
|
||||
expect(() => runner.completeTask(result)).toThrow(
|
||||
'Cannot complete a failed task. Use failTask() instead.'
|
||||
);
|
||||
});
|
||||
expect(task.name).toContain('task-with-stale-lock');
|
||||
expect(existsSync(join(testDir, '.takt', 'tasks.yaml.lock'))).toBe(false);
|
||||
});
|
||||
|
||||
describe('failTask', () => {
|
||||
it('should move task to failed directory', () => {
|
||||
const tasksDir = join(testDir, '.takt', 'tasks');
|
||||
mkdirSync(tasksDir, { recursive: true });
|
||||
const taskFile = join(tasksDir, 'fail-task.md');
|
||||
writeFileSync(taskFile, 'Task that will fail');
|
||||
it('should timeout when lock file is held by a live process', () => {
|
||||
mkdirSync(join(testDir, '.takt'), { recursive: true });
|
||||
writeFileSync(join(testDir, '.takt', 'tasks.yaml.lock'), String(process.pid), 'utf-8');
|
||||
|
||||
const task = runner.getTask('fail-task')!;
|
||||
const result = {
|
||||
task,
|
||||
success: false,
|
||||
response: 'Error occurred',
|
||||
executionLog: ['Started', 'Error'],
|
||||
startedAt: '2024-01-01T00:00:00.000Z',
|
||||
completedAt: '2024-01-01T00:01:00.000Z',
|
||||
};
|
||||
const dateNowSpy = vi.spyOn(Date, 'now');
|
||||
dateNowSpy.mockReturnValueOnce(0);
|
||||
dateNowSpy.mockReturnValue(5_000);
|
||||
|
||||
const reportFile = runner.failTask(result);
|
||||
|
||||
// Original task file should be removed from tasks dir
|
||||
expect(existsSync(taskFile)).toBe(false);
|
||||
|
||||
// Report should be in .takt/failed/ (not .takt/completed/)
|
||||
expect(reportFile).toContain(join('.takt', 'failed'));
|
||||
expect(reportFile).not.toContain(join('.takt', 'completed'));
|
||||
expect(existsSync(reportFile)).toBe(true);
|
||||
|
||||
const reportContent = readFileSync(reportFile, 'utf-8');
|
||||
expect(reportContent).toContain('# タスク実行レポート');
|
||||
expect(reportContent).toContain('fail-task');
|
||||
expect(reportContent).toContain('失敗');
|
||||
|
||||
// Log file should be created in failed dir
|
||||
const logFile = reportFile.replace('report.md', 'log.json');
|
||||
expect(existsSync(logFile)).toBe(true);
|
||||
const logData = JSON.parse(readFileSync(logFile, 'utf-8'));
|
||||
expect(logData.taskName).toBe('fail-task');
|
||||
expect(logData.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should not move failed task to completed directory', () => {
|
||||
const tasksDir = join(testDir, '.takt', 'tasks');
|
||||
const completedDir = join(testDir, '.takt', 'completed');
|
||||
mkdirSync(tasksDir, { recursive: true });
|
||||
const taskFile = join(tasksDir, 'another-fail.md');
|
||||
writeFileSync(taskFile, 'Another failing task');
|
||||
|
||||
const task = runner.getTask('another-fail')!;
|
||||
const result = {
|
||||
task,
|
||||
success: false,
|
||||
response: 'Something went wrong',
|
||||
executionLog: [],
|
||||
startedAt: '2024-01-01T00:00:00.000Z',
|
||||
completedAt: '2024-01-01T00:01:00.000Z',
|
||||
};
|
||||
|
||||
runner.failTask(result);
|
||||
|
||||
// completed directory should be empty (only the dir itself exists)
|
||||
mkdirSync(completedDir, { recursive: true });
|
||||
const completedContents = readdirSync(completedDir);
|
||||
expect(completedContents).toHaveLength(0);
|
||||
});
|
||||
try {
|
||||
expect(() => runner.listTasks()).toThrow('Failed to acquire tasks lock within 5000ms');
|
||||
} finally {
|
||||
dateNowSpy.mockRestore();
|
||||
rmSync(join(testDir, '.takt', 'tasks.yaml.lock'), { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('getTasksDir', () => {
|
||||
it('should return tasks directory path', () => {
|
||||
expect(runner.getTasksDir()).toBe(join(testDir, '.takt', 'tasks'));
|
||||
});
|
||||
it('should recover from corrupted tasks.yaml and allow adding tasks again', () => {
|
||||
mkdirSync(join(testDir, '.takt'), { recursive: true });
|
||||
writeFileSync(join(testDir, '.takt', 'tasks.yaml'), 'tasks:\n - name: [broken', 'utf-8');
|
||||
|
||||
expect(() => runner.listTasks()).not.toThrow();
|
||||
expect(runner.listTasks()).toEqual([]);
|
||||
expect(existsSync(join(testDir, '.takt', 'tasks.yaml'))).toBe(false);
|
||||
|
||||
const task = runner.addTask('Task after recovery');
|
||||
expect(task.name).toContain('task-after-recovery');
|
||||
expect(existsSync(join(testDir, '.takt', 'tasks.yaml'))).toBe(true);
|
||||
expect(runner.listTasks()).toHaveLength(1);
|
||||
});
|
||||
|
||||
describe('requeueFailedTask', () => {
|
||||
it('should copy task file from failed to tasks directory', () => {
|
||||
runner.ensureDirs();
|
||||
it('should load pending content from relative content_file', () => {
|
||||
mkdirSync(join(testDir, 'fixtures'), { recursive: true });
|
||||
writeFileSync(join(testDir, 'fixtures', 'task.txt'), 'Task from file\nsecond line', 'utf-8');
|
||||
writeTasksFile(testDir, [createPendingRecord({
|
||||
content: undefined,
|
||||
content_file: 'fixtures/task.txt',
|
||||
})]);
|
||||
|
||||
// Create a failed task directory
|
||||
const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_my-task');
|
||||
mkdirSync(failedDir, { recursive: true });
|
||||
writeFileSync(join(failedDir, 'my-task.yaml'), 'task: Do something\n');
|
||||
writeFileSync(join(failedDir, 'report.md'), '# Report');
|
||||
writeFileSync(join(failedDir, 'log.json'), '{}');
|
||||
const tasks = runner.listTasks();
|
||||
const pendingItems = runner.listPendingTaskItems();
|
||||
|
||||
const result = runner.requeueFailedTask(failedDir);
|
||||
expect(tasks[0]?.content).toBe('Task from file\nsecond line');
|
||||
expect(pendingItems[0]?.content).toBe('Task from file');
|
||||
});
|
||||
|
||||
// Task file should be copied to tasks dir
|
||||
expect(existsSync(result)).toBe(true);
|
||||
expect(result).toBe(join(testDir, '.takt', 'tasks', 'my-task.yaml'));
|
||||
it('should load pending content from absolute content_file', () => {
|
||||
const contentPath = join(testDir, 'absolute-task.txt');
|
||||
writeFileSync(contentPath, 'Absolute task content', 'utf-8');
|
||||
writeTasksFile(testDir, [createPendingRecord({
|
||||
content: undefined,
|
||||
content_file: contentPath,
|
||||
})]);
|
||||
|
||||
// Original failed directory should still exist
|
||||
expect(existsSync(failedDir)).toBe(true);
|
||||
const tasks = runner.listTasks();
|
||||
expect(tasks[0]?.content).toBe('Absolute task content');
|
||||
});
|
||||
|
||||
// Task content should be preserved
|
||||
const content = readFileSync(result, 'utf-8');
|
||||
expect(content).toBe('task: Do something\n');
|
||||
it('should prefer inline content over content_file', () => {
|
||||
writeTasksFile(testDir, [createPendingRecord({
|
||||
content: 'Inline content',
|
||||
content_file: 'missing-content-file.txt',
|
||||
})]);
|
||||
|
||||
const tasks = runner.listTasks();
|
||||
expect(tasks[0]?.content).toBe('Inline content');
|
||||
});
|
||||
|
||||
it('should throw when content_file target is missing', () => {
|
||||
writeTasksFile(testDir, [createPendingRecord({
|
||||
content: undefined,
|
||||
content_file: 'missing-content-file.txt',
|
||||
})]);
|
||||
|
||||
expect(() => runner.listTasks()).toThrow(/ENOENT|no such file/i);
|
||||
});
|
||||
|
||||
it('should mark claimed task as completed', () => {
|
||||
runner.addTask('Task A');
|
||||
const task = runner.claimNextTasks(1)[0]!;
|
||||
|
||||
runner.completeTask({
|
||||
task,
|
||||
success: true,
|
||||
response: 'Done',
|
||||
executionLog: [],
|
||||
startedAt: new Date().toISOString(),
|
||||
completedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
it('should add start_movement to YAML task file when specified', () => {
|
||||
runner.ensureDirs();
|
||||
const file = loadTasksFile(testDir);
|
||||
expect(file.tasks[0]?.status).toBe('completed');
|
||||
});
|
||||
|
||||
const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_retry-task');
|
||||
mkdirSync(failedDir, { recursive: true });
|
||||
writeFileSync(join(failedDir, 'retry-task.yaml'), 'task: Retry me\npiece: default\n');
|
||||
it('should mark claimed task as failed with failure detail', () => {
|
||||
runner.addTask('Task A');
|
||||
const task = runner.claimNextTasks(1)[0]!;
|
||||
|
||||
const result = runner.requeueFailedTask(failedDir, 'implement');
|
||||
|
||||
const content = readFileSync(result, 'utf-8');
|
||||
expect(content).toContain('start_movement: implement');
|
||||
expect(content).toContain('task: Retry me');
|
||||
expect(content).toContain('piece: default');
|
||||
runner.failTask({
|
||||
task,
|
||||
success: false,
|
||||
response: 'Boom',
|
||||
executionLog: ['last message'],
|
||||
failureMovement: 'review',
|
||||
failureLastMessage: 'last message',
|
||||
startedAt: new Date().toISOString(),
|
||||
completedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
it('should replace existing start_movement in YAML task file', () => {
|
||||
runner.ensureDirs();
|
||||
const failed = runner.listFailedTasks();
|
||||
expect(failed).toHaveLength(1);
|
||||
expect(failed[0]?.failure?.error).toBe('Boom');
|
||||
expect(failed[0]?.failure?.movement).toBe('review');
|
||||
expect(failed[0]?.failure?.last_message).toBe('last message');
|
||||
});
|
||||
|
||||
const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_replace-task');
|
||||
mkdirSync(failedDir, { recursive: true });
|
||||
writeFileSync(join(failedDir, 'replace-task.yaml'), 'task: Replace me\nstart_movement: plan\n');
|
||||
|
||||
const result = runner.requeueFailedTask(failedDir, 'ai_review');
|
||||
|
||||
const content = readFileSync(result, 'utf-8');
|
||||
expect(content).toContain('start_movement: ai_review');
|
||||
expect(content).not.toContain('start_movement: plan');
|
||||
it('should requeue failed task to pending with retry metadata', () => {
|
||||
runner.addTask('Task A');
|
||||
const task = runner.claimNextTasks(1)[0]!;
|
||||
runner.failTask({
|
||||
task,
|
||||
success: false,
|
||||
response: 'Boom',
|
||||
executionLog: [],
|
||||
startedAt: new Date().toISOString(),
|
||||
completedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
it('should not modify markdown task files even with startMovement', () => {
|
||||
runner.ensureDirs();
|
||||
runner.requeueFailedTask(task.name, 'implement', 'retry note');
|
||||
|
||||
const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_md-task');
|
||||
mkdirSync(failedDir, { recursive: true });
|
||||
writeFileSync(join(failedDir, 'md-task.md'), '# Task\nDo something');
|
||||
const pending = runner.listTasks();
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0]?.data?.start_movement).toBe('implement');
|
||||
expect(pending[0]?.data?.retry_note).toBe('retry note');
|
||||
});
|
||||
|
||||
const result = runner.requeueFailedTask(failedDir, 'implement');
|
||||
it('should delete pending and failed tasks', () => {
|
||||
const pending = runner.addTask('Task A');
|
||||
runner.deletePendingTask(pending.name);
|
||||
expect(runner.listTasks()).toHaveLength(0);
|
||||
|
||||
const content = readFileSync(result, 'utf-8');
|
||||
// Markdown files should not have start_movement added
|
||||
expect(content).toBe('# Task\nDo something');
|
||||
expect(content).not.toContain('start_movement');
|
||||
});
|
||||
|
||||
it('should throw error when no task file found', () => {
|
||||
runner.ensureDirs();
|
||||
|
||||
const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_no-task');
|
||||
mkdirSync(failedDir, { recursive: true });
|
||||
writeFileSync(join(failedDir, 'report.md'), '# Report');
|
||||
|
||||
expect(() => runner.requeueFailedTask(failedDir)).toThrow(
|
||||
/No task file found in failed directory/
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when failed directory does not exist', () => {
|
||||
runner.ensureDirs();
|
||||
|
||||
expect(() => runner.requeueFailedTask('/nonexistent/path')).toThrow(
|
||||
/Failed to read failed task directory/
|
||||
);
|
||||
});
|
||||
|
||||
it('should add retry_note to YAML task file when specified', () => {
|
||||
runner.ensureDirs();
|
||||
|
||||
const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_note-task');
|
||||
mkdirSync(failedDir, { recursive: true });
|
||||
writeFileSync(join(failedDir, 'note-task.yaml'), 'task: Task with note\n');
|
||||
|
||||
const result = runner.requeueFailedTask(failedDir, undefined, 'Fixed the ENOENT error');
|
||||
|
||||
const content = readFileSync(result, 'utf-8');
|
||||
expect(content).toContain('retry_note: "Fixed the ENOENT error"');
|
||||
expect(content).toContain('task: Task with note');
|
||||
});
|
||||
|
||||
it('should escape double quotes in retry_note', () => {
|
||||
runner.ensureDirs();
|
||||
|
||||
const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_quote-task');
|
||||
mkdirSync(failedDir, { recursive: true });
|
||||
writeFileSync(join(failedDir, 'quote-task.yaml'), 'task: Task with quotes\n');
|
||||
|
||||
const result = runner.requeueFailedTask(failedDir, undefined, 'Fixed "spawn node ENOENT" error');
|
||||
|
||||
const content = readFileSync(result, 'utf-8');
|
||||
expect(content).toContain('retry_note: "Fixed \\"spawn node ENOENT\\" error"');
|
||||
});
|
||||
|
||||
it('should add both start_movement and retry_note when both specified', () => {
|
||||
runner.ensureDirs();
|
||||
|
||||
const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_both-task');
|
||||
mkdirSync(failedDir, { recursive: true });
|
||||
writeFileSync(join(failedDir, 'both-task.yaml'), 'task: Task with both\n');
|
||||
|
||||
const result = runner.requeueFailedTask(failedDir, 'implement', 'Retrying from implement');
|
||||
|
||||
const content = readFileSync(result, 'utf-8');
|
||||
expect(content).toContain('start_movement: implement');
|
||||
expect(content).toContain('retry_note: "Retrying from implement"');
|
||||
});
|
||||
|
||||
it('should not add retry_note to markdown task files', () => {
|
||||
runner.ensureDirs();
|
||||
|
||||
const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_md-note-task');
|
||||
mkdirSync(failedDir, { recursive: true });
|
||||
writeFileSync(join(failedDir, 'md-note-task.md'), '# Task\nDo something');
|
||||
|
||||
const result = runner.requeueFailedTask(failedDir, undefined, 'Should be ignored');
|
||||
|
||||
const content = readFileSync(result, 'utf-8');
|
||||
expect(content).toBe('# Task\nDo something');
|
||||
expect(content).not.toContain('retry_note');
|
||||
const failed = runner.addTask('Task B');
|
||||
const running = runner.claimNextTasks(1)[0]!;
|
||||
runner.failTask({
|
||||
task: running,
|
||||
success: false,
|
||||
response: 'Boom',
|
||||
executionLog: [],
|
||||
startedAt: new Date().toISOString(),
|
||||
completedAt: new Date().toISOString(),
|
||||
});
|
||||
runner.deleteFailedTask(failed.name);
|
||||
expect(runner.listFailedTasks()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TaskRecordSchema', () => {
|
||||
it('should reject failed record without failure details', () => {
|
||||
expect(() => TaskRecordSchema.parse({
|
||||
name: 'task-a',
|
||||
status: 'failed',
|
||||
content: 'Do work',
|
||||
created_at: '2026-02-09T00:00:00.000Z',
|
||||
started_at: '2026-02-09T00:01:00.000Z',
|
||||
completed_at: '2026-02-09T00:02:00.000Z',
|
||||
})).toThrow();
|
||||
});
|
||||
|
||||
it('should reject completed record with failure details', () => {
|
||||
expect(() => TaskRecordSchema.parse({
|
||||
name: 'task-a',
|
||||
status: 'completed',
|
||||
content: 'Do work',
|
||||
created_at: '2026-02-09T00:00:00.000Z',
|
||||
started_at: '2026-02-09T00:01:00.000Z',
|
||||
completed_at: '2026-02-09T00:02:00.000Z',
|
||||
failure: {
|
||||
error: 'unexpected',
|
||||
},
|
||||
})).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,10 +1,7 @@
|
||||
/**
|
||||
* Tests for taskDeleteActions — pending/failed task deletion
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { stringify as stringifyYaml } from 'yaml';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
vi.mock('../shared/prompt/index.js', () => ({
|
||||
@ -35,6 +32,33 @@ const mockLogError = vi.mocked(logError);
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
function setupTasksFile(projectDir: string): string {
|
||||
const tasksFile = path.join(projectDir, '.takt', 'tasks.yaml');
|
||||
fs.mkdirSync(path.dirname(tasksFile), { recursive: true });
|
||||
fs.writeFileSync(tasksFile, stringifyYaml({
|
||||
tasks: [
|
||||
{
|
||||
name: 'pending-task',
|
||||
status: 'pending',
|
||||
content: 'pending',
|
||||
created_at: '2025-01-15T00:00:00.000Z',
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
},
|
||||
{
|
||||
name: 'failed-task',
|
||||
status: 'failed',
|
||||
content: 'failed',
|
||||
created_at: '2025-01-15T00:00:00.000Z',
|
||||
started_at: '2025-01-15T00:01:00.000Z',
|
||||
completed_at: '2025-01-15T00:02:00.000Z',
|
||||
failure: { error: 'boom' },
|
||||
},
|
||||
],
|
||||
}), 'utf-8');
|
||||
return tasksFile;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-delete-'));
|
||||
@ -44,137 +68,59 @@ afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('deletePendingTask', () => {
|
||||
it('should delete pending task file when confirmed', async () => {
|
||||
// Given
|
||||
const filePath = path.join(tmpDir, 'my-task.md');
|
||||
fs.writeFileSync(filePath, 'task content');
|
||||
describe('taskDeleteActions', () => {
|
||||
it('should delete pending task when confirmed', async () => {
|
||||
const tasksFile = setupTasksFile(tmpDir);
|
||||
const task: TaskListItem = {
|
||||
kind: 'pending',
|
||||
name: 'my-task',
|
||||
name: 'pending-task',
|
||||
createdAt: '2025-01-15',
|
||||
filePath,
|
||||
content: 'task content',
|
||||
filePath: tasksFile,
|
||||
content: 'pending',
|
||||
};
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
|
||||
// When
|
||||
const result = await deletePendingTask(task);
|
||||
|
||||
// Then
|
||||
expect(result).toBe(true);
|
||||
expect(fs.existsSync(filePath)).toBe(false);
|
||||
expect(mockSuccess).toHaveBeenCalledWith('Deleted pending task: my-task');
|
||||
const raw = fs.readFileSync(tasksFile, 'utf-8');
|
||||
expect(raw).not.toContain('pending-task');
|
||||
expect(mockSuccess).toHaveBeenCalledWith('Deleted pending task: pending-task');
|
||||
});
|
||||
|
||||
it('should not delete when user declines confirmation', async () => {
|
||||
// Given
|
||||
const filePath = path.join(tmpDir, 'my-task.md');
|
||||
fs.writeFileSync(filePath, 'task content');
|
||||
it('should delete failed task when confirmed', async () => {
|
||||
const tasksFile = setupTasksFile(tmpDir);
|
||||
const task: TaskListItem = {
|
||||
kind: 'pending',
|
||||
name: 'my-task',
|
||||
createdAt: '2025-01-15',
|
||||
filePath,
|
||||
content: 'task content',
|
||||
kind: 'failed',
|
||||
name: 'failed-task',
|
||||
createdAt: '2025-01-15T12:34:56',
|
||||
filePath: tasksFile,
|
||||
content: 'failed',
|
||||
};
|
||||
mockConfirm.mockResolvedValue(false);
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
|
||||
// When
|
||||
const result = await deletePendingTask(task);
|
||||
const result = await deleteFailedTask(task);
|
||||
|
||||
// Then
|
||||
expect(result).toBe(false);
|
||||
expect(fs.existsSync(filePath)).toBe(true);
|
||||
expect(mockSuccess).not.toHaveBeenCalled();
|
||||
expect(result).toBe(true);
|
||||
const raw = fs.readFileSync(tasksFile, 'utf-8');
|
||||
expect(raw).not.toContain('failed-task');
|
||||
expect(mockSuccess).toHaveBeenCalledWith('Deleted failed task: failed-task');
|
||||
});
|
||||
|
||||
it('should return false and show error when file does not exist', async () => {
|
||||
// Given
|
||||
const filePath = path.join(tmpDir, 'non-existent.md');
|
||||
it('should return false when target task is missing', async () => {
|
||||
const tasksFile = setupTasksFile(tmpDir);
|
||||
const task: TaskListItem = {
|
||||
kind: 'pending',
|
||||
name: 'non-existent',
|
||||
createdAt: '2025-01-15',
|
||||
filePath,
|
||||
kind: 'failed',
|
||||
name: 'not-found',
|
||||
createdAt: '2025-01-15T12:34:56',
|
||||
filePath: tasksFile,
|
||||
content: '',
|
||||
};
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
|
||||
// When
|
||||
const result = await deletePendingTask(task);
|
||||
const result = await deleteFailedTask(task);
|
||||
|
||||
// Then
|
||||
expect(result).toBe(false);
|
||||
expect(mockLogError).toHaveBeenCalled();
|
||||
expect(mockSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFailedTask', () => {
|
||||
it('should delete failed task directory when confirmed', async () => {
|
||||
// Given
|
||||
const dirPath = path.join(tmpDir, '2025-01-15T12-34-56_my-task');
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
fs.writeFileSync(path.join(dirPath, 'my-task.md'), 'content');
|
||||
const task: TaskListItem = {
|
||||
kind: 'failed',
|
||||
name: 'my-task',
|
||||
createdAt: '2025-01-15T12:34:56',
|
||||
filePath: dirPath,
|
||||
content: 'content',
|
||||
};
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
|
||||
// When
|
||||
const result = await deleteFailedTask(task);
|
||||
|
||||
// Then
|
||||
expect(result).toBe(true);
|
||||
expect(fs.existsSync(dirPath)).toBe(false);
|
||||
expect(mockSuccess).toHaveBeenCalledWith('Deleted failed task: my-task');
|
||||
});
|
||||
|
||||
it('should not delete when user declines confirmation', async () => {
|
||||
// Given
|
||||
const dirPath = path.join(tmpDir, '2025-01-15T12-34-56_my-task');
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
const task: TaskListItem = {
|
||||
kind: 'failed',
|
||||
name: 'my-task',
|
||||
createdAt: '2025-01-15T12:34:56',
|
||||
filePath: dirPath,
|
||||
content: '',
|
||||
};
|
||||
mockConfirm.mockResolvedValue(false);
|
||||
|
||||
// When
|
||||
const result = await deleteFailedTask(task);
|
||||
|
||||
// Then
|
||||
expect(result).toBe(false);
|
||||
expect(fs.existsSync(dirPath)).toBe(true);
|
||||
expect(mockSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false and show error when directory does not exist', async () => {
|
||||
// Given
|
||||
const dirPath = path.join(tmpDir, 'non-existent-dir');
|
||||
const task: TaskListItem = {
|
||||
kind: 'failed',
|
||||
name: 'non-existent',
|
||||
createdAt: '2025-01-15T12:34:56',
|
||||
filePath: dirPath,
|
||||
content: '',
|
||||
};
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
|
||||
// When
|
||||
const result = await deleteFailedTask(task);
|
||||
|
||||
// Then
|
||||
expect(result).toBe(false);
|
||||
expect(mockLogError).toHaveBeenCalled();
|
||||
expect(mockSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -95,6 +95,9 @@ describe('resolveTaskExecution', () => {
|
||||
name: 'simple-task',
|
||||
content: 'Simple task content',
|
||||
filePath: '/tasks/simple-task.yaml',
|
||||
createdAt: '2026-02-09T00:00:00.000Z',
|
||||
status: 'pending',
|
||||
data: null,
|
||||
};
|
||||
|
||||
// When
|
||||
|
||||
@ -1,10 +1,7 @@
|
||||
/**
|
||||
* Tests for taskRetryActions — failed task retry functionality
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { stringify as stringifyYaml } from 'yaml';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
vi.mock('../shared/prompt/index.js', () => ({
|
||||
@ -29,10 +26,6 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../infra/fs/session.js', () => ({
|
||||
extractFailureInfo: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../infra/config/index.js', () => ({
|
||||
loadGlobalConfig: vi.fn(),
|
||||
loadPieceByIdentifier: vi.fn(),
|
||||
@ -66,16 +59,37 @@ const defaultPieceConfig: PieceConfig = {
|
||||
],
|
||||
};
|
||||
|
||||
const customPieceConfig: PieceConfig = {
|
||||
name: 'custom',
|
||||
description: 'Custom piece',
|
||||
initialMovement: 'step1',
|
||||
maxIterations: 10,
|
||||
movements: [
|
||||
{ name: 'step1', persona: 'coder', instruction: '' },
|
||||
{ name: 'step2', persona: 'reviewer', instruction: '' },
|
||||
],
|
||||
};
|
||||
function writeFailedTask(projectDir: string, name: string): TaskListItem {
|
||||
const tasksFile = path.join(projectDir, '.takt', 'tasks.yaml');
|
||||
fs.mkdirSync(path.dirname(tasksFile), { recursive: true });
|
||||
fs.writeFileSync(tasksFile, stringifyYaml({
|
||||
tasks: [
|
||||
{
|
||||
name,
|
||||
status: 'failed',
|
||||
content: 'Do something',
|
||||
created_at: '2025-01-15T12:00:00.000Z',
|
||||
started_at: '2025-01-15T12:01:00.000Z',
|
||||
completed_at: '2025-01-15T12:02:00.000Z',
|
||||
piece: 'default',
|
||||
failure: {
|
||||
movement: 'review',
|
||||
error: 'Boom',
|
||||
},
|
||||
},
|
||||
],
|
||||
}), 'utf-8');
|
||||
|
||||
return {
|
||||
kind: 'failed',
|
||||
name,
|
||||
createdAt: '2025-01-15T12:02:00.000Z',
|
||||
filePath: tasksFile,
|
||||
content: 'Do something',
|
||||
data: { task: 'Do something', piece: 'default' },
|
||||
failure: { movement: 'review', error: 'Boom' },
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@ -88,264 +102,49 @@ afterEach(() => {
|
||||
|
||||
describe('retryFailedTask', () => {
|
||||
it('should requeue task with selected movement', async () => {
|
||||
// Given: a failed task directory with a task file
|
||||
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_my-task');
|
||||
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
|
||||
fs.mkdirSync(failedDir, { recursive: true });
|
||||
fs.mkdirSync(tasksDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(failedDir, 'my-task.yaml'), 'task: Do something\n');
|
||||
|
||||
const task: TaskListItem = {
|
||||
kind: 'failed',
|
||||
name: 'my-task',
|
||||
createdAt: '2025-01-15T12:34:56',
|
||||
filePath: failedDir,
|
||||
content: 'Do something',
|
||||
};
|
||||
const task = writeFailedTask(tmpDir, 'my-task');
|
||||
|
||||
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' });
|
||||
mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig);
|
||||
mockSelectOption.mockResolvedValue('implement');
|
||||
mockPromptInput.mockResolvedValue(''); // Empty retry note
|
||||
mockPromptInput.mockResolvedValue('');
|
||||
|
||||
// When
|
||||
const result = await retryFailedTask(task, tmpDir);
|
||||
|
||||
// Then
|
||||
expect(result).toBe(true);
|
||||
expect(mockSuccess).toHaveBeenCalledWith('Task requeued: my-task');
|
||||
|
||||
// Verify requeued file
|
||||
const requeuedFile = path.join(tasksDir, 'my-task.yaml');
|
||||
expect(fs.existsSync(requeuedFile)).toBe(true);
|
||||
const content = fs.readFileSync(requeuedFile, 'utf-8');
|
||||
expect(content).toContain('start_movement: implement');
|
||||
});
|
||||
|
||||
it('should use piece field from task file instead of defaultPiece', async () => {
|
||||
// Given: a failed task with piece: custom in YAML
|
||||
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_custom-task');
|
||||
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
|
||||
fs.mkdirSync(failedDir, { recursive: true });
|
||||
fs.mkdirSync(tasksDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(failedDir, 'custom-task.yaml'),
|
||||
'task: Do something\npiece: custom\n',
|
||||
);
|
||||
|
||||
const task: TaskListItem = {
|
||||
kind: 'failed',
|
||||
name: 'custom-task',
|
||||
createdAt: '2025-01-15T12:34:56',
|
||||
filePath: failedDir,
|
||||
content: 'Do something',
|
||||
};
|
||||
|
||||
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' });
|
||||
// Should be called with 'custom', not 'default'
|
||||
mockLoadPieceByIdentifier.mockImplementation((name: string) => {
|
||||
if (name === 'custom') return customPieceConfig;
|
||||
if (name === 'default') return defaultPieceConfig;
|
||||
return null;
|
||||
});
|
||||
mockSelectOption.mockResolvedValue('step2');
|
||||
mockPromptInput.mockResolvedValue('');
|
||||
|
||||
// When
|
||||
const result = await retryFailedTask(task, tmpDir);
|
||||
|
||||
// Then
|
||||
expect(result).toBe(true);
|
||||
expect(mockLoadPieceByIdentifier).toHaveBeenCalledWith('custom', tmpDir);
|
||||
expect(mockSuccess).toHaveBeenCalledWith('Task requeued: custom-task');
|
||||
});
|
||||
|
||||
it('should return false when user cancels movement selection', async () => {
|
||||
// Given
|
||||
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_my-task');
|
||||
fs.mkdirSync(failedDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(failedDir, 'my-task.yaml'), 'task: Do something\n');
|
||||
|
||||
const task: TaskListItem = {
|
||||
kind: 'failed',
|
||||
name: 'my-task',
|
||||
createdAt: '2025-01-15T12:34:56',
|
||||
filePath: failedDir,
|
||||
content: 'Do something',
|
||||
};
|
||||
|
||||
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' });
|
||||
mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig);
|
||||
mockSelectOption.mockResolvedValue(null); // User cancelled
|
||||
// No need to mock promptInput since user cancelled before reaching it
|
||||
|
||||
// When
|
||||
const result = await retryFailedTask(task, tmpDir);
|
||||
|
||||
// Then
|
||||
expect(result).toBe(false);
|
||||
expect(mockSuccess).not.toHaveBeenCalled();
|
||||
expect(mockPromptInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false and show error when piece not found', async () => {
|
||||
// Given
|
||||
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_my-task');
|
||||
fs.mkdirSync(failedDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(failedDir, 'my-task.yaml'), 'task: Do something\n');
|
||||
|
||||
const task: TaskListItem = {
|
||||
kind: 'failed',
|
||||
name: 'my-task',
|
||||
createdAt: '2025-01-15T12:34:56',
|
||||
filePath: failedDir,
|
||||
content: 'Do something',
|
||||
};
|
||||
|
||||
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'nonexistent' });
|
||||
mockLoadPieceByIdentifier.mockReturnValue(null);
|
||||
|
||||
// When
|
||||
const result = await retryFailedTask(task, tmpDir);
|
||||
|
||||
// Then
|
||||
expect(result).toBe(false);
|
||||
expect(mockLogError).toHaveBeenCalledWith(
|
||||
'Piece "nonexistent" not found. Cannot determine available movements.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fallback to defaultPiece when task file has no piece field', async () => {
|
||||
// Given: a failed task without piece field in YAML
|
||||
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_plain-task');
|
||||
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
|
||||
fs.mkdirSync(failedDir, { recursive: true });
|
||||
fs.mkdirSync(tasksDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(failedDir, 'plain-task.yaml'),
|
||||
'task: Do something without piece\n',
|
||||
);
|
||||
|
||||
const task: TaskListItem = {
|
||||
kind: 'failed',
|
||||
name: 'plain-task',
|
||||
createdAt: '2025-01-15T12:34:56',
|
||||
filePath: failedDir,
|
||||
content: 'Do something without piece',
|
||||
};
|
||||
|
||||
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' });
|
||||
mockLoadPieceByIdentifier.mockImplementation((name: string) => {
|
||||
if (name === 'default') return defaultPieceConfig;
|
||||
return null;
|
||||
});
|
||||
mockSelectOption.mockResolvedValue('plan');
|
||||
mockPromptInput.mockResolvedValue('');
|
||||
|
||||
// When
|
||||
const result = await retryFailedTask(task, tmpDir);
|
||||
|
||||
// Then
|
||||
expect(result).toBe(true);
|
||||
expect(mockLoadPieceByIdentifier).toHaveBeenCalledWith('default', tmpDir);
|
||||
const tasksYaml = fs.readFileSync(path.join(tmpDir, '.takt', 'tasks.yaml'), 'utf-8');
|
||||
expect(tasksYaml).toContain('status: pending');
|
||||
expect(tasksYaml).toContain('start_movement: implement');
|
||||
});
|
||||
|
||||
it('should not add start_movement when initial movement is selected', async () => {
|
||||
// Given
|
||||
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_my-task');
|
||||
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
|
||||
fs.mkdirSync(failedDir, { recursive: true });
|
||||
fs.mkdirSync(tasksDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(failedDir, 'my-task.yaml'), 'task: Do something\n');
|
||||
|
||||
const task: TaskListItem = {
|
||||
kind: 'failed',
|
||||
name: 'my-task',
|
||||
createdAt: '2025-01-15T12:34:56',
|
||||
filePath: failedDir,
|
||||
content: 'Do something',
|
||||
};
|
||||
const task = writeFailedTask(tmpDir, 'my-task');
|
||||
|
||||
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' });
|
||||
mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig);
|
||||
mockSelectOption.mockResolvedValue('plan'); // Initial movement
|
||||
mockPromptInput.mockResolvedValue(''); // Empty retry note
|
||||
mockSelectOption.mockResolvedValue('plan');
|
||||
mockPromptInput.mockResolvedValue('');
|
||||
|
||||
// When
|
||||
const result = await retryFailedTask(task, tmpDir);
|
||||
|
||||
// Then
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Verify requeued file does not have start_movement
|
||||
const requeuedFile = path.join(tasksDir, 'my-task.yaml');
|
||||
const content = fs.readFileSync(requeuedFile, 'utf-8');
|
||||
expect(content).not.toContain('start_movement');
|
||||
const tasksYaml = fs.readFileSync(path.join(tmpDir, '.takt', 'tasks.yaml'), 'utf-8');
|
||||
expect(tasksYaml).not.toContain('start_movement');
|
||||
});
|
||||
|
||||
it('should add retry_note when user provides one', async () => {
|
||||
// Given
|
||||
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_retry-note-task');
|
||||
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
|
||||
fs.mkdirSync(failedDir, { recursive: true });
|
||||
fs.mkdirSync(tasksDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(failedDir, 'retry-note-task.yaml'), 'task: Do something\n');
|
||||
|
||||
const task: TaskListItem = {
|
||||
kind: 'failed',
|
||||
name: 'retry-note-task',
|
||||
createdAt: '2025-01-15T12:34:56',
|
||||
filePath: failedDir,
|
||||
content: 'Do something',
|
||||
};
|
||||
it('should return false and show error when piece not found', async () => {
|
||||
const task = writeFailedTask(tmpDir, 'my-task');
|
||||
|
||||
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' });
|
||||
mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig);
|
||||
mockSelectOption.mockResolvedValue('implement');
|
||||
mockPromptInput.mockResolvedValue('Fixed spawn node ENOENT error');
|
||||
mockLoadPieceByIdentifier.mockReturnValue(null);
|
||||
|
||||
// When
|
||||
const result = await retryFailedTask(task, tmpDir);
|
||||
|
||||
// Then
|
||||
expect(result).toBe(true);
|
||||
|
||||
const requeuedFile = path.join(tasksDir, 'retry-note-task.yaml');
|
||||
const content = fs.readFileSync(requeuedFile, 'utf-8');
|
||||
expect(content).toContain('start_movement: implement');
|
||||
expect(content).toContain('retry_note: "Fixed spawn node ENOENT error"');
|
||||
});
|
||||
|
||||
it('should not add retry_note when user skips it', async () => {
|
||||
// Given
|
||||
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_no-note-task');
|
||||
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
|
||||
fs.mkdirSync(failedDir, { recursive: true });
|
||||
fs.mkdirSync(tasksDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(failedDir, 'no-note-task.yaml'), 'task: Do something\n');
|
||||
|
||||
const task: TaskListItem = {
|
||||
kind: 'failed',
|
||||
name: 'no-note-task',
|
||||
createdAt: '2025-01-15T12:34:56',
|
||||
filePath: failedDir,
|
||||
content: 'Do something',
|
||||
};
|
||||
|
||||
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' });
|
||||
mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig);
|
||||
mockSelectOption.mockResolvedValue('implement');
|
||||
mockPromptInput.mockResolvedValue(''); // Empty string - user skipped
|
||||
|
||||
// When
|
||||
const result = await retryFailedTask(task, tmpDir);
|
||||
|
||||
// Then
|
||||
expect(result).toBe(true);
|
||||
|
||||
const requeuedFile = path.join(tasksDir, 'no-note-task.yaml');
|
||||
const content = fs.readFileSync(requeuedFile, 'utf-8');
|
||||
expect(content).toContain('start_movement: implement');
|
||||
expect(content).not.toContain('retry_note');
|
||||
expect(result).toBe(false);
|
||||
expect(mockLogError).toHaveBeenCalledWith(
|
||||
'Piece "default" not found. Cannot determine available movements.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
89
src/__tests__/watchTasks.test.ts
Normal file
89
src/__tests__/watchTasks.test.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { TaskInfo } from '../infra/task/index.js';
|
||||
|
||||
const {
|
||||
mockRecoverInterruptedRunningTasks,
|
||||
mockGetTasksDir,
|
||||
mockWatch,
|
||||
mockStop,
|
||||
mockExecuteAndCompleteTask,
|
||||
mockInfo,
|
||||
mockHeader,
|
||||
mockBlankLine,
|
||||
mockStatus,
|
||||
mockSuccess,
|
||||
mockGetCurrentPiece,
|
||||
} = vi.hoisted(() => ({
|
||||
mockRecoverInterruptedRunningTasks: vi.fn(),
|
||||
mockGetTasksDir: vi.fn(),
|
||||
mockWatch: vi.fn(),
|
||||
mockStop: vi.fn(),
|
||||
mockExecuteAndCompleteTask: vi.fn(),
|
||||
mockInfo: vi.fn(),
|
||||
mockHeader: vi.fn(),
|
||||
mockBlankLine: vi.fn(),
|
||||
mockStatus: vi.fn(),
|
||||
mockSuccess: vi.fn(),
|
||||
mockGetCurrentPiece: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../infra/task/index.js', () => ({
|
||||
TaskRunner: vi.fn().mockImplementation(() => ({
|
||||
recoverInterruptedRunningTasks: mockRecoverInterruptedRunningTasks,
|
||||
getTasksDir: mockGetTasksDir,
|
||||
})),
|
||||
TaskWatcher: vi.fn().mockImplementation(() => ({
|
||||
watch: mockWatch,
|
||||
stop: mockStop,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../features/tasks/execute/taskExecution.js', () => ({
|
||||
executeAndCompleteTask: mockExecuteAndCompleteTask,
|
||||
}));
|
||||
|
||||
vi.mock('../shared/ui/index.js', () => ({
|
||||
header: mockHeader,
|
||||
info: mockInfo,
|
||||
success: mockSuccess,
|
||||
status: mockStatus,
|
||||
blankLine: mockBlankLine,
|
||||
}));
|
||||
|
||||
vi.mock('../infra/config/index.js', () => ({
|
||||
getCurrentPiece: mockGetCurrentPiece,
|
||||
}));
|
||||
|
||||
import { watchTasks } from '../features/tasks/watch/index.js';
|
||||
|
||||
describe('watchTasks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetCurrentPiece.mockReturnValue('default');
|
||||
mockRecoverInterruptedRunningTasks.mockReturnValue(0);
|
||||
mockGetTasksDir.mockReturnValue('/project/.takt/tasks.yaml');
|
||||
mockExecuteAndCompleteTask.mockResolvedValue(true);
|
||||
|
||||
mockWatch.mockImplementation(async (onTask: (task: TaskInfo) => Promise<void>) => {
|
||||
await onTask({
|
||||
name: 'task-1',
|
||||
content: 'Task 1',
|
||||
filePath: '/project/.takt/tasks.yaml',
|
||||
createdAt: '2026-02-09T00:00:00.000Z',
|
||||
status: 'running',
|
||||
data: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('watch開始時に中断されたrunningタスクをpendingへ復旧する', async () => {
|
||||
mockRecoverInterruptedRunningTasks.mockReturnValue(1);
|
||||
|
||||
await watchTasks('/project');
|
||||
|
||||
expect(mockRecoverInterruptedRunningTasks).toHaveBeenCalledTimes(1);
|
||||
expect(mockInfo).toHaveBeenCalledWith('Recovered 1 interrupted running task(s) to pending.');
|
||||
expect(mockWatch).toHaveBeenCalledTimes(1);
|
||||
expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@ -1,24 +1,26 @@
|
||||
/**
|
||||
* TaskWatcher tests
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { stringify as stringifyYaml } from 'yaml';
|
||||
import { TaskWatcher } from '../infra/task/watcher.js';
|
||||
import { TaskRunner } from '../infra/task/runner.js';
|
||||
import type { TaskInfo } from '../infra/task/types.js';
|
||||
|
||||
describe('TaskWatcher', () => {
|
||||
const testDir = `/tmp/takt-watcher-test-${Date.now()}`;
|
||||
let watcher: TaskWatcher | null = null;
|
||||
|
||||
function writeTasksYaml(tasks: Array<Record<string, unknown>>): void {
|
||||
const tasksFile = join(testDir, '.takt', 'tasks.yaml');
|
||||
mkdirSync(join(testDir, '.takt'), { recursive: true });
|
||||
writeFileSync(tasksFile, stringifyYaml({ tasks }), 'utf-8');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(join(testDir, '.takt', 'tasks'), { recursive: true });
|
||||
mkdirSync(join(testDir, '.takt', 'completed'), { recursive: true });
|
||||
mkdirSync(join(testDir, '.takt'), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Ensure watcher is stopped before cleanup
|
||||
if (watcher) {
|
||||
watcher.stop();
|
||||
watcher = null;
|
||||
@ -41,21 +43,24 @@ describe('TaskWatcher', () => {
|
||||
});
|
||||
|
||||
describe('watch', () => {
|
||||
it('should detect and process a task file', async () => {
|
||||
it('should detect and process a pending task from tasks.yaml', async () => {
|
||||
writeTasksYaml([
|
||||
{
|
||||
name: 'test-task',
|
||||
status: 'pending',
|
||||
content: 'Test task content',
|
||||
created_at: '2026-02-09T00:00:00.000Z',
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
},
|
||||
]);
|
||||
|
||||
watcher = new TaskWatcher(testDir, { pollInterval: 50 });
|
||||
const processed: string[] = [];
|
||||
|
||||
// Pre-create a task file
|
||||
writeFileSync(
|
||||
join(testDir, '.takt', 'tasks', 'test-task.md'),
|
||||
'Test task content'
|
||||
);
|
||||
|
||||
// Start watching, stop after first task
|
||||
const watchPromise = watcher.watch(async (task: TaskInfo) => {
|
||||
processed.push(task.name);
|
||||
// Stop after processing to avoid infinite loop in test
|
||||
watcher.stop();
|
||||
watcher?.stop();
|
||||
});
|
||||
|
||||
await watchPromise;
|
||||
@ -64,48 +69,61 @@ describe('TaskWatcher', () => {
|
||||
expect(watcher.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it('should wait when no tasks are available', async () => {
|
||||
it('should wait when no tasks are available and then process added task', async () => {
|
||||
writeTasksYaml([]);
|
||||
watcher = new TaskWatcher(testDir, { pollInterval: 50 });
|
||||
let pollCount = 0;
|
||||
const runner = new TaskRunner(testDir);
|
||||
let processed = 0;
|
||||
|
||||
// Start watching, add a task after a delay
|
||||
const watchPromise = watcher.watch(async (task: TaskInfo) => {
|
||||
pollCount++;
|
||||
watcher.stop();
|
||||
const watchPromise = watcher.watch(async () => {
|
||||
processed++;
|
||||
watcher?.stop();
|
||||
});
|
||||
|
||||
// Add task after short delay (after at least one empty poll)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
writeFileSync(
|
||||
join(testDir, '.takt', 'tasks', 'delayed-task.md'),
|
||||
'Delayed task'
|
||||
);
|
||||
runner.addTask('Delayed task');
|
||||
|
||||
await watchPromise;
|
||||
|
||||
expect(pollCount).toBe(1);
|
||||
expect(processed).toBe(1);
|
||||
});
|
||||
|
||||
it('should process multiple tasks sequentially', async () => {
|
||||
writeTasksYaml([
|
||||
{
|
||||
name: 'a-task',
|
||||
status: 'pending',
|
||||
content: 'First task',
|
||||
created_at: '2026-02-09T00:00:00.000Z',
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
},
|
||||
{
|
||||
name: 'b-task',
|
||||
status: 'pending',
|
||||
content: 'Second task',
|
||||
created_at: '2026-02-09T00:01:00.000Z',
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const runner = new TaskRunner(testDir);
|
||||
watcher = new TaskWatcher(testDir, { pollInterval: 50 });
|
||||
const processed: string[] = [];
|
||||
|
||||
// Pre-create two task files
|
||||
writeFileSync(
|
||||
join(testDir, '.takt', 'tasks', 'a-task.md'),
|
||||
'First task'
|
||||
);
|
||||
writeFileSync(
|
||||
join(testDir, '.takt', 'tasks', 'b-task.md'),
|
||||
'Second task'
|
||||
);
|
||||
|
||||
const watchPromise = watcher.watch(async (task: TaskInfo) => {
|
||||
processed.push(task.name);
|
||||
// Remove the task file to simulate completion
|
||||
rmSync(task.filePath);
|
||||
runner.completeTask({
|
||||
task,
|
||||
success: true,
|
||||
response: 'Done',
|
||||
executionLog: [],
|
||||
startedAt: new Date().toISOString(),
|
||||
completedAt: new Date().toISOString(),
|
||||
});
|
||||
if (processed.length >= 2) {
|
||||
watcher.stop();
|
||||
watcher?.stop();
|
||||
}
|
||||
});
|
||||
|
||||
@ -117,15 +135,13 @@ describe('TaskWatcher', () => {
|
||||
|
||||
describe('stop', () => {
|
||||
it('should stop the watcher gracefully', async () => {
|
||||
writeTasksYaml([]);
|
||||
watcher = new TaskWatcher(testDir, { pollInterval: 50 });
|
||||
|
||||
// Start watching, stop after a short delay
|
||||
const watchPromise = watcher.watch(async () => {
|
||||
// Should not be called since no tasks
|
||||
});
|
||||
|
||||
// Stop after short delay
|
||||
setTimeout(() => watcher.stop(), 100);
|
||||
setTimeout(() => watcher?.stop(), 100);
|
||||
|
||||
await watchPromise;
|
||||
|
||||
@ -133,18 +149,17 @@ describe('TaskWatcher', () => {
|
||||
});
|
||||
|
||||
it('should abort sleep immediately when stopped', async () => {
|
||||
writeTasksYaml([]);
|
||||
watcher = new TaskWatcher(testDir, { pollInterval: 10000 });
|
||||
|
||||
const start = Date.now();
|
||||
const watchPromise = watcher.watch(async () => {});
|
||||
|
||||
// Stop after 50ms, should not wait the full 10s
|
||||
setTimeout(() => watcher.stop(), 50);
|
||||
setTimeout(() => watcher?.stop(), 50);
|
||||
|
||||
await watchPromise;
|
||||
|
||||
const elapsed = Date.now() - start;
|
||||
// Should complete well under the 10s poll interval
|
||||
expect(elapsed).toBeLessThan(1000);
|
||||
});
|
||||
});
|
||||
|
||||
@ -2,15 +2,13 @@
|
||||
* add command implementation
|
||||
*
|
||||
* Starts an AI conversation to refine task requirements,
|
||||
* then creates a task file in .takt/tasks/ with YAML format.
|
||||
* then appends a task record to .takt/tasks.yaml.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { stringify as stringifyYaml } from 'yaml';
|
||||
import { promptInput, confirm } from '../../../shared/prompt/index.js';
|
||||
import { success, info, error } from '../../../shared/ui/index.js';
|
||||
import { summarizeTaskName, type TaskFileData } from '../../../infra/task/index.js';
|
||||
import { TaskRunner, type TaskFileData } from '../../../infra/task/index.js';
|
||||
import { getPieceDescription, loadGlobalConfig } from '../../../infra/config/index.js';
|
||||
import { determinePiece } from '../execute/selectAndExecute.js';
|
||||
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
|
||||
@ -19,23 +17,8 @@ import { interactiveMode } from '../../interactive/index.js';
|
||||
|
||||
const log = createLogger('add-task');
|
||||
|
||||
async function generateFilename(tasksDir: string, taskContent: string, cwd: string): Promise<string> {
|
||||
info('Generating task filename...');
|
||||
const slug = await summarizeTaskName(taskContent, { cwd });
|
||||
const base = slug || 'task';
|
||||
let filename = `${base}.yaml`;
|
||||
let counter = 1;
|
||||
|
||||
while (fs.existsSync(path.join(tasksDir, filename))) {
|
||||
filename = `${base}-${counter}.yaml`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
return filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a task file to .takt/tasks/ with YAML format.
|
||||
* Save a task entry to .takt/tasks.yaml.
|
||||
*
|
||||
* Common logic extracted from addTask(). Used by both addTask()
|
||||
* and saveTaskFromInteractive().
|
||||
@ -44,29 +27,19 @@ export async function saveTaskFile(
|
||||
cwd: string,
|
||||
taskContent: string,
|
||||
options?: { piece?: string; issue?: number; worktree?: boolean | string; branch?: string; autoPr?: boolean },
|
||||
): Promise<string> {
|
||||
const tasksDir = path.join(cwd, '.takt', 'tasks');
|
||||
fs.mkdirSync(tasksDir, { recursive: true });
|
||||
|
||||
const firstLine = taskContent.split('\n')[0] || taskContent;
|
||||
const filename = await generateFilename(tasksDir, firstLine, cwd);
|
||||
|
||||
const taskData: TaskFileData = {
|
||||
task: taskContent,
|
||||
): Promise<{ taskName: string; tasksFile: string }> {
|
||||
const runner = new TaskRunner(cwd);
|
||||
const config: Omit<TaskFileData, 'task'> = {
|
||||
...(options?.worktree !== undefined && { worktree: options.worktree }),
|
||||
...(options?.branch && { branch: options.branch }),
|
||||
...(options?.piece && { piece: options.piece }),
|
||||
...(options?.issue !== undefined && { issue: options.issue }),
|
||||
...(options?.autoPr !== undefined && { auto_pr: options.autoPr }),
|
||||
};
|
||||
|
||||
const filePath = path.join(tasksDir, filename);
|
||||
const yamlContent = stringifyYaml(taskData);
|
||||
fs.writeFileSync(filePath, yamlContent, 'utf-8');
|
||||
|
||||
log.info('Task created', { filePath, taskData });
|
||||
|
||||
return filePath;
|
||||
const created = runner.addTask(taskContent, config);
|
||||
const tasksFile = path.join(cwd, '.takt', 'tasks.yaml');
|
||||
log.info('Task created', { taskName: created.name, tasksFile, config });
|
||||
return { taskName: created.name, tasksFile };
|
||||
}
|
||||
|
||||
/**
|
||||
@ -120,10 +93,9 @@ export async function saveTaskFromInteractive(
|
||||
piece?: string,
|
||||
): Promise<void> {
|
||||
const settings = await promptWorktreeSettings();
|
||||
const filePath = await saveTaskFile(cwd, task, { piece, ...settings });
|
||||
const filename = path.basename(filePath);
|
||||
success(`Task created: ${filename}`);
|
||||
info(` Path: ${filePath}`);
|
||||
const created = await saveTaskFile(cwd, task, { piece, ...settings });
|
||||
success(`Task created: ${created.taskName}`);
|
||||
info(` File: ${created.tasksFile}`);
|
||||
if (settings.worktree) {
|
||||
info(` Worktree: ${typeof settings.worktree === 'string' ? settings.worktree : 'auto'}`);
|
||||
}
|
||||
@ -144,9 +116,6 @@ export async function saveTaskFromInteractive(
|
||||
* B) それ以外: ピース選択 → AI対話モード → ワークツリー設定 → YAML作成
|
||||
*/
|
||||
export async function addTask(cwd: string, task?: string): Promise<void> {
|
||||
const tasksDir = path.join(cwd, '.takt', 'tasks');
|
||||
fs.mkdirSync(tasksDir, { recursive: true });
|
||||
|
||||
// ピース選択とタスク内容の決定
|
||||
let taskContent: string;
|
||||
let issueNumber: number | undefined;
|
||||
@ -209,15 +178,14 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
|
||||
const settings = await promptWorktreeSettings();
|
||||
|
||||
// YAMLファイル作成
|
||||
const filePath = await saveTaskFile(cwd, taskContent, {
|
||||
const created = await saveTaskFile(cwd, taskContent, {
|
||||
piece,
|
||||
issue: issueNumber,
|
||||
...settings,
|
||||
});
|
||||
|
||||
const filename = path.basename(filePath);
|
||||
success(`Task created: ${filename}`);
|
||||
info(` Path: ${filePath}`);
|
||||
success(`Task created: ${created.taskName}`);
|
||||
info(` File: ${created.tasksFile}`);
|
||||
if (settings.worktree) {
|
||||
info(` Worktree: ${typeof settings.worktree === 'string' ? settings.worktree : 'auto'}`);
|
||||
}
|
||||
|
||||
@ -647,6 +647,8 @@ export async function executePiece(
|
||||
return {
|
||||
success: finalState.status === 'completed',
|
||||
reason: abortReason,
|
||||
lastMovement: lastMovementName,
|
||||
lastMessage: lastMovementContent,
|
||||
};
|
||||
} finally {
|
||||
prefixWriter?.flush();
|
||||
|
||||
@ -29,7 +29,6 @@ export async function resolveTaskExecution(
|
||||
defaultPiece: string,
|
||||
): Promise<ResolvedTaskExecution> {
|
||||
const data = task.data;
|
||||
|
||||
if (!data) {
|
||||
return { execCwd: defaultCwd, execPiece: defaultPiece, isWorktree: false };
|
||||
}
|
||||
|
||||
@ -15,7 +15,7 @@ import {
|
||||
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
|
||||
import { executePiece } from './pieceExecution.js';
|
||||
import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js';
|
||||
import type { TaskExecutionOptions, ExecuteTaskOptions } from './types.js';
|
||||
import type { TaskExecutionOptions, ExecuteTaskOptions, PieceExecutionResult } from './types.js';
|
||||
import { createPullRequest, buildPrBody, pushBranch, fetchIssue, checkGhCli } from '../../../infra/github/index.js';
|
||||
import { runWithWorkerPool } from './parallelExecution.js';
|
||||
import { resolveTaskExecution } from './resolveTask.js';
|
||||
@ -48,22 +48,20 @@ function resolveTaskIssue(issueNumber: number | undefined): ReturnType<typeof fe
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single task with piece.
|
||||
*/
|
||||
export async function executeTask(options: ExecuteTaskOptions): Promise<boolean> {
|
||||
async function executeTaskWithResult(options: ExecuteTaskOptions): Promise<PieceExecutionResult> {
|
||||
const { task, cwd, pieceIdentifier, projectCwd, agentOverrides, interactiveUserInput, interactiveMetadata, startMovement, retryNote, abortSignal, taskPrefix, taskColorIndex } = options;
|
||||
const pieceConfig = loadPieceByIdentifier(pieceIdentifier, projectCwd);
|
||||
|
||||
if (!pieceConfig) {
|
||||
if (isPiecePath(pieceIdentifier)) {
|
||||
error(`Piece file not found: ${pieceIdentifier}`);
|
||||
return { success: false, reason: `Piece file not found: ${pieceIdentifier}` };
|
||||
} else {
|
||||
error(`Piece "${pieceIdentifier}" not found.`);
|
||||
info('Available pieces are in ~/.takt/pieces/ or .takt/pieces/');
|
||||
info('Use "takt switch" to select a piece.');
|
||||
return { success: false, reason: `Piece "${pieceIdentifier}" not found.` };
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
log.debug('Running piece', {
|
||||
@ -72,7 +70,7 @@ export async function executeTask(options: ExecuteTaskOptions): Promise<boolean>
|
||||
});
|
||||
|
||||
const globalConfig = loadGlobalConfig();
|
||||
const result = await executePiece(pieceConfig, task, cwd, {
|
||||
return await executePiece(pieceConfig, task, cwd, {
|
||||
projectCwd,
|
||||
language: globalConfig.language,
|
||||
provider: agentOverrides?.provider,
|
||||
@ -86,6 +84,13 @@ export async function executeTask(options: ExecuteTaskOptions): Promise<boolean>
|
||||
taskPrefix,
|
||||
taskColorIndex,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single task with piece.
|
||||
*/
|
||||
export async function executeTask(options: ExecuteTaskOptions): Promise<boolean> {
|
||||
const result = await executeTaskWithResult(options);
|
||||
return result.success;
|
||||
}
|
||||
|
||||
@ -106,7 +111,6 @@ export async function executeAndCompleteTask(
|
||||
parallelOptions?: { abortSignal?: AbortSignal; taskPrefix?: string; taskColorIndex?: number },
|
||||
): Promise<boolean> {
|
||||
const startedAt = new Date().toISOString();
|
||||
const executionLog: string[] = [];
|
||||
const taskAbortController = new AbortController();
|
||||
const externalAbortSignal = parallelOptions?.abortSignal;
|
||||
const taskAbortSignal = externalAbortSignal ? taskAbortController.signal : undefined;
|
||||
@ -127,7 +131,7 @@ export async function executeAndCompleteTask(
|
||||
const { execCwd, execPiece, isWorktree, branch, baseBranch, startMovement, retryNote, autoPr, issueNumber } = await resolveTaskExecution(task, cwd, pieceName);
|
||||
|
||||
// cwd is always the project root; pass it as projectCwd so reports/sessions go there
|
||||
const taskRunPromise = executeTask({
|
||||
const taskRunResult = await executeTaskWithResult({
|
||||
task: task.content,
|
||||
cwd: execCwd,
|
||||
pieceIdentifier: execPiece,
|
||||
@ -140,7 +144,11 @@ export async function executeAndCompleteTask(
|
||||
taskColorIndex: parallelOptions?.taskColorIndex,
|
||||
});
|
||||
|
||||
const taskSuccess = await taskRunPromise;
|
||||
if (!taskRunResult.success && !taskRunResult.reason) {
|
||||
throw new Error('Task failed without reason');
|
||||
}
|
||||
|
||||
const taskSuccess = taskRunResult.success;
|
||||
const completedAt = new Date().toISOString();
|
||||
|
||||
if (taskSuccess && isWorktree) {
|
||||
@ -180,8 +188,10 @@ export async function executeAndCompleteTask(
|
||||
const taskResult = {
|
||||
task,
|
||||
success: taskSuccess,
|
||||
response: taskSuccess ? 'Task completed successfully' : 'Task failed',
|
||||
executionLog,
|
||||
response: taskSuccess ? 'Task completed successfully' : taskRunResult.reason!,
|
||||
executionLog: taskRunResult.lastMessage ? [taskRunResult.lastMessage] : [],
|
||||
failureMovement: taskRunResult.lastMovement,
|
||||
failureLastMessage: taskRunResult.lastMessage,
|
||||
startedAt,
|
||||
completedAt,
|
||||
};
|
||||
@ -202,7 +212,7 @@ export async function executeAndCompleteTask(
|
||||
task,
|
||||
success: false,
|
||||
response: getErrorMessage(err),
|
||||
executionLog,
|
||||
executionLog: [],
|
||||
startedAt,
|
||||
completedAt,
|
||||
});
|
||||
@ -230,12 +240,16 @@ export async function runAllTasks(
|
||||
const taskRunner = new TaskRunner(cwd);
|
||||
const globalConfig = loadGlobalConfig();
|
||||
const concurrency = globalConfig.concurrency;
|
||||
const recovered = taskRunner.recoverInterruptedRunningTasks();
|
||||
if (recovered > 0) {
|
||||
info(`Recovered ${recovered} interrupted running task(s) to pending.`);
|
||||
}
|
||||
|
||||
const initialTasks = taskRunner.claimNextTasks(concurrency);
|
||||
|
||||
if (initialTasks.length === 0) {
|
||||
info('No pending tasks in .takt/tasks/');
|
||||
info('Create task files as .takt/tasks/*.yaml or use takt add');
|
||||
info('No pending tasks in .takt/tasks.yaml');
|
||||
info('Use takt add to append tasks.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -10,6 +10,8 @@ import type { GitHubIssue } from '../../../infra/github/index.js';
|
||||
export interface PieceExecutionResult {
|
||||
success: boolean;
|
||||
reason?: string;
|
||||
lastMovement?: string;
|
||||
lastMessage?: string;
|
||||
}
|
||||
|
||||
/** Metadata from interactive mode, passed through to NDJSON logging */
|
||||
|
||||
@ -37,16 +37,21 @@ export type ListAction = 'diff' | 'instruct' | 'try' | 'merge' | 'delete';
|
||||
* Check if a branch has already been merged into HEAD.
|
||||
*/
|
||||
export function isBranchMerged(projectDir: string, branch: string): boolean {
|
||||
try {
|
||||
execFileSync('git', ['merge-base', '--is-ancestor', branch, 'HEAD'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
const result = spawnSync('git', ['merge-base', '--is-ancestor', branch, 'HEAD'], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
log.error('Failed to check if branch is merged', {
|
||||
branch,
|
||||
error: getErrorMessage(result.error),
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -70,8 +75,13 @@ export function showFullDiff(
|
||||
if (result.status !== 0) {
|
||||
warn('Could not display diff');
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
warn('Could not display diff');
|
||||
log.error('Failed to display full diff', {
|
||||
branch,
|
||||
defaultBranch,
|
||||
error: getErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -95,8 +105,13 @@ export async function showDiffAndPromptAction(
|
||||
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
|
||||
);
|
||||
info(stat);
|
||||
} catch {
|
||||
} catch (err) {
|
||||
warn('Could not generate diff stat');
|
||||
log.error('Failed to generate diff stat', {
|
||||
branch: item.info.branch,
|
||||
defaultBranch,
|
||||
error: getErrorMessage(err),
|
||||
});
|
||||
}
|
||||
|
||||
const action = await selectOption<ListAction>(
|
||||
@ -168,8 +183,12 @@ export function mergeBranch(projectDir: string, item: BranchListItem): boolean {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
});
|
||||
} catch {
|
||||
} catch (err) {
|
||||
warn(`Could not delete branch ${branch}. You may delete it manually.`);
|
||||
log.error('Failed to delete merged branch', {
|
||||
branch,
|
||||
error: getErrorMessage(err),
|
||||
});
|
||||
}
|
||||
|
||||
cleanupOrphanedClone(projectDir, branch);
|
||||
@ -276,8 +295,12 @@ function getBranchContext(projectDir: string, branch: string): string {
|
||||
lines.push(diffStat);
|
||||
lines.push('```');
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
} catch (err) {
|
||||
log.debug('Failed to collect branch diff stat for instruction context', {
|
||||
branch,
|
||||
defaultBranch,
|
||||
error: getErrorMessage(err),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
@ -292,8 +315,12 @@ function getBranchContext(projectDir: string, branch: string): string {
|
||||
lines.push(commitLog);
|
||||
lines.push('```');
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
} catch (err) {
|
||||
log.debug('Failed to collect branch commit log for instruction context', {
|
||||
branch,
|
||||
defaultBranch,
|
||||
error: getErrorMessage(err),
|
||||
});
|
||||
}
|
||||
|
||||
return lines.length > 0 ? lines.join('\n') + '\n\n' : '';
|
||||
@ -361,4 +388,3 @@ export async function instructBranch(
|
||||
removeCloneMeta(projectDir, branch);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,17 +2,22 @@
|
||||
* Delete actions for pending and failed tasks.
|
||||
*
|
||||
* Provides interactive deletion (with confirm prompt)
|
||||
* for pending task files and failed task directories.
|
||||
* for pending/failed tasks in .takt/tasks.yaml.
|
||||
*/
|
||||
|
||||
import { rmSync, unlinkSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import type { TaskListItem } from '../../../infra/task/index.js';
|
||||
import { TaskRunner } from '../../../infra/task/index.js';
|
||||
import { confirm } from '../../../shared/prompt/index.js';
|
||||
import { success, error as logError } from '../../../shared/ui/index.js';
|
||||
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
|
||||
|
||||
const log = createLogger('list-tasks');
|
||||
|
||||
function getProjectDir(task: TaskListItem): string {
|
||||
return dirname(dirname(task.filePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a pending task file.
|
||||
* Prompts user for confirmation first.
|
||||
@ -21,7 +26,8 @@ export async function deletePendingTask(task: TaskListItem): Promise<boolean> {
|
||||
const confirmed = await confirm(`Delete pending task "${task.name}"?`, false);
|
||||
if (!confirmed) return false;
|
||||
try {
|
||||
unlinkSync(task.filePath);
|
||||
const runner = new TaskRunner(getProjectDir(task));
|
||||
runner.deletePendingTask(task.name);
|
||||
} catch (err) {
|
||||
const msg = getErrorMessage(err);
|
||||
logError(`Failed to delete pending task "${task.name}": ${msg}`);
|
||||
@ -38,10 +44,11 @@ export async function deletePendingTask(task: TaskListItem): Promise<boolean> {
|
||||
* Prompts user for confirmation first.
|
||||
*/
|
||||
export async function deleteFailedTask(task: TaskListItem): Promise<boolean> {
|
||||
const confirmed = await confirm(`Delete failed task "${task.name}" and its logs?`, false);
|
||||
const confirmed = await confirm(`Delete failed task "${task.name}"?`, false);
|
||||
if (!confirmed) return false;
|
||||
try {
|
||||
rmSync(task.filePath, { recursive: true });
|
||||
const runner = new TaskRunner(getProjectDir(task));
|
||||
runner.deleteFailedTask(task.name);
|
||||
} catch (err) {
|
||||
const msg = getErrorMessage(err);
|
||||
logError(`Failed to delete failed task "${task.name}": ${msg}`);
|
||||
|
||||
@ -5,11 +5,8 @@
|
||||
* failure info display and movement selection.
|
||||
*/
|
||||
|
||||
import { join } from 'node:path';
|
||||
import { existsSync, readdirSync } from 'node:fs';
|
||||
import type { TaskListItem } from '../../../infra/task/index.js';
|
||||
import { TaskRunner, parseTaskFile, type TaskFileData } from '../../../infra/task/index.js';
|
||||
import { extractFailureInfo, type FailureInfo } from '../../../infra/fs/session.js';
|
||||
import { TaskRunner } from '../../../infra/task/index.js';
|
||||
import { loadPieceByIdentifier, loadGlobalConfig } from '../../../infra/config/index.js';
|
||||
import { selectOption, promptInput } from '../../../shared/prompt/index.js';
|
||||
import { success, error as logError, info, header, blankLine, status } from '../../../shared/ui/index.js';
|
||||
@ -18,121 +15,30 @@ import type { PieceConfig } from '../../../core/models/index.js';
|
||||
|
||||
const log = createLogger('list-tasks');
|
||||
|
||||
/**
|
||||
* Find the session log file path from a failed task directory.
|
||||
* Looks in .takt/logs/ for a matching session ID from log.json.
|
||||
*/
|
||||
function findSessionLogPath(failedTaskDir: string, projectDir: string): string | null {
|
||||
const logsDir = join(projectDir, '.takt', 'logs');
|
||||
if (!existsSync(logsDir)) return null;
|
||||
|
||||
// Try to find the log file
|
||||
// Failed tasks don't have sessionId in log.json by default,
|
||||
// so we look for the most recent log file that matches the failure time
|
||||
const logJsonPath = join(failedTaskDir, 'log.json');
|
||||
if (!existsSync(logJsonPath)) return null;
|
||||
|
||||
try {
|
||||
// List all .jsonl files in logs dir
|
||||
const logFiles = readdirSync(logsDir).filter((f) => f.endsWith('.jsonl'));
|
||||
if (logFiles.length === 0) return null;
|
||||
|
||||
// Get the failed task timestamp from directory name
|
||||
const dirName = failedTaskDir.split('/').pop();
|
||||
if (!dirName) return null;
|
||||
const underscoreIdx = dirName.indexOf('_');
|
||||
if (underscoreIdx === -1) return null;
|
||||
const timestampRaw = dirName.slice(0, underscoreIdx);
|
||||
// Convert format: 2026-01-31T12-00-00 -> 20260131-120000
|
||||
const normalizedTimestamp = timestampRaw
|
||||
.replace(/-/g, '')
|
||||
.replace('T', '-');
|
||||
|
||||
// Find logs that match the date (first 8 chars of normalized timestamp)
|
||||
const datePrefix = normalizedTimestamp.slice(0, 8);
|
||||
const matchingLogs = logFiles
|
||||
.filter((f) => f.startsWith(datePrefix))
|
||||
.sort()
|
||||
.reverse(); // Most recent first
|
||||
|
||||
// Return the most recent matching log
|
||||
if (matchingLogs.length > 0) {
|
||||
return join(logsDir, matchingLogs[0]!);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find and parse the task file from a failed task directory.
|
||||
* Returns the parsed TaskFileData if found, null otherwise.
|
||||
*/
|
||||
function parseFailedTaskFile(failedTaskDir: string): TaskFileData | null {
|
||||
const taskExtensions = ['.yaml', '.yml', '.md'];
|
||||
let files: string[];
|
||||
try {
|
||||
files = readdirSync(failedTaskDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const ext = file.slice(file.lastIndexOf('.'));
|
||||
if (file === 'report.md' || file === 'log.json') continue;
|
||||
if (!taskExtensions.includes(ext)) continue;
|
||||
|
||||
try {
|
||||
const taskFilePath = join(failedTaskDir, file);
|
||||
const parsed = parseTaskFile(taskFilePath);
|
||||
return parsed.data;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display failure information for a failed task.
|
||||
*/
|
||||
function displayFailureInfo(task: TaskListItem, failureInfo: FailureInfo | null): void {
|
||||
function displayFailureInfo(task: TaskListItem): void {
|
||||
header(`Failed Task: ${task.name}`);
|
||||
info(` Failed at: ${task.createdAt}`);
|
||||
|
||||
if (failureInfo) {
|
||||
if (task.failure) {
|
||||
blankLine();
|
||||
if (failureInfo.lastCompletedMovement) {
|
||||
status('Last completed', failureInfo.lastCompletedMovement);
|
||||
if (task.failure.movement) {
|
||||
status('Failed at', task.failure.movement, 'red');
|
||||
}
|
||||
if (failureInfo.failedMovement) {
|
||||
status('Failed at', failureInfo.failedMovement, 'red');
|
||||
status('Error', task.failure.error, 'red');
|
||||
if (task.failure.last_message) {
|
||||
status('Last message', task.failure.last_message);
|
||||
}
|
||||
status('Iterations', String(failureInfo.iterations));
|
||||
if (failureInfo.errorMessage) {
|
||||
status('Error', failureInfo.errorMessage, 'red');
|
||||
}
|
||||
} else {
|
||||
blankLine();
|
||||
info(' (No session log found - failure details unavailable)');
|
||||
}
|
||||
|
||||
blankLine();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt user to select a movement to start from.
|
||||
* Returns the selected movement name, or null if cancelled.
|
||||
*/
|
||||
async function selectStartMovement(
|
||||
pieceConfig: PieceConfig,
|
||||
defaultMovement: string | null,
|
||||
): Promise<string | null> {
|
||||
const movements = pieceConfig.movements.map((m) => m.name);
|
||||
|
||||
// Determine default selection
|
||||
const defaultIdx = defaultMovement
|
||||
? movements.indexOf(defaultMovement)
|
||||
: 0;
|
||||
@ -149,7 +55,6 @@ async function selectStartMovement(
|
||||
|
||||
/**
|
||||
* Retry a failed task.
|
||||
* Shows failure info, prompts for movement selection, and requeues the task.
|
||||
*
|
||||
* @returns true if task was requeued, false if cancelled
|
||||
*/
|
||||
@ -157,19 +62,9 @@ export async function retryFailedTask(
|
||||
task: TaskListItem,
|
||||
projectDir: string,
|
||||
): Promise<boolean> {
|
||||
// Find session log and extract failure info
|
||||
const sessionLogPath = findSessionLogPath(task.filePath, projectDir);
|
||||
const failureInfo = sessionLogPath ? extractFailureInfo(sessionLogPath) : null;
|
||||
displayFailureInfo(task);
|
||||
|
||||
// Display failure information
|
||||
displayFailureInfo(task, failureInfo);
|
||||
|
||||
// Parse the failed task file to get the piece field
|
||||
const taskFileData = parseFailedTaskFile(task.filePath);
|
||||
|
||||
// Determine piece name: task file -> global config -> 'default'
|
||||
const globalConfig = loadGlobalConfig();
|
||||
const pieceName = taskFileData?.piece ?? globalConfig.defaultPiece ?? 'default';
|
||||
const pieceName = task.data?.piece ?? loadGlobalConfig().defaultPiece ?? 'default';
|
||||
const pieceConfig = loadPieceByIdentifier(pieceName, projectDir);
|
||||
|
||||
if (!pieceConfig) {
|
||||
@ -177,42 +72,22 @@ export async function retryFailedTask(
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prompt for movement selection
|
||||
// Default to failed movement, or last completed + 1, or initial movement
|
||||
let defaultMovement: string | null = null;
|
||||
if (failureInfo?.failedMovement) {
|
||||
defaultMovement = failureInfo.failedMovement;
|
||||
} else if (failureInfo?.lastCompletedMovement) {
|
||||
// Find the next movement after the last completed one
|
||||
const movements = pieceConfig.movements.map((m) => m.name);
|
||||
const lastIdx = movements.indexOf(failureInfo.lastCompletedMovement);
|
||||
if (lastIdx >= 0 && lastIdx < movements.length - 1) {
|
||||
defaultMovement = movements[lastIdx + 1] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
const selectedMovement = await selectStartMovement(pieceConfig, defaultMovement);
|
||||
const selectedMovement = await selectStartMovement(pieceConfig, task.failure?.movement ?? null);
|
||||
if (selectedMovement === null) {
|
||||
return false; // User cancelled
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prompt for retry note (optional)
|
||||
blankLine();
|
||||
const retryNote = await promptInput('Retry note (optional, press Enter to skip):');
|
||||
const trimmedNote = retryNote?.trim();
|
||||
|
||||
// Requeue the task
|
||||
try {
|
||||
const runner = new TaskRunner(projectDir);
|
||||
// Only pass startMovement if it's different from the initial movement
|
||||
const startMovement = selectedMovement !== pieceConfig.initialMovement
|
||||
? selectedMovement
|
||||
: undefined;
|
||||
const requeuedPath = runner.requeueFailedTask(
|
||||
task.filePath,
|
||||
startMovement,
|
||||
trimmedNote || undefined
|
||||
);
|
||||
|
||||
runner.requeueFailedTask(task.name, startMovement, trimmedNote || undefined);
|
||||
|
||||
success(`Task requeued: ${task.name}`);
|
||||
if (startMovement) {
|
||||
@ -221,12 +96,11 @@ export async function retryFailedTask(
|
||||
if (trimmedNote) {
|
||||
info(` Retry note: ${trimmedNote}`);
|
||||
}
|
||||
info(` Task file: ${requeuedPath}`);
|
||||
info(` File: ${task.filePath}`);
|
||||
|
||||
log.info('Requeued failed task', {
|
||||
name: task.name,
|
||||
from: task.filePath,
|
||||
to: requeuedPath,
|
||||
tasksFile: task.filePath,
|
||||
startMovement,
|
||||
retryNote: trimmedNote,
|
||||
});
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/**
|
||||
* /watch command implementation
|
||||
*
|
||||
* Watches .takt/tasks/ for new task files and executes them automatically.
|
||||
* Watches .takt/tasks.yaml for pending tasks and executes them automatically.
|
||||
* Stays resident until Ctrl+C (SIGINT).
|
||||
*/
|
||||
|
||||
@ -26,6 +26,7 @@ export async function watchTasks(cwd: string, options?: TaskExecutionOptions): P
|
||||
const pieceName = getCurrentPiece(cwd) || DEFAULT_PIECE_NAME;
|
||||
const taskRunner = new TaskRunner(cwd);
|
||||
const watcher = new TaskWatcher(cwd);
|
||||
const recovered = taskRunner.recoverInterruptedRunningTasks();
|
||||
|
||||
let taskCount = 0;
|
||||
let successCount = 0;
|
||||
@ -34,6 +35,9 @@ export async function watchTasks(cwd: string, options?: TaskExecutionOptions): P
|
||||
header('TAKT Watch Mode');
|
||||
info(`Piece: ${pieceName}`);
|
||||
info(`Watching: ${taskRunner.getTasksDir()}`);
|
||||
if (recovered > 0) {
|
||||
info(`Recovered ${recovered} interrupted running task(s) to pending.`);
|
||||
}
|
||||
info('Waiting for tasks... (Ctrl+C to stop)');
|
||||
blankLine();
|
||||
|
||||
|
||||
@ -24,8 +24,8 @@ export function showTaskList(runner: TaskRunner): void {
|
||||
if (tasks.length === 0) {
|
||||
console.log();
|
||||
info('実行待ちのタスクはありません。');
|
||||
console.log(chalk.gray(`\n${runner.getTasksDir()}/ にタスクファイル(.yaml/.md)を配置してください。`));
|
||||
console.log(chalk.gray(`または takt add でタスクを追加できます。`));
|
||||
console.log(chalk.gray(`\n${runner.getTasksDir()} を確認してください。`));
|
||||
console.log(chalk.gray('takt add でタスクを追加できます。'));
|
||||
return;
|
||||
}
|
||||
|
||||
@ -39,7 +39,6 @@ export function showTaskList(runner: TaskRunner): void {
|
||||
console.log(chalk.cyan.bold(` [${i + 1}] ${task.name}`));
|
||||
console.log(chalk.gray(` ${firstLine}...`));
|
||||
|
||||
// Show worktree/branch info for YAML tasks
|
||||
if (task.data) {
|
||||
const extras: string[] = [];
|
||||
if (task.data.worktree) {
|
||||
|
||||
@ -24,8 +24,19 @@ export { TaskRunner } from './runner.js';
|
||||
|
||||
export { showTaskList } from './display.js';
|
||||
|
||||
export { TaskFileSchema, type TaskFileData } from './schema.js';
|
||||
export { parseTaskFile, parseTaskFiles, type ParsedTask } from './parser.js';
|
||||
export {
|
||||
TaskFileSchema,
|
||||
type TaskFileData,
|
||||
TaskExecutionConfigSchema,
|
||||
TaskStatusSchema,
|
||||
type TaskStatus,
|
||||
TaskFailureSchema,
|
||||
type TaskFailure,
|
||||
TaskRecordSchema,
|
||||
type TaskRecord,
|
||||
TasksFileSchema,
|
||||
type TasksFileData,
|
||||
} from './schema.js';
|
||||
export {
|
||||
createSharedClone,
|
||||
removeClone,
|
||||
|
||||
79
src/infra/task/mapper.ts
Normal file
79
src/infra/task/mapper.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { TaskFileSchema, type TaskFileData, type TaskRecord } from './schema.js';
|
||||
import type { TaskInfo, TaskListItem } from './types.js';
|
||||
|
||||
function firstLine(content: string): string {
|
||||
return content.trim().split('\n')[0]?.slice(0, 80) ?? '';
|
||||
}
|
||||
|
||||
export function resolveTaskContent(projectDir: string, task: TaskRecord): string {
|
||||
if (task.content) {
|
||||
return task.content;
|
||||
}
|
||||
if (!task.content_file) {
|
||||
throw new Error(`Task content is missing: ${task.name}`);
|
||||
}
|
||||
|
||||
const contentPath = path.isAbsolute(task.content_file)
|
||||
? task.content_file
|
||||
: path.join(projectDir, task.content_file);
|
||||
return fs.readFileSync(contentPath, 'utf-8');
|
||||
}
|
||||
|
||||
export function toTaskData(projectDir: string, task: TaskRecord): TaskFileData {
|
||||
return TaskFileSchema.parse({
|
||||
task: resolveTaskContent(projectDir, task),
|
||||
worktree: task.worktree,
|
||||
branch: task.branch,
|
||||
piece: task.piece,
|
||||
issue: task.issue,
|
||||
start_movement: task.start_movement,
|
||||
retry_note: task.retry_note,
|
||||
auto_pr: task.auto_pr,
|
||||
});
|
||||
}
|
||||
|
||||
export function toTaskInfo(projectDir: string, tasksFile: string, task: TaskRecord): TaskInfo {
|
||||
const content = resolveTaskContent(projectDir, task);
|
||||
return {
|
||||
filePath: tasksFile,
|
||||
name: task.name,
|
||||
content,
|
||||
createdAt: task.created_at,
|
||||
status: task.status,
|
||||
data: TaskFileSchema.parse({
|
||||
task: content,
|
||||
worktree: task.worktree,
|
||||
branch: task.branch,
|
||||
piece: task.piece,
|
||||
issue: task.issue,
|
||||
start_movement: task.start_movement,
|
||||
retry_note: task.retry_note,
|
||||
auto_pr: task.auto_pr,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function toPendingTaskItem(projectDir: string, tasksFile: string, task: TaskRecord): TaskListItem {
|
||||
return {
|
||||
kind: 'pending',
|
||||
name: task.name,
|
||||
createdAt: task.created_at,
|
||||
filePath: tasksFile,
|
||||
content: firstLine(resolveTaskContent(projectDir, task)),
|
||||
data: toTaskData(projectDir, task),
|
||||
};
|
||||
}
|
||||
|
||||
export function toFailedTaskItem(projectDir: string, tasksFile: string, task: TaskRecord): TaskListItem {
|
||||
return {
|
||||
kind: 'failed',
|
||||
name: task.name,
|
||||
createdAt: task.completed_at ?? task.created_at,
|
||||
filePath: tasksFile,
|
||||
content: firstLine(resolveTaskContent(projectDir, task)),
|
||||
data: toTaskData(projectDir, task),
|
||||
failure: task.failure,
|
||||
};
|
||||
}
|
||||
22
src/infra/task/naming.ts
Normal file
22
src/infra/task/naming.ts
Normal file
@ -0,0 +1,22 @@
|
||||
export function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export function firstLine(content: string): string {
|
||||
return content.trim().split('\n')[0]?.slice(0, 80) ?? '';
|
||||
}
|
||||
|
||||
export function sanitizeTaskName(base: string): string {
|
||||
const normalized = base
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, ' ')
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-');
|
||||
|
||||
if (!normalized) {
|
||||
return `task-${Date.now()}`;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
@ -1,106 +0,0 @@
|
||||
/**
|
||||
* Task file parser
|
||||
*
|
||||
* Supports both YAML (.yaml/.yml) and Markdown (.md) task files.
|
||||
* YAML files are validated against TaskFileSchema.
|
||||
* Markdown files are treated as plain text.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import { TaskFileSchema, type TaskFileData } from './schema.js';
|
||||
|
||||
/** Supported task file extensions */
|
||||
const YAML_EXTENSIONS = ['.yaml', '.yml'];
|
||||
const MD_EXTENSIONS = ['.md'];
|
||||
export const TASK_EXTENSIONS = [...YAML_EXTENSIONS, ...MD_EXTENSIONS];
|
||||
|
||||
/** Parsed task with optional structured data */
|
||||
export interface ParsedTask {
|
||||
filePath: string;
|
||||
name: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
/** Structured data from YAML files (null for .md files) */
|
||||
data: TaskFileData | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is a supported task file
|
||||
*/
|
||||
export function isTaskFile(filename: string): boolean {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
return TASK_EXTENSIONS.includes(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is a YAML task file
|
||||
*/
|
||||
function isYamlFile(filename: string): boolean {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
return YAML_EXTENSIONS.includes(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the task name from a filename (without extension)
|
||||
*/
|
||||
function getTaskName(filename: string): string {
|
||||
const ext = path.extname(filename);
|
||||
return path.basename(filename, ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single task file
|
||||
*
|
||||
* @throws Error if YAML parsing or validation fails
|
||||
*/
|
||||
export function parseTaskFile(filePath: string): ParsedTask {
|
||||
const rawContent = fs.readFileSync(filePath, 'utf-8');
|
||||
const stat = fs.statSync(filePath);
|
||||
const filename = path.basename(filePath);
|
||||
const name = getTaskName(filename);
|
||||
|
||||
if (isYamlFile(filename)) {
|
||||
const parsed = parseYaml(rawContent) as unknown;
|
||||
const validated = TaskFileSchema.parse(parsed);
|
||||
return {
|
||||
filePath,
|
||||
name,
|
||||
content: validated.task,
|
||||
createdAt: stat.birthtime.toISOString(),
|
||||
data: validated,
|
||||
};
|
||||
}
|
||||
|
||||
// Markdown file: plain text, no structured data
|
||||
return {
|
||||
filePath,
|
||||
name,
|
||||
content: rawContent,
|
||||
createdAt: stat.birthtime.toISOString(),
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List and parse all task files in a directory
|
||||
*/
|
||||
export function parseTaskFiles(tasksDir: string): ParsedTask[] {
|
||||
const tasks: ParsedTask[] = [];
|
||||
|
||||
const files = fs.readdirSync(tasksDir)
|
||||
.filter(isTaskFile)
|
||||
.sort();
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(tasksDir, file);
|
||||
try {
|
||||
tasks.push(parseTaskFile(filePath));
|
||||
} catch {
|
||||
// Skip files that fail to parse
|
||||
}
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
@ -1,393 +1,281 @@
|
||||
/**
|
||||
* 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 {
|
||||
TaskRecordSchema,
|
||||
type TaskFileData,
|
||||
type TaskRecord,
|
||||
type TaskFailure,
|
||||
} from './schema.js';
|
||||
import type { TaskInfo, TaskResult, TaskListItem } from './types.js';
|
||||
import { createLogger } from '../../shared/utils/index.js';
|
||||
import { toFailedTaskItem, toPendingTaskItem, toTaskInfo } from './mapper.js';
|
||||
import { TaskStore } from './store.js';
|
||||
import { firstLine, nowIso, sanitizeTaskName } from './naming.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>();
|
||||
private readonly store: TaskStore;
|
||||
private readonly tasksFile: 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');
|
||||
constructor(private readonly projectDir: string) {
|
||||
this.store = new TaskStore(projectDir);
|
||||
this.tasksFile = this.store.getTasksFilePath();
|
||||
}
|
||||
|
||||
/** ディレクトリ構造を作成 */
|
||||
ensureDirs(): void {
|
||||
fs.mkdirSync(this.tasksDir, { recursive: true });
|
||||
fs.mkdirSync(this.completedDir, { recursive: true });
|
||||
fs.mkdirSync(this.failedDir, { recursive: true });
|
||||
this.store.ensureDirs();
|
||||
}
|
||||
|
||||
/** タスクディレクトリのパスを取得 */
|
||||
getTasksDir(): string {
|
||||
return this.tasksDir;
|
||||
return this.tasksFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* タスク一覧を取得
|
||||
* @returns タスク情報のリスト(ファイル名順)
|
||||
*/
|
||||
listTasks(): TaskInfo[] {
|
||||
this.ensureDirs();
|
||||
addTask(content: string, options?: Omit<TaskFileData, 'task'>): TaskInfo {
|
||||
const state = this.store.update((current) => {
|
||||
const name = this.generateTaskName(content, current.tasks.map((task) => task.name));
|
||||
const record: TaskRecord = TaskRecordSchema.parse({
|
||||
name,
|
||||
status: 'pending',
|
||||
content,
|
||||
created_at: nowIso(),
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
owner_pid: null,
|
||||
...options,
|
||||
});
|
||||
return { tasks: [...current.tasks, record] };
|
||||
});
|
||||
|
||||
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 は許容(ディレクトリ未作成)
|
||||
const created = state.tasks[state.tasks.length - 1];
|
||||
if (!created) {
|
||||
throw new Error('Failed to create task.');
|
||||
}
|
||||
return toTaskInfo(this.projectDir, this.tasksFile, created);
|
||||
}
|
||||
|
||||
listTasks(): TaskInfo[] {
|
||||
const state = this.store.read();
|
||||
return state.tasks
|
||||
.filter((task) => task.status === 'pending')
|
||||
.map((task) => toTaskInfo(this.projectDir, this.tasksFile, task));
|
||||
}
|
||||
|
||||
claimNextTasks(count: number): TaskInfo[] {
|
||||
if (count <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const claimed: TaskRecord[] = [];
|
||||
|
||||
this.store.update((current) => {
|
||||
let remaining = count;
|
||||
const tasks = current.tasks.map((task) => {
|
||||
if (remaining > 0 && task.status === 'pending') {
|
||||
const next: TaskRecord = {
|
||||
...task,
|
||||
status: 'running',
|
||||
started_at: nowIso(),
|
||||
owner_pid: process.pid,
|
||||
};
|
||||
claimed.push(next);
|
||||
remaining--;
|
||||
return next;
|
||||
}
|
||||
return task;
|
||||
});
|
||||
return { tasks };
|
||||
});
|
||||
|
||||
return claimed.map((task) => toTaskInfo(this.projectDir, this.tasksFile, task));
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定した名前のタスクを取得
|
||||
* 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;
|
||||
recoverInterruptedRunningTasks(): number {
|
||||
let recovered = 0;
|
||||
this.store.update((current) => {
|
||||
const tasks = current.tasks.map((task) => {
|
||||
if (task.status !== 'running' || !this.isRunningTaskStale(task)) {
|
||||
return task;
|
||||
}
|
||||
recovered++;
|
||||
return {
|
||||
...task,
|
||||
status: 'pending',
|
||||
started_at: null,
|
||||
owner_pid: null,
|
||||
} as TaskRecord;
|
||||
});
|
||||
return { tasks };
|
||||
});
|
||||
return recovered;
|
||||
}
|
||||
|
||||
/**
|
||||
* 次に実行すべきタスクを取得(最初のタスク)
|
||||
*/
|
||||
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);
|
||||
|
||||
this.store.update((current) => {
|
||||
const index = this.findActiveTaskIndex(current.tasks, result.task.name);
|
||||
if (index === -1) {
|
||||
throw new Error(`Task not found: ${result.task.name}`);
|
||||
}
|
||||
|
||||
const target = current.tasks[index]!;
|
||||
const updated: TaskRecord = {
|
||||
...target,
|
||||
status: 'completed',
|
||||
completed_at: result.completedAt,
|
||||
owner_pid: null,
|
||||
failure: undefined,
|
||||
};
|
||||
const tasks = [...current.tasks];
|
||||
tasks[index] = updated;
|
||||
return { tasks };
|
||||
});
|
||||
|
||||
return this.tasksFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* タスクを失敗としてマーク
|
||||
*
|
||||
* タスクファイルを .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,
|
||||
const failure: TaskFailure = {
|
||||
movement: result.failureMovement,
|
||||
error: result.response,
|
||||
last_message: result.failureLastMessage ?? result.executionLog[result.executionLog.length - 1],
|
||||
};
|
||||
fs.writeFileSync(logFile, JSON.stringify(logData, null, 2), 'utf-8');
|
||||
|
||||
return reportFile;
|
||||
this.store.update((current) => {
|
||||
const index = this.findActiveTaskIndex(current.tasks, result.task.name);
|
||||
if (index === -1) {
|
||||
throw new Error(`Task not found: ${result.task.name}`);
|
||||
}
|
||||
|
||||
const target = current.tasks[index]!;
|
||||
const updated: TaskRecord = {
|
||||
...target,
|
||||
status: 'failed',
|
||||
completed_at: result.completedAt,
|
||||
owner_pid: null,
|
||||
failure,
|
||||
};
|
||||
const tasks = [...current.tasks];
|
||||
tasks[index] = updated;
|
||||
return { tasks };
|
||||
});
|
||||
|
||||
return this.tasksFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* レポートを生成
|
||||
*/
|
||||
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*
|
||||
`;
|
||||
listPendingTaskItems(): TaskListItem[] {
|
||||
const state = this.store.read();
|
||||
return state.tasks
|
||||
.filter((task) => task.status === 'pending')
|
||||
.map((task) => toPendingTaskItem(this.projectDir, this.tasksFile, task));
|
||||
}
|
||||
|
||||
listFailedTasks(): TaskListItem[] {
|
||||
const state = this.store.read();
|
||||
return state.tasks
|
||||
.filter((task) => task.status === 'failed')
|
||||
.map((task) => toFailedTaskItem(this.projectDir, this.tasksFile, task));
|
||||
}
|
||||
|
||||
requeueFailedTask(taskRef: string, startMovement?: string, retryNote?: string): string {
|
||||
const taskName = this.normalizeTaskRef(taskRef);
|
||||
|
||||
this.store.update((current) => {
|
||||
const index = current.tasks.findIndex((task) => task.name === taskName && task.status === 'failed');
|
||||
if (index === -1) {
|
||||
throw new Error(`Failed task not found: ${taskRef}`);
|
||||
}
|
||||
|
||||
const target = current.tasks[index]!;
|
||||
const updated: TaskRecord = {
|
||||
...target,
|
||||
status: 'pending',
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
owner_pid: null,
|
||||
failure: undefined,
|
||||
start_movement: startMovement,
|
||||
retry_note: retryNote,
|
||||
};
|
||||
|
||||
const tasks = [...current.tasks];
|
||||
tasks[index] = updated;
|
||||
return { tasks };
|
||||
});
|
||||
|
||||
return this.tasksFile;
|
||||
}
|
||||
|
||||
deletePendingTask(name: string): void {
|
||||
this.deleteTaskByNameAndStatus(name, 'pending');
|
||||
}
|
||||
|
||||
deleteFailedTask(name: string): void {
|
||||
this.deleteTaskByNameAndStatus(name, 'failed');
|
||||
}
|
||||
|
||||
private deleteTaskByNameAndStatus(name: string, status: 'pending' | 'failed'): void {
|
||||
this.store.update((current) => {
|
||||
const exists = current.tasks.some((task) => task.name === name && task.status === status);
|
||||
if (!exists) {
|
||||
throw new Error(`Task not found: ${name} (${status})`);
|
||||
}
|
||||
return {
|
||||
tasks: current.tasks.filter((task) => !(task.name === name && task.status === status)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private normalizeTaskRef(taskRef: string): string {
|
||||
if (!taskRef.includes(path.sep)) {
|
||||
return taskRef;
|
||||
}
|
||||
|
||||
const base = path.basename(taskRef);
|
||||
if (base.includes('_')) {
|
||||
return base.slice(base.indexOf('_') + 1);
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
private findActiveTaskIndex(tasks: TaskRecord[], name: string): number {
|
||||
return tasks.findIndex((task) => task.name === name && (task.status === 'running' || task.status === 'pending'));
|
||||
}
|
||||
|
||||
private isRunningTaskStale(task: TaskRecord): boolean {
|
||||
if (task.owner_pid == null) {
|
||||
return true;
|
||||
}
|
||||
return !this.isProcessAlive(task.owner_pid);
|
||||
}
|
||||
|
||||
private isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const nodeErr = err as NodeJS.ErrnoException;
|
||||
if (nodeErr.code === 'ESRCH') {
|
||||
return false;
|
||||
}
|
||||
if (nodeErr.code === 'EPERM') {
|
||||
return true;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private generateTaskName(content: string, existingNames: string[]): string {
|
||||
const base = sanitizeTaskName(firstLine(content));
|
||||
let candidate = base;
|
||||
let counter = 1;
|
||||
while (existingNames.includes(candidate)) {
|
||||
candidate = `${base}-${counter}`;
|
||||
counter++;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
/** 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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,39 +1,184 @@
|
||||
/**
|
||||
* Task YAML schema definition
|
||||
*
|
||||
* Zod schema for structured task files (.yaml/.yml)
|
||||
* Task schema definitions
|
||||
*/
|
||||
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
/**
|
||||
* YAML task file schema
|
||||
*
|
||||
* Examples:
|
||||
* task: "認証機能を追加する"
|
||||
* worktree: true # 共有クローンで隔離実行
|
||||
* branch: "feat/add-auth" # オプション(省略時は自動生成)
|
||||
* piece: "default" # オプション(省略時はcurrent piece)
|
||||
*
|
||||
* worktree patterns (uses git clone --shared internally):
|
||||
* - true: create shared clone in sibling dir or worktree_dir
|
||||
* - "/path/to/dir": create at specified path
|
||||
* - omitted: no isolation (run in cwd)
|
||||
*
|
||||
* branch patterns:
|
||||
* - "feat/xxx": use specified branch name
|
||||
* - omitted: auto-generate as takt/{timestamp}-{task-slug}
|
||||
* Per-task execution config schema.
|
||||
* Used by `takt add` input and in-memory TaskInfo.data.
|
||||
*/
|
||||
export const TaskFileSchema = z.object({
|
||||
task: z.string().min(1),
|
||||
export const TaskExecutionConfigSchema = z.object({
|
||||
worktree: z.union([z.boolean(), z.string()]).optional(),
|
||||
branch: z.string().optional(),
|
||||
piece: z.string().optional(),
|
||||
issue: z.number().int().positive().optional(),
|
||||
start_movement: z.string().optional(),
|
||||
retry_note: z.string().optional(),
|
||||
/** Auto-create PR after worktree execution (default: prompt in interactive mode) */
|
||||
auto_pr: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Single task payload schema used by in-memory TaskInfo.data.
|
||||
*/
|
||||
export const TaskFileSchema = TaskExecutionConfigSchema.extend({
|
||||
task: z.string().min(1),
|
||||
});
|
||||
|
||||
export type TaskFileData = z.infer<typeof TaskFileSchema>;
|
||||
|
||||
export const TaskStatusSchema = z.enum(['pending', 'running', 'completed', 'failed']);
|
||||
export type TaskStatus = z.infer<typeof TaskStatusSchema>;
|
||||
|
||||
export const TaskFailureSchema = z.object({
|
||||
movement: z.string().optional(),
|
||||
error: z.string().min(1),
|
||||
last_message: z.string().optional(),
|
||||
});
|
||||
export type TaskFailure = z.infer<typeof TaskFailureSchema>;
|
||||
|
||||
export const TaskRecordSchema = TaskExecutionConfigSchema.extend({
|
||||
name: z.string().min(1),
|
||||
status: TaskStatusSchema,
|
||||
content: z.string().optional(),
|
||||
content_file: z.string().optional(),
|
||||
created_at: z.string().min(1),
|
||||
started_at: z.string().nullable(),
|
||||
completed_at: z.string().nullable(),
|
||||
owner_pid: z.number().int().positive().nullable().optional(),
|
||||
failure: TaskFailureSchema.optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (!value.content && !value.content_file) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['content'],
|
||||
message: 'Either content or content_file is required.',
|
||||
});
|
||||
}
|
||||
|
||||
const hasFailure = value.failure !== undefined;
|
||||
const hasOwnerPid = typeof value.owner_pid === 'number';
|
||||
|
||||
if (value.status === 'pending') {
|
||||
if (value.started_at !== null) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['started_at'],
|
||||
message: 'Pending task must not have started_at.',
|
||||
});
|
||||
}
|
||||
if (value.completed_at !== null) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['completed_at'],
|
||||
message: 'Pending task must not have completed_at.',
|
||||
});
|
||||
}
|
||||
if (hasFailure) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['failure'],
|
||||
message: 'Pending task must not have failure.',
|
||||
});
|
||||
}
|
||||
if (hasOwnerPid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['owner_pid'],
|
||||
message: 'Pending task must not have owner_pid.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (value.status === 'running') {
|
||||
if (value.started_at === null) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['started_at'],
|
||||
message: 'Running task requires started_at.',
|
||||
});
|
||||
}
|
||||
if (value.completed_at !== null) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['completed_at'],
|
||||
message: 'Running task must not have completed_at.',
|
||||
});
|
||||
}
|
||||
if (hasFailure) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['failure'],
|
||||
message: 'Running task must not have failure.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (value.status === 'completed') {
|
||||
if (value.started_at === null) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['started_at'],
|
||||
message: 'Completed task requires started_at.',
|
||||
});
|
||||
}
|
||||
if (value.completed_at === null) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['completed_at'],
|
||||
message: 'Completed task requires completed_at.',
|
||||
});
|
||||
}
|
||||
if (hasFailure) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['failure'],
|
||||
message: 'Completed task must not have failure.',
|
||||
});
|
||||
}
|
||||
if (hasOwnerPid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['owner_pid'],
|
||||
message: 'Completed task must not have owner_pid.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (value.status === 'failed') {
|
||||
if (value.started_at === null) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['started_at'],
|
||||
message: 'Failed task requires started_at.',
|
||||
});
|
||||
}
|
||||
if (value.completed_at === null) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['completed_at'],
|
||||
message: 'Failed task requires completed_at.',
|
||||
});
|
||||
}
|
||||
if (!hasFailure) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['failure'],
|
||||
message: 'Failed task requires failure.',
|
||||
});
|
||||
}
|
||||
if (hasOwnerPid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['owner_pid'],
|
||||
message: 'Failed task must not have owner_pid.',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
export type TaskRecord = z.infer<typeof TaskRecordSchema>;
|
||||
|
||||
export const TasksFileSchema = z.object({
|
||||
tasks: z.array(TaskRecordSchema),
|
||||
});
|
||||
export type TasksFileData = z.infer<typeof TasksFileSchema>;
|
||||
|
||||
181
src/infra/task/store.ts
Normal file
181
src/infra/task/store.ts
Normal file
@ -0,0 +1,181 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
||||
import { TasksFileSchema, type TasksFileData } from './schema.js';
|
||||
import { createLogger } from '../../shared/utils/index.js';
|
||||
|
||||
const log = createLogger('task-store');
|
||||
const LOCK_WAIT_MS = 5_000;
|
||||
const LOCK_POLL_MS = 50;
|
||||
|
||||
function sleepSync(ms: number): void {
|
||||
const arr = new Int32Array(new SharedArrayBuffer(4));
|
||||
Atomics.wait(arr, 0, 0, ms);
|
||||
}
|
||||
|
||||
export class TaskStore {
|
||||
private readonly tasksFile: string;
|
||||
private readonly lockFile: string;
|
||||
private readonly taktDir: string;
|
||||
|
||||
constructor(private readonly projectDir: string) {
|
||||
this.taktDir = path.join(projectDir, '.takt');
|
||||
this.tasksFile = path.join(this.taktDir, 'tasks.yaml');
|
||||
this.lockFile = path.join(this.taktDir, 'tasks.yaml.lock');
|
||||
}
|
||||
|
||||
getTasksFilePath(): string {
|
||||
return this.tasksFile;
|
||||
}
|
||||
|
||||
ensureDirs(): void {
|
||||
fs.mkdirSync(this.taktDir, { recursive: true });
|
||||
}
|
||||
|
||||
read(): TasksFileData {
|
||||
return this.withLock(() => this.readUnsafe());
|
||||
}
|
||||
|
||||
update(mutator: (current: TasksFileData) => TasksFileData): TasksFileData {
|
||||
return this.withLock(() => {
|
||||
const current = this.readUnsafe();
|
||||
const updated = TasksFileSchema.parse(mutator(current));
|
||||
this.writeUnsafe(updated);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
private readUnsafe(): TasksFileData {
|
||||
this.ensureDirs();
|
||||
|
||||
if (!fs.existsSync(this.tasksFile)) {
|
||||
return { tasks: [] };
|
||||
}
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(this.tasksFile, 'utf-8');
|
||||
} catch (err) {
|
||||
log.error('Failed to read tasks file', { file: this.tasksFile, error: String(err) });
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = parseYaml(raw) as unknown;
|
||||
return TasksFileSchema.parse(parsed);
|
||||
} catch (err) {
|
||||
log.error('tasks.yaml is broken. Resetting file.', { file: this.tasksFile, error: String(err) });
|
||||
fs.unlinkSync(this.tasksFile);
|
||||
return { tasks: [] };
|
||||
}
|
||||
}
|
||||
|
||||
private writeUnsafe(state: TasksFileData): void {
|
||||
this.ensureDirs();
|
||||
const tempPath = `${this.tasksFile}.tmp-${process.pid}-${Date.now()}`;
|
||||
const yaml = stringifyYaml(state);
|
||||
fs.writeFileSync(tempPath, yaml, 'utf-8');
|
||||
fs.renameSync(tempPath, this.tasksFile);
|
||||
}
|
||||
|
||||
private withLock<T>(fn: () => T): T {
|
||||
this.acquireLock();
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
this.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
private acquireLock(): void {
|
||||
this.ensureDirs();
|
||||
const start = Date.now();
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
fs.writeFileSync(this.lockFile, String(process.pid), { encoding: 'utf-8', flag: 'wx' });
|
||||
return;
|
||||
} catch (err) {
|
||||
const nodeErr = err as NodeJS.ErrnoException;
|
||||
if (nodeErr.code !== 'EEXIST') {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isStaleLock()) {
|
||||
this.removeStaleLock();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Date.now() - start >= LOCK_WAIT_MS) {
|
||||
throw new Error(`Failed to acquire tasks lock within ${LOCK_WAIT_MS}ms`);
|
||||
}
|
||||
|
||||
sleepSync(LOCK_POLL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
private isStaleLock(): boolean {
|
||||
let pidRaw: string;
|
||||
try {
|
||||
pidRaw = fs.readFileSync(this.lockFile, 'utf-8').trim();
|
||||
} catch (err) {
|
||||
const nodeErr = err as NodeJS.ErrnoException;
|
||||
if (nodeErr.code === 'ENOENT') {
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const pid = Number.parseInt(pidRaw, 10);
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !this.isProcessAlive(pid);
|
||||
}
|
||||
|
||||
private removeStaleLock(): void {
|
||||
try {
|
||||
fs.unlinkSync(this.lockFile);
|
||||
} catch (err) {
|
||||
const nodeErr = err as NodeJS.ErrnoException;
|
||||
if (nodeErr.code !== 'ENOENT') {
|
||||
log.debug('Failed to remove stale lock, retrying.', { lockFile: this.lockFile, error: String(err) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const nodeErr = err as NodeJS.ErrnoException;
|
||||
if (nodeErr.code === 'ESRCH') {
|
||||
return false;
|
||||
}
|
||||
if (nodeErr.code === 'EPERM') {
|
||||
return true;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private releaseLock(): void {
|
||||
try {
|
||||
const holder = fs.readFileSync(this.lockFile, 'utf-8').trim();
|
||||
if (holder !== String(process.pid)) {
|
||||
return;
|
||||
}
|
||||
fs.unlinkSync(this.lockFile);
|
||||
} catch (err) {
|
||||
const nodeErr = err as NodeJS.ErrnoException;
|
||||
if (nodeErr.code === 'ENOENT') {
|
||||
return;
|
||||
}
|
||||
log.debug('Failed to release tasks lock.', { lockFile: this.lockFile, error: String(err) });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { TaskFileData } from './schema.js';
|
||||
import type { TaskFailure, TaskStatus } from './schema.js';
|
||||
|
||||
/** タスク情報 */
|
||||
export interface TaskInfo {
|
||||
@ -10,7 +11,7 @@ export interface TaskInfo {
|
||||
name: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
/** Structured data from YAML files (null for .md files) */
|
||||
status: TaskStatus;
|
||||
data: TaskFileData | null;
|
||||
}
|
||||
|
||||
@ -20,6 +21,8 @@ export interface TaskResult {
|
||||
success: boolean;
|
||||
response: string;
|
||||
executionLog: string[];
|
||||
failureMovement?: string;
|
||||
failureLastMessage?: string;
|
||||
startedAt: string;
|
||||
completedAt: string;
|
||||
}
|
||||
@ -74,4 +77,6 @@ export interface TaskListItem {
|
||||
createdAt: string;
|
||||
filePath: string;
|
||||
content: string;
|
||||
data?: TaskFileData;
|
||||
failure?: TaskFailure;
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Task directory watcher
|
||||
*
|
||||
* Polls .takt/tasks/ for new task files and invokes a callback when found.
|
||||
* Polls .takt/tasks.yaml for pending tasks and invokes a callback when found.
|
||||
* Uses polling (not fs.watch) for cross-platform reliability.
|
||||
*/
|
||||
|
||||
@ -40,7 +40,8 @@ export class TaskWatcher {
|
||||
log.info('Watch started', { pollInterval: this.pollInterval });
|
||||
|
||||
while (this.running) {
|
||||
const task = this.runner.getNextTask();
|
||||
const claimed = this.runner.claimNextTasks(1);
|
||||
const task = claimed[0];
|
||||
|
||||
if (task) {
|
||||
log.info('Task found', { name: task.name });
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user