Merge pull request #465 from nrslib/takt/420/remove-default-piece-switch
feat: デフォルトピースの概念と takt switch コマンドを削除
This commit is contained in:
parent
9fc8ab73fd
commit
4f02c20c1d
@ -35,7 +35,6 @@ TAKT (TAKT Agent Koordination Topology) is a multi-agent orchestration system fo
|
|||||||
| `takt watch` | Watch `.takt/tasks/` and auto-execute tasks (resident process) |
|
| `takt watch` | Watch `.takt/tasks/` and auto-execute tasks (resident process) |
|
||||||
| `takt add [task]` | Add a new task via AI conversation |
|
| `takt add [task]` | Add a new task via AI conversation |
|
||||||
| `takt list` | List task branches (merge, delete, retry) |
|
| `takt list` | List task branches (merge, delete, retry) |
|
||||||
| `takt switch [piece]` | Switch piece interactively |
|
|
||||||
| `takt clear` | Clear agent conversation sessions (reset state) |
|
| `takt clear` | Clear agent conversation sessions (reset state) |
|
||||||
| `takt eject [type] [name]` | Copy builtin piece or facet for customization (`--global` for ~/.takt/) |
|
| `takt eject [type] [name]` | Copy builtin piece or facet for customization (`--global` for ~/.takt/) |
|
||||||
| `takt prompt [piece]` | Preview assembled prompts for each movement and phase |
|
| `takt prompt [piece]` | Preview assembled prompts for each movement and phase |
|
||||||
|
|||||||
@ -51,4 +51,36 @@ describe('E2E: Help command (takt --help)', () => {
|
|||||||
const output = result.stdout.toLowerCase();
|
const output = result.stdout.toLowerCase();
|
||||||
expect(output).toMatch(/run|task|pending/);
|
expect(output).toMatch(/run|task|pending/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should show prompt argument help without current-piece wording', () => {
|
||||||
|
// Given: a local repo with isolated env
|
||||||
|
|
||||||
|
// When: running takt prompt --help
|
||||||
|
const result = runTakt({
|
||||||
|
args: ['prompt', '--help'],
|
||||||
|
cwd: repo.path,
|
||||||
|
env: isolatedEnv.env,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then: prompt help uses explicit default piece wording
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(result.stdout).toMatch(/defaults to ["']default["']/i);
|
||||||
|
expect(result.stdout).not.toMatch(/defaults to current/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail with unknown command for removed switch subcommand', () => {
|
||||||
|
// Given: a local repo with isolated env
|
||||||
|
|
||||||
|
// When: running removed takt switch command
|
||||||
|
const result = runTakt({
|
||||||
|
args: ['switch'],
|
||||||
|
cwd: repo.path,
|
||||||
|
env: isolatedEnv.env,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then: command exits non-zero and reports unknown command
|
||||||
|
const combined = `${result.stdout}\n${result.stderr}`;
|
||||||
|
expect(result.exitCode).not.toBe(0);
|
||||||
|
expect(combined).toMatch(/unknown command/i);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,51 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
||||||
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
|
|
||||||
import { runTakt } from '../helpers/takt-runner';
|
|
||||||
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
|
|
||||||
|
|
||||||
// E2E更新時は docs/testing/e2e.md も更新すること
|
|
||||||
describe('E2E: Switch piece command (takt switch)', () => {
|
|
||||||
let isolatedEnv: IsolatedEnv;
|
|
||||||
let repo: LocalRepo;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
isolatedEnv = createIsolatedEnv();
|
|
||||||
repo = createLocalRepo();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
try { repo.cleanup(); } catch { /* best-effort */ }
|
|
||||||
try { isolatedEnv.cleanup(); } catch { /* best-effort */ }
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should switch piece when a valid piece name is given', () => {
|
|
||||||
// Given: a local repo with isolated env
|
|
||||||
|
|
||||||
// When: running takt switch default
|
|
||||||
const result = runTakt({
|
|
||||||
args: ['switch', 'default'],
|
|
||||||
cwd: repo.path,
|
|
||||||
env: isolatedEnv.env,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Then: exits successfully
|
|
||||||
expect(result.exitCode).toBe(0);
|
|
||||||
const output = result.stdout.toLowerCase();
|
|
||||||
expect(output).toMatch(/default|switched|piece/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should error when a nonexistent piece name is given', () => {
|
|
||||||
// Given: a local repo with isolated env
|
|
||||||
|
|
||||||
// When: running takt switch with a nonexistent piece name
|
|
||||||
const result = runTakt({
|
|
||||||
args: ['switch', 'nonexistent-piece-xyz'],
|
|
||||||
cwd: repo.path,
|
|
||||||
env: isolatedEnv.env,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Then: error output
|
|
||||||
const combined = result.stdout + result.stderr;
|
|
||||||
expect(combined).toMatch(/not found|error|does not exist/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -44,7 +44,7 @@ describe('E2E: Config priority (piece / autoPr)', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use configured piece in pipeline when --piece is omitted', () => {
|
it('should require --piece in pipeline even when config has piece', () => {
|
||||||
const configuredPiecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml');
|
const configuredPiecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml');
|
||||||
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json');
|
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json');
|
||||||
const projectConfigDir = join(testRepo.path, '.takt');
|
const projectConfigDir = join(testRepo.path, '.takt');
|
||||||
@ -70,9 +70,8 @@ describe('E2E: Config priority (piece / autoPr)', () => {
|
|||||||
timeout: 240_000,
|
timeout: 240_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.exitCode).toBe(0);
|
expect(result.exitCode).toBe(1);
|
||||||
expect(result.stdout).toContain(`Running piece: ${configuredPiecePath}`);
|
expect(`${result.stdout}${result.stderr}`).toContain('piece');
|
||||||
expect(result.stdout).toContain(`Piece '${configuredPiecePath}' completed`);
|
|
||||||
}, 240_000);
|
}, 240_000);
|
||||||
|
|
||||||
it('should default auto_pr to true when unset in config/env', () => {
|
it('should default auto_pr to true when unset in config/env', () => {
|
||||||
|
|||||||
@ -81,15 +81,11 @@ vi.mock('../infra/task/index.js', () => ({
|
|||||||
|
|
||||||
vi.mock('../infra/config/index.js', () => ({
|
vi.mock('../infra/config/index.js', () => ({
|
||||||
getPieceDescription: vi.fn(() => ({ name: 'default', description: 'test piece', pieceStructure: '', movementPreviews: [] })),
|
getPieceDescription: vi.fn(() => ({ name: 'default', description: 'test piece', pieceStructure: '', movementPreviews: [] })),
|
||||||
resolveConfigValue: vi.fn((_: string, key: string) => (key === 'piece' ? 'default' : false)),
|
|
||||||
resolveConfigValues: vi.fn(() => ({ language: 'en', interactivePreviewMovements: 3, provider: 'claude' })),
|
resolveConfigValues: vi.fn(() => ({ language: 'en', interactivePreviewMovements: 3, provider: 'claude' })),
|
||||||
|
resolveConfigValue: vi.fn(() => undefined),
|
||||||
loadPersonaSessions: vi.fn(() => ({})),
|
loadPersonaSessions: vi.fn(() => ({})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../shared/constants.js', () => ({
|
|
||||||
DEFAULT_PIECE_NAME: 'default',
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockOpts: Record<string, unknown> = {};
|
const mockOpts: Record<string, unknown> = {};
|
||||||
|
|
||||||
vi.mock('../app/cli/program.js', () => {
|
vi.mock('../app/cli/program.js', () => {
|
||||||
@ -113,7 +109,6 @@ vi.mock('../app/cli/helpers.js', () => ({
|
|||||||
import { selectAndExecuteTask, determinePiece, saveTaskFromInteractive } from '../features/tasks/index.js';
|
import { selectAndExecuteTask, determinePiece, saveTaskFromInteractive } from '../features/tasks/index.js';
|
||||||
import { interactiveMode } from '../features/interactive/index.js';
|
import { interactiveMode } from '../features/interactive/index.js';
|
||||||
import { executePipeline } from '../features/pipeline/index.js';
|
import { executePipeline } from '../features/pipeline/index.js';
|
||||||
import { resolveConfigValue } from '../infra/config/index.js';
|
|
||||||
import { executeDefaultAction } from '../app/cli/routing.js';
|
import { executeDefaultAction } from '../app/cli/routing.js';
|
||||||
import { error as logError } from '../shared/ui/index.js';
|
import { error as logError } from '../shared/ui/index.js';
|
||||||
import type { InteractiveModeResult } from '../features/interactive/index.js';
|
import type { InteractiveModeResult } from '../features/interactive/index.js';
|
||||||
@ -123,7 +118,6 @@ const mockSelectAndExecuteTask = vi.mocked(selectAndExecuteTask);
|
|||||||
const mockDeterminePiece = vi.mocked(determinePiece);
|
const mockDeterminePiece = vi.mocked(determinePiece);
|
||||||
const mockInteractiveMode = vi.mocked(interactiveMode);
|
const mockInteractiveMode = vi.mocked(interactiveMode);
|
||||||
const mockExecutePipeline = vi.mocked(executePipeline);
|
const mockExecutePipeline = vi.mocked(executePipeline);
|
||||||
const mockResolveConfigValue = vi.mocked(resolveConfigValue);
|
|
||||||
const mockLogError = vi.mocked(logError);
|
const mockLogError = vi.mocked(logError);
|
||||||
const mockSaveTaskFromInteractive = vi.mocked(saveTaskFromInteractive);
|
const mockSaveTaskFromInteractive = vi.mocked(saveTaskFromInteractive);
|
||||||
|
|
||||||
@ -148,7 +142,6 @@ beforeEach(() => {
|
|||||||
}
|
}
|
||||||
mockDeterminePiece.mockResolvedValue('default');
|
mockDeterminePiece.mockResolvedValue('default');
|
||||||
mockInteractiveMode.mockResolvedValue({ action: 'execute', task: 'summarized task' });
|
mockInteractiveMode.mockResolvedValue({ action: 'execute', task: 'summarized task' });
|
||||||
mockResolveConfigValue.mockImplementation((_: string, key: string) => (key === 'piece' ? 'default' : false));
|
|
||||||
mockListAllTaskItems.mockReturnValue([]);
|
mockListAllTaskItems.mockReturnValue([]);
|
||||||
mockIsStaleRunningTask.mockReturnValue(false);
|
mockIsStaleRunningTask.mockReturnValue(false);
|
||||||
});
|
});
|
||||||
@ -343,6 +336,7 @@ describe('PR resolution in routing', () => {
|
|||||||
Object.defineProperty(programModule, 'pipelineMode', { value: true, writable: true });
|
Object.defineProperty(programModule, 'pipelineMode', { value: true, writable: true });
|
||||||
|
|
||||||
mockOpts.pr = 456;
|
mockOpts.pr = 456;
|
||||||
|
mockOpts.piece = 'default';
|
||||||
mockExecutePipeline.mockResolvedValue(0);
|
mockExecutePipeline.mockResolvedValue(0);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
@ -359,22 +353,24 @@ describe('PR resolution in routing', () => {
|
|||||||
Object.defineProperty(programModule, 'pipelineMode', { value: originalPipelineMode, writable: true });
|
Object.defineProperty(programModule, 'pipelineMode', { value: originalPipelineMode, writable: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use DEFAULT_PIECE_NAME when resolved piece is undefined', async () => {
|
it('should exit with error when piece is omitted in pipeline mode', async () => {
|
||||||
const programModule = await import('../app/cli/program.js');
|
const programModule = await import('../app/cli/program.js');
|
||||||
const originalPipelineMode = programModule.pipelineMode;
|
const originalPipelineMode = programModule.pipelineMode;
|
||||||
Object.defineProperty(programModule, 'pipelineMode', { value: true, writable: true });
|
Object.defineProperty(programModule, 'pipelineMode', { value: true, writable: true });
|
||||||
|
|
||||||
mockOpts.pr = 456;
|
mockOpts.pr = 456;
|
||||||
mockExecutePipeline.mockResolvedValue(0);
|
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||||
mockResolveConfigValue.mockImplementation((_: string, key: string) => (key === 'piece' ? undefined : false));
|
throw new Error('process.exit called');
|
||||||
|
});
|
||||||
|
|
||||||
await executeDefaultAction();
|
await expect(executeDefaultAction()).rejects.toThrow('process.exit called');
|
||||||
|
|
||||||
expect(mockExecutePipeline).toHaveBeenCalledWith(
|
expect(mockExit).toHaveBeenCalledWith(1);
|
||||||
expect.objectContaining({
|
expect(mockLogError).toHaveBeenCalledWith(
|
||||||
piece: 'default',
|
expect.stringContaining('piece'),
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
expect(mockExecutePipeline).not.toHaveBeenCalled();
|
||||||
|
mockExit.mockRestore();
|
||||||
|
|
||||||
Object.defineProperty(programModule, 'pipelineMode', { value: originalPipelineMode, writable: true });
|
Object.defineProperty(programModule, 'pipelineMode', { value: originalPipelineMode, writable: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@ -8,7 +8,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { isDirectTask } from '../app/cli/helpers.js';
|
import type { Command } from 'commander';
|
||||||
|
import { isDirectTask, resolveAgentOverrides, resolveRemovedRootCommand, resolveSlashFallbackTask } from '../app/cli/helpers.js';
|
||||||
|
|
||||||
describe('isDirectTask', () => {
|
describe('isDirectTask', () => {
|
||||||
describe('slash prefixed inputs', () => {
|
describe('slash prefixed inputs', () => {
|
||||||
@ -103,3 +104,55 @@ describe('isDirectTask', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('resolveSlashFallbackTask', () => {
|
||||||
|
it('returns raw argv as task for unknown slash command', () => {
|
||||||
|
const task = resolveSlashFallbackTask(['/foo', '--bar'], ['run', 'add', 'watch']);
|
||||||
|
expect(task).toBe('/foo --bar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for known slash command', () => {
|
||||||
|
const task = resolveSlashFallbackTask(['/run', '--help'], ['run', 'add', 'watch']);
|
||||||
|
expect(task).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when first argument is not slash-prefixed', () => {
|
||||||
|
const task = resolveSlashFallbackTask(['run', '/foo'], ['run', 'add', 'watch']);
|
||||||
|
expect(task).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveRemovedRootCommand', () => {
|
||||||
|
it('returns removed command when first argument is switch', () => {
|
||||||
|
expect(resolveRemovedRootCommand(['switch'])).toBe('switch');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when first argument is a valid command', () => {
|
||||||
|
expect(resolveRemovedRootCommand(['run'])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when argument only contains removed command in later position', () => {
|
||||||
|
expect(resolveRemovedRootCommand(['--help', 'switch'])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveAgentOverrides', () => {
|
||||||
|
it('returns undefined when provider and model are both missing', () => {
|
||||||
|
const program = {
|
||||||
|
opts: () => ({}),
|
||||||
|
} as unknown as Command;
|
||||||
|
|
||||||
|
expect(resolveAgentOverrides(program)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns provider/model pair when one or both are provided', () => {
|
||||||
|
const program = {
|
||||||
|
opts: () => ({ provider: 'codex', model: 'gpt-5' }),
|
||||||
|
} as unknown as Command;
|
||||||
|
|
||||||
|
expect(resolveAgentOverrides(program)).toEqual({
|
||||||
|
provider: 'codex',
|
||||||
|
model: 'gpt-5',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -45,7 +45,7 @@ vi.mock('../infra/config/global/globalConfig.js', () => ({
|
|||||||
|
|
||||||
vi.mock('../infra/config/project/projectConfig.js', async (importOriginal) => ({
|
vi.mock('../infra/config/project/projectConfig.js', async (importOriginal) => ({
|
||||||
...(await importOriginal<Record<string, unknown>>()),
|
...(await importOriginal<Record<string, unknown>>()),
|
||||||
loadProjectConfig: vi.fn(() => ({ piece: 'default' })),
|
loadProjectConfig: vi.fn(() => ({})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
@ -59,7 +59,7 @@ const mockLoadProjectConfig = vi.mocked(loadProjectConfig);
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockLoadProjectConfig.mockReturnValue({ piece: 'default' });
|
mockLoadProjectConfig.mockReturnValue({});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('cloneAndIsolate git config propagation', () => {
|
describe('cloneAndIsolate git config propagation', () => {
|
||||||
@ -598,7 +598,7 @@ describe('clone submodule arguments', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it('should append recurse flag when submodules is all', () => {
|
it('should append recurse flag when submodules is all', () => {
|
||||||
mockLoadProjectConfig.mockReturnValue({ piece: 'default', submodules: 'all' });
|
mockLoadProjectConfig.mockReturnValue({ submodules: 'all' });
|
||||||
const cloneCalls = setupCloneArgsCapture();
|
const cloneCalls = setupCloneArgsCapture();
|
||||||
|
|
||||||
createSharedClone('/project', {
|
createSharedClone('/project', {
|
||||||
@ -611,7 +611,7 @@ describe('clone submodule arguments', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should append path-scoped recurse flags when submodules is explicit list', () => {
|
it('should append path-scoped recurse flags when submodules is explicit list', () => {
|
||||||
mockLoadProjectConfig.mockReturnValue({ piece: 'default', submodules: ['path/a', 'path/b'] });
|
mockLoadProjectConfig.mockReturnValue({ submodules: ['path/a', 'path/b'] });
|
||||||
const cloneCalls = setupCloneArgsCapture();
|
const cloneCalls = setupCloneArgsCapture();
|
||||||
|
|
||||||
createSharedClone('/project', {
|
createSharedClone('/project', {
|
||||||
@ -629,7 +629,7 @@ describe('clone submodule arguments', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should append recurse flag when withSubmodules is true and submodules is unset', () => {
|
it('should append recurse flag when withSubmodules is true and submodules is unset', () => {
|
||||||
mockLoadProjectConfig.mockReturnValue({ piece: 'default', withSubmodules: true });
|
mockLoadProjectConfig.mockReturnValue({ withSubmodules: true });
|
||||||
const cloneCalls = setupCloneArgsCapture();
|
const cloneCalls = setupCloneArgsCapture();
|
||||||
|
|
||||||
createSharedClone('/project', {
|
createSharedClone('/project', {
|
||||||
@ -647,7 +647,7 @@ describe('clone submodule arguments', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should keep existing clone args when submodule acquisition is disabled', () => {
|
it('should keep existing clone args when submodule acquisition is disabled', () => {
|
||||||
mockLoadProjectConfig.mockReturnValue({ piece: 'default', withSubmodules: false });
|
mockLoadProjectConfig.mockReturnValue({ withSubmodules: false });
|
||||||
const cloneCalls = setupCloneArgsCapture();
|
const cloneCalls = setupCloneArgsCapture();
|
||||||
|
|
||||||
createSharedClone('/project', {
|
createSharedClone('/project', {
|
||||||
|
|||||||
@ -3,8 +3,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|||||||
const mockOpts: Record<string, unknown> = {};
|
const mockOpts: Record<string, unknown> = {};
|
||||||
const mockAddTask = vi.fn();
|
const mockAddTask = vi.fn();
|
||||||
|
|
||||||
const { rootCommand, commandActions } = vi.hoisted(() => {
|
const { rootCommand, commandActions, commandMocks } = vi.hoisted(() => {
|
||||||
const commandActions = new Map<string, (...args: unknown[]) => void>();
|
const commandActions = new Map<string, (...args: unknown[]) => void>();
|
||||||
|
const commandMocks = new Map<string, Record<string, unknown>>();
|
||||||
|
|
||||||
function createCommandMock(actionKey: string): {
|
function createCommandMock(actionKey: string): {
|
||||||
description: ReturnType<typeof vi.fn>;
|
description: ReturnType<typeof vi.fn>;
|
||||||
@ -20,6 +21,7 @@ const { rootCommand, commandActions } = vi.hoisted(() => {
|
|||||||
option: vi.fn().mockReturnThis(),
|
option: vi.fn().mockReturnThis(),
|
||||||
opts: vi.fn(() => mockOpts),
|
opts: vi.fn(() => mockOpts),
|
||||||
};
|
};
|
||||||
|
commandMocks.set(actionKey, command);
|
||||||
|
|
||||||
command.command = vi.fn((subName: string) => createCommandMock(`${actionKey}.${subName}`));
|
command.command = vi.fn((subName: string) => createCommandMock(`${actionKey}.${subName}`));
|
||||||
command.action = vi.fn((action: (...args: unknown[]) => void) => {
|
command.action = vi.fn((action: (...args: unknown[]) => void) => {
|
||||||
@ -40,6 +42,7 @@ const { rootCommand, commandActions } = vi.hoisted(() => {
|
|||||||
return {
|
return {
|
||||||
rootCommand: createCommandMock('root'),
|
rootCommand: createCommandMock('root'),
|
||||||
commandActions,
|
commandActions,
|
||||||
|
commandMocks,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -71,7 +74,6 @@ vi.mock('../features/tasks/index.js', () => ({
|
|||||||
|
|
||||||
vi.mock('../features/config/index.js', () => ({
|
vi.mock('../features/config/index.js', () => ({
|
||||||
clearPersonaSessions: vi.fn(),
|
clearPersonaSessions: vi.fn(),
|
||||||
switchPiece: vi.fn(),
|
|
||||||
ejectBuiltin: vi.fn(),
|
ejectBuiltin: vi.fn(),
|
||||||
ejectFacet: vi.fn(),
|
ejectFacet: vi.fn(),
|
||||||
parseFacetType: vi.fn(),
|
parseFacetType: vi.fn(),
|
||||||
@ -112,7 +114,7 @@ import '../app/cli/commands.js';
|
|||||||
|
|
||||||
describe('CLI add command', () => {
|
describe('CLI add command', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
mockAddTask.mockClear();
|
||||||
for (const key of Object.keys(mockOpts)) {
|
for (const key of Object.keys(mockOpts)) {
|
||||||
delete mockOpts[key];
|
delete mockOpts[key];
|
||||||
}
|
}
|
||||||
@ -141,4 +143,20 @@ describe('CLI add command', () => {
|
|||||||
expect(mockAddTask).toHaveBeenCalledWith('/test/cwd', 'Regular task', undefined);
|
expect(mockAddTask).toHaveBeenCalledWith('/test/cwd', 'Regular task', undefined);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not register switch command', () => {
|
||||||
|
const calledCommandNames = rootCommand.command.mock.calls
|
||||||
|
.map((call: unknown[]) => call[0] as string);
|
||||||
|
|
||||||
|
expect(calledCommandNames).not.toContain('switch');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should describe prompt piece argument as defaulting to "default"', () => {
|
||||||
|
const promptCommand = commandMocks.get('root.prompt');
|
||||||
|
expect(promptCommand).toBeTruthy();
|
||||||
|
expect(promptCommand?.argument).toHaveBeenCalledWith(
|
||||||
|
'[piece]',
|
||||||
|
'Piece name or path (defaults to "default")',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -13,7 +13,6 @@ import {
|
|||||||
loadPiece,
|
loadPiece,
|
||||||
listPieces,
|
listPieces,
|
||||||
loadPersonaPromptFromPath,
|
loadPersonaPromptFromPath,
|
||||||
setCurrentPiece,
|
|
||||||
getProjectConfigDir,
|
getProjectConfigDir,
|
||||||
getBuiltinPersonasDir,
|
getBuiltinPersonasDir,
|
||||||
loadInputHistory,
|
loadInputHistory,
|
||||||
@ -332,69 +331,6 @@ describe('loadPersonaPromptFromPath (builtin paths)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('setCurrentPiece', () => {
|
|
||||||
let testDir: string;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
testDir = join(tmpdir(), `takt-test-${randomUUID()}`);
|
|
||||||
mkdirSync(testDir, { recursive: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (existsSync(testDir)) {
|
|
||||||
rmSync(testDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should save piece name to config.yaml', () => {
|
|
||||||
setCurrentPiece(testDir, 'my-piece');
|
|
||||||
|
|
||||||
const config = loadProjectConfig(testDir);
|
|
||||||
|
|
||||||
expect(config.piece).toBe('my-piece');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create config directory if not exists', () => {
|
|
||||||
const configDir = getProjectConfigDir(testDir);
|
|
||||||
expect(existsSync(configDir)).toBe(false);
|
|
||||||
|
|
||||||
setCurrentPiece(testDir, 'test');
|
|
||||||
|
|
||||||
expect(existsSync(configDir)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should overwrite existing piece name', () => {
|
|
||||||
setCurrentPiece(testDir, 'first');
|
|
||||||
setCurrentPiece(testDir, 'second');
|
|
||||||
|
|
||||||
const piece = loadProjectConfig(testDir).piece;
|
|
||||||
|
|
||||||
expect(piece).toBe('second');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should preserve provider_options when updating piece', () => {
|
|
||||||
const configDir = getProjectConfigDir(testDir);
|
|
||||||
mkdirSync(configDir, { recursive: true });
|
|
||||||
writeFileSync(
|
|
||||||
join(configDir, 'config.yaml'),
|
|
||||||
[
|
|
||||||
'piece: first',
|
|
||||||
'provider_options:',
|
|
||||||
' codex:',
|
|
||||||
' network_access: true',
|
|
||||||
].join('\n'),
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
|
|
||||||
setCurrentPiece(testDir, 'updated');
|
|
||||||
|
|
||||||
const saved = readFileSync(join(configDir, 'config.yaml'), 'utf-8');
|
|
||||||
expect(saved).toContain('piece: updated');
|
|
||||||
expect(saved).toContain('provider_options:');
|
|
||||||
expect(saved).toContain('network_access: true');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('loadProjectConfig provider_options', () => {
|
describe('loadProjectConfig provider_options', () => {
|
||||||
let testDir: string;
|
let testDir: string;
|
||||||
|
|
||||||
@ -413,7 +349,6 @@ describe('loadProjectConfig provider_options', () => {
|
|||||||
const projectConfigDir = getProjectConfigDir(testDir);
|
const projectConfigDir = getProjectConfigDir(testDir);
|
||||||
mkdirSync(projectConfigDir, { recursive: true });
|
mkdirSync(projectConfigDir, { recursive: true });
|
||||||
writeFileSync(join(projectConfigDir, 'config.yaml'), [
|
writeFileSync(join(projectConfigDir, 'config.yaml'), [
|
||||||
'piece: default',
|
|
||||||
'provider_options:',
|
'provider_options:',
|
||||||
' codex:',
|
' codex:',
|
||||||
' network_access: true',
|
' network_access: true',
|
||||||
@ -450,7 +385,6 @@ describe('loadProjectConfig provider_options', () => {
|
|||||||
const projectConfigDir = getProjectConfigDir(testDir);
|
const projectConfigDir = getProjectConfigDir(testDir);
|
||||||
mkdirSync(projectConfigDir, { recursive: true });
|
mkdirSync(projectConfigDir, { recursive: true });
|
||||||
writeFileSync(join(projectConfigDir, 'config.yaml'), [
|
writeFileSync(join(projectConfigDir, 'config.yaml'), [
|
||||||
'piece: default',
|
|
||||||
'provider:',
|
'provider:',
|
||||||
' type: claude',
|
' type: claude',
|
||||||
' network_access: true',
|
' network_access: true',
|
||||||
@ -463,7 +397,6 @@ describe('loadProjectConfig provider_options', () => {
|
|||||||
const projectConfigDir = getProjectConfigDir(testDir);
|
const projectConfigDir = getProjectConfigDir(testDir);
|
||||||
mkdirSync(projectConfigDir, { recursive: true });
|
mkdirSync(projectConfigDir, { recursive: true });
|
||||||
writeFileSync(join(projectConfigDir, 'config.yaml'), [
|
writeFileSync(join(projectConfigDir, 'config.yaml'), [
|
||||||
'piece: default',
|
|
||||||
'provider:',
|
'provider:',
|
||||||
' type: codex',
|
' type: codex',
|
||||||
' model: gpt-5.3',
|
' model: gpt-5.3',
|
||||||
@ -483,7 +416,6 @@ describe('loadProjectConfig provider_options', () => {
|
|||||||
const projectConfigDir = getProjectConfigDir(testDir);
|
const projectConfigDir = getProjectConfigDir(testDir);
|
||||||
mkdirSync(projectConfigDir, { recursive: true });
|
mkdirSync(projectConfigDir, { recursive: true });
|
||||||
writeFileSync(join(projectConfigDir, 'config.yaml'), [
|
writeFileSync(join(projectConfigDir, 'config.yaml'), [
|
||||||
'piece: default',
|
|
||||||
'provider:',
|
'provider:',
|
||||||
' type: codex',
|
' type: codex',
|
||||||
' sandbox:',
|
' sandbox:',
|
||||||
@ -497,7 +429,6 @@ describe('loadProjectConfig provider_options', () => {
|
|||||||
const projectConfigDir = getProjectConfigDir(testDir);
|
const projectConfigDir = getProjectConfigDir(testDir);
|
||||||
mkdirSync(projectConfigDir, { recursive: true });
|
mkdirSync(projectConfigDir, { recursive: true });
|
||||||
writeFileSync(join(projectConfigDir, 'config.yaml'), [
|
writeFileSync(join(projectConfigDir, 'config.yaml'), [
|
||||||
'piece: default',
|
|
||||||
'provider:',
|
'provider:',
|
||||||
' type: codex',
|
' type: codex',
|
||||||
' unknown_option: true',
|
' unknown_option: true',
|
||||||
@ -510,7 +441,6 @@ describe('loadProjectConfig provider_options', () => {
|
|||||||
const projectConfigDir = getProjectConfigDir(testDir);
|
const projectConfigDir = getProjectConfigDir(testDir);
|
||||||
mkdirSync(projectConfigDir, { recursive: true });
|
mkdirSync(projectConfigDir, { recursive: true });
|
||||||
writeFileSync(join(projectConfigDir, 'config.yaml'), [
|
writeFileSync(join(projectConfigDir, 'config.yaml'), [
|
||||||
'piece: default',
|
|
||||||
'provider: invalid-provider',
|
'provider: invalid-provider',
|
||||||
].join('\n'));
|
].join('\n'));
|
||||||
|
|
||||||
@ -571,7 +501,6 @@ describe('analytics config resolution', () => {
|
|||||||
const projectConfigDir = getProjectConfigDir(testDir);
|
const projectConfigDir = getProjectConfigDir(testDir);
|
||||||
mkdirSync(projectConfigDir, { recursive: true });
|
mkdirSync(projectConfigDir, { recursive: true });
|
||||||
writeFileSync(join(projectConfigDir, 'config.yaml'), [
|
writeFileSync(join(projectConfigDir, 'config.yaml'), [
|
||||||
'piece: default',
|
|
||||||
'analytics:',
|
'analytics:',
|
||||||
' enabled: false',
|
' enabled: false',
|
||||||
' events_path: .takt/project-analytics/events',
|
' events_path: .takt/project-analytics/events',
|
||||||
@ -614,7 +543,6 @@ describe('analytics config resolution', () => {
|
|||||||
const projectConfigDir = getProjectConfigDir(testDir);
|
const projectConfigDir = getProjectConfigDir(testDir);
|
||||||
mkdirSync(projectConfigDir, { recursive: true });
|
mkdirSync(projectConfigDir, { recursive: true });
|
||||||
writeFileSync(join(projectConfigDir, 'config.yaml'), [
|
writeFileSync(join(projectConfigDir, 'config.yaml'), [
|
||||||
'piece: default',
|
|
||||||
'analytics:',
|
'analytics:',
|
||||||
' events_path: /tmp/project-analytics',
|
' events_path: /tmp/project-analytics',
|
||||||
' retention_days: 14',
|
' retention_days: 14',
|
||||||
@ -664,7 +592,7 @@ describe('isVerboseMode', () => {
|
|||||||
it('should return project verbose when project config has verbose: true', () => {
|
it('should return project verbose when project config has verbose: true', () => {
|
||||||
const projectConfigDir = getProjectConfigDir(testDir);
|
const projectConfigDir = getProjectConfigDir(testDir);
|
||||||
mkdirSync(projectConfigDir, { recursive: true });
|
mkdirSync(projectConfigDir, { recursive: true });
|
||||||
writeFileSync(join(projectConfigDir, 'config.yaml'), 'piece: default\nverbose: true\n');
|
writeFileSync(join(projectConfigDir, 'config.yaml'), 'verbose: true\n');
|
||||||
|
|
||||||
const globalConfigDir = process.env.TAKT_CONFIG_DIR!;
|
const globalConfigDir = process.env.TAKT_CONFIG_DIR!;
|
||||||
mkdirSync(globalConfigDir, { recursive: true });
|
mkdirSync(globalConfigDir, { recursive: true });
|
||||||
@ -676,7 +604,7 @@ describe('isVerboseMode', () => {
|
|||||||
it('should return project verbose when project config has verbose: false', () => {
|
it('should return project verbose when project config has verbose: false', () => {
|
||||||
const projectConfigDir = getProjectConfigDir(testDir);
|
const projectConfigDir = getProjectConfigDir(testDir);
|
||||||
mkdirSync(projectConfigDir, { recursive: true });
|
mkdirSync(projectConfigDir, { recursive: true });
|
||||||
writeFileSync(join(projectConfigDir, 'config.yaml'), 'piece: default\nverbose: false\n');
|
writeFileSync(join(projectConfigDir, 'config.yaml'), 'verbose: false\n');
|
||||||
|
|
||||||
const globalConfigDir = process.env.TAKT_CONFIG_DIR!;
|
const globalConfigDir = process.env.TAKT_CONFIG_DIR!;
|
||||||
mkdirSync(globalConfigDir, { recursive: true });
|
mkdirSync(globalConfigDir, { recursive: true });
|
||||||
@ -688,7 +616,7 @@ describe('isVerboseMode', () => {
|
|||||||
it('should use default verbose=false when project verbose is not set', () => {
|
it('should use default verbose=false when project verbose is not set', () => {
|
||||||
const projectConfigDir = getProjectConfigDir(testDir);
|
const projectConfigDir = getProjectConfigDir(testDir);
|
||||||
mkdirSync(projectConfigDir, { recursive: true });
|
mkdirSync(projectConfigDir, { recursive: true });
|
||||||
writeFileSync(join(projectConfigDir, 'config.yaml'), 'piece: default\n');
|
writeFileSync(join(projectConfigDir, 'config.yaml'), '');
|
||||||
|
|
||||||
const globalConfigDir = process.env.TAKT_CONFIG_DIR!;
|
const globalConfigDir = process.env.TAKT_CONFIG_DIR!;
|
||||||
mkdirSync(globalConfigDir, { recursive: true });
|
mkdirSync(globalConfigDir, { recursive: true });
|
||||||
@ -704,7 +632,7 @@ describe('isVerboseMode', () => {
|
|||||||
it('should prioritize TAKT_VERBOSE over project and global config', () => {
|
it('should prioritize TAKT_VERBOSE over project and global config', () => {
|
||||||
const projectConfigDir = getProjectConfigDir(testDir);
|
const projectConfigDir = getProjectConfigDir(testDir);
|
||||||
mkdirSync(projectConfigDir, { recursive: true });
|
mkdirSync(projectConfigDir, { recursive: true });
|
||||||
writeFileSync(join(projectConfigDir, 'config.yaml'), 'piece: default\nverbose: false\n');
|
writeFileSync(join(projectConfigDir, 'config.yaml'), 'verbose: false\n');
|
||||||
|
|
||||||
const globalConfigDir = process.env.TAKT_CONFIG_DIR!;
|
const globalConfigDir = process.env.TAKT_CONFIG_DIR!;
|
||||||
mkdirSync(globalConfigDir, { recursive: true });
|
mkdirSync(globalConfigDir, { recursive: true });
|
||||||
@ -954,7 +882,7 @@ describe('saveProjectConfig - gitignore copy', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should copy .gitignore when creating new config', () => {
|
it('should copy .gitignore when creating new config', () => {
|
||||||
setCurrentPiece(testDir, 'test');
|
saveProjectConfig(testDir, {});
|
||||||
|
|
||||||
const configDir = getProjectConfigDir(testDir);
|
const configDir = getProjectConfigDir(testDir);
|
||||||
const gitignorePath = join(configDir, '.gitignore');
|
const gitignorePath = join(configDir, '.gitignore');
|
||||||
@ -966,10 +894,10 @@ describe('saveProjectConfig - gitignore copy', () => {
|
|||||||
// Create config directory without .gitignore
|
// Create config directory without .gitignore
|
||||||
const configDir = getProjectConfigDir(testDir);
|
const configDir = getProjectConfigDir(testDir);
|
||||||
mkdirSync(configDir, { recursive: true });
|
mkdirSync(configDir, { recursive: true });
|
||||||
writeFileSync(join(configDir, 'config.yaml'), 'piece: existing\n');
|
writeFileSync(join(configDir, 'config.yaml'), '');
|
||||||
|
|
||||||
// Save config should still copy .gitignore
|
// Save config should still copy .gitignore
|
||||||
setCurrentPiece(testDir, 'updated');
|
saveProjectConfig(testDir, {});
|
||||||
|
|
||||||
const gitignorePath = join(configDir, '.gitignore');
|
const gitignorePath = join(configDir, '.gitignore');
|
||||||
expect(existsSync(gitignorePath)).toBe(true);
|
expect(existsSync(gitignorePath)).toBe(true);
|
||||||
@ -981,7 +909,7 @@ describe('saveProjectConfig - gitignore copy', () => {
|
|||||||
const customContent = '# Custom gitignore\nmy-custom-file';
|
const customContent = '# Custom gitignore\nmy-custom-file';
|
||||||
writeFileSync(join(configDir, '.gitignore'), customContent);
|
writeFileSync(join(configDir, '.gitignore'), customContent);
|
||||||
|
|
||||||
setCurrentPiece(testDir, 'test');
|
saveProjectConfig(testDir, {});
|
||||||
|
|
||||||
const gitignorePath = join(configDir, '.gitignore');
|
const gitignorePath = join(configDir, '.gitignore');
|
||||||
const content = readFileSync(gitignorePath, 'utf-8');
|
const content = readFileSync(gitignorePath, 'utf-8');
|
||||||
@ -1436,7 +1364,7 @@ describe('saveProjectConfig snake_case denormalization', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should persist autoPr as auto_pr and reload correctly', () => {
|
it('should persist autoPr as auto_pr and reload correctly', () => {
|
||||||
saveProjectConfig(testDir, { piece: 'default', autoPr: true });
|
saveProjectConfig(testDir, { autoPr: true });
|
||||||
|
|
||||||
const saved = loadProjectConfig(testDir);
|
const saved = loadProjectConfig(testDir);
|
||||||
|
|
||||||
@ -1445,7 +1373,7 @@ describe('saveProjectConfig snake_case denormalization', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should persist draftPr as draft_pr and reload correctly', () => {
|
it('should persist draftPr as draft_pr and reload correctly', () => {
|
||||||
saveProjectConfig(testDir, { piece: 'default', draftPr: true });
|
saveProjectConfig(testDir, { draftPr: true });
|
||||||
|
|
||||||
const saved = loadProjectConfig(testDir);
|
const saved = loadProjectConfig(testDir);
|
||||||
|
|
||||||
@ -1454,7 +1382,7 @@ describe('saveProjectConfig snake_case denormalization', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should persist baseBranch as base_branch and reload correctly', () => {
|
it('should persist baseBranch as base_branch and reload correctly', () => {
|
||||||
saveProjectConfig(testDir, { piece: 'default', baseBranch: 'main' });
|
saveProjectConfig(testDir, { baseBranch: 'main' });
|
||||||
|
|
||||||
const saved = loadProjectConfig(testDir);
|
const saved = loadProjectConfig(testDir);
|
||||||
|
|
||||||
@ -1463,7 +1391,7 @@ describe('saveProjectConfig snake_case denormalization', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should persist withSubmodules as with_submodules and reload correctly', () => {
|
it('should persist withSubmodules as with_submodules and reload correctly', () => {
|
||||||
saveProjectConfig(testDir, { piece: 'default', withSubmodules: true });
|
saveProjectConfig(testDir, { withSubmodules: true });
|
||||||
|
|
||||||
const saved = loadProjectConfig(testDir);
|
const saved = loadProjectConfig(testDir);
|
||||||
|
|
||||||
@ -1472,7 +1400,7 @@ describe('saveProjectConfig snake_case denormalization', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should persist submodules and ignore with_submodules when both are provided', () => {
|
it('should persist submodules and ignore with_submodules when both are provided', () => {
|
||||||
saveProjectConfig(testDir, { piece: 'default', submodules: ['path/a'], withSubmodules: true });
|
saveProjectConfig(testDir, { submodules: ['path/a'], withSubmodules: true });
|
||||||
|
|
||||||
const projectConfigDir = getProjectConfigDir(testDir);
|
const projectConfigDir = getProjectConfigDir(testDir);
|
||||||
const content = readFileSync(join(projectConfigDir, 'config.yaml'), 'utf-8');
|
const content = readFileSync(join(projectConfigDir, 'config.yaml'), 'utf-8');
|
||||||
@ -1485,7 +1413,7 @@ describe('saveProjectConfig snake_case denormalization', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should persist concurrency and reload correctly', () => {
|
it('should persist concurrency and reload correctly', () => {
|
||||||
saveProjectConfig(testDir, { piece: 'default', concurrency: 3 });
|
saveProjectConfig(testDir, { concurrency: 3 });
|
||||||
|
|
||||||
const saved = loadProjectConfig(testDir);
|
const saved = loadProjectConfig(testDir);
|
||||||
|
|
||||||
@ -1493,7 +1421,7 @@ describe('saveProjectConfig snake_case denormalization', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not write camelCase keys to YAML file', () => {
|
it('should not write camelCase keys to YAML file', () => {
|
||||||
saveProjectConfig(testDir, { piece: 'default', autoPr: true, draftPr: false, baseBranch: 'develop' });
|
saveProjectConfig(testDir, { autoPr: true, draftPr: false, baseBranch: 'develop' });
|
||||||
|
|
||||||
const projectConfigDir = getProjectConfigDir(testDir);
|
const projectConfigDir = getProjectConfigDir(testDir);
|
||||||
const content = readFileSync(join(projectConfigDir, 'config.yaml'), 'utf-8');
|
const content = readFileSync(join(projectConfigDir, 'config.yaml'), 'utf-8');
|
||||||
|
|||||||
@ -108,7 +108,7 @@ describe('IT: migrated config keys should prefer project over global', () => {
|
|||||||
it('should resolve migrated keys from global when project config does not set them', () => {
|
it('should resolve migrated keys from global when project config does not set them', () => {
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(projectDir, '.takt', 'config.yaml'),
|
join(projectDir, '.takt', 'config.yaml'),
|
||||||
'piece: default\n',
|
'',
|
||||||
'utf-8',
|
'utf-8',
|
||||||
);
|
);
|
||||||
invalidateGlobalConfigCache();
|
invalidateGlobalConfigCache();
|
||||||
@ -142,7 +142,7 @@ describe('IT: migrated config keys should prefer project over global', () => {
|
|||||||
it('should mark migrated key source as global when only global defines the key', () => {
|
it('should mark migrated key source as global when only global defines the key', () => {
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(projectDir, '.takt', 'config.yaml'),
|
join(projectDir, '.takt', 'config.yaml'),
|
||||||
'piece: default\n',
|
'',
|
||||||
'utf-8',
|
'utf-8',
|
||||||
);
|
);
|
||||||
invalidateGlobalConfigCache();
|
invalidateGlobalConfigCache();
|
||||||
|
|||||||
@ -36,6 +36,8 @@ import { TaskRunner } from '../infra/task/index.js';
|
|||||||
import { runAgent } from '../agents/runner.js';
|
import { runAgent } from '../agents/runner.js';
|
||||||
import { invalidateGlobalConfigCache } from '../infra/config/index.js';
|
import { invalidateGlobalConfigCache } from '../infra/config/index.js';
|
||||||
|
|
||||||
|
const runAllTasksNoPiece = runAllTasks as (projectCwd: string) => ReturnType<typeof runAllTasks>;
|
||||||
|
|
||||||
interface TestEnv {
|
interface TestEnv {
|
||||||
root: string;
|
root: string;
|
||||||
projectDir: string;
|
projectDir: string;
|
||||||
@ -107,7 +109,7 @@ describe('IT: runAllTasks provider_options reflection', () => {
|
|||||||
vi.mocked(runAgent).mockResolvedValue(mockDoneResponse());
|
vi.mocked(runAgent).mockResolvedValue(mockDoneResponse());
|
||||||
|
|
||||||
const runner = new TaskRunner(env.projectDir);
|
const runner = new TaskRunner(env.projectDir);
|
||||||
runner.addTask('test task');
|
runner.addTask('test task', { piece: 'run-config-it' });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -137,7 +139,7 @@ describe('IT: runAllTasks provider_options reflection', () => {
|
|||||||
' network_access: false',
|
' network_access: false',
|
||||||
].join('\n'));
|
].join('\n'));
|
||||||
|
|
||||||
await runAllTasks(env.projectDir, 'run-config-it');
|
await runAllTasksNoPiece(env.projectDir);
|
||||||
|
|
||||||
const options = vi.mocked(runAgent).mock.calls[0]?.[2];
|
const options = vi.mocked(runAgent).mock.calls[0]?.[2];
|
||||||
expect(options?.providerOptions).toEqual({
|
expect(options?.providerOptions).toEqual({
|
||||||
@ -159,7 +161,7 @@ describe('IT: runAllTasks provider_options reflection', () => {
|
|||||||
process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS = 'true';
|
process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS = 'true';
|
||||||
invalidateGlobalConfigCache();
|
invalidateGlobalConfigCache();
|
||||||
|
|
||||||
await runAllTasks(env.projectDir, 'run-config-it');
|
await runAllTasksNoPiece(env.projectDir);
|
||||||
|
|
||||||
const options = vi.mocked(runAgent).mock.calls[0]?.[2];
|
const options = vi.mocked(runAgent).mock.calls[0]?.[2];
|
||||||
expect(options?.providerOptions).toEqual({
|
expect(options?.providerOptions).toEqual({
|
||||||
@ -167,4 +169,3 @@ describe('IT: runAllTasks provider_options reflection', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -50,6 +50,9 @@ function createTask(name: string): TaskInfo {
|
|||||||
name,
|
name,
|
||||||
content: `Task: ${name}`,
|
content: `Task: ${name}`,
|
||||||
filePath: `/tasks/${name}.yaml`,
|
filePath: `/tasks/${name}.yaml`,
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
status: 'pending',
|
||||||
|
data: { task: `Task: ${name}`, piece: 'default' },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,14 +91,14 @@ describe('worker pool: abort signal propagation', () => {
|
|||||||
const receivedSignals: (AbortSignal | undefined)[] = [];
|
const receivedSignals: (AbortSignal | undefined)[] = [];
|
||||||
|
|
||||||
mockExecuteAndCompleteTask.mockImplementation(
|
mockExecuteAndCompleteTask.mockImplementation(
|
||||||
(_task: unknown, _runner: unknown, _cwd: unknown, _piece: unknown, _opts: unknown, parallelOpts: { abortSignal?: AbortSignal }) => {
|
(_task: unknown, _runner: unknown, _cwd: unknown, _opts: unknown, parallelOpts: { abortSignal?: AbortSignal }) => {
|
||||||
receivedSignals.push(parallelOpts?.abortSignal);
|
receivedSignals.push(parallelOpts?.abortSignal);
|
||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
await runWithWorkerPool(runner as never, tasks, 1, '/cwd', 'default', undefined, 50);
|
await runWithWorkerPool(runner as never, tasks, 1, '/cwd', undefined, 50);
|
||||||
|
|
||||||
// Then: AbortSignal is passed even with concurrency=1
|
// Then: AbortSignal is passed even with concurrency=1
|
||||||
expect(receivedSignals).toHaveLength(1);
|
expect(receivedSignals).toHaveLength(1);
|
||||||
@ -109,7 +112,7 @@ describe('worker pool: abort signal propagation', () => {
|
|||||||
let capturedSignal: AbortSignal | undefined;
|
let capturedSignal: AbortSignal | undefined;
|
||||||
|
|
||||||
mockExecuteAndCompleteTask.mockImplementation(
|
mockExecuteAndCompleteTask.mockImplementation(
|
||||||
(_task: unknown, _runner: unknown, _cwd: unknown, _piece: unknown, _opts: unknown, parallelOpts: { abortSignal?: AbortSignal }) => {
|
(_task: unknown, _runner: unknown, _cwd: unknown, _opts: unknown, parallelOpts: { abortSignal?: AbortSignal }) => {
|
||||||
capturedSignal = parallelOpts?.abortSignal;
|
capturedSignal = parallelOpts?.abortSignal;
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
// Wait long enough for SIGINT to fire
|
// Wait long enough for SIGINT to fire
|
||||||
@ -119,7 +122,7 @@ describe('worker pool: abort signal propagation', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Start execution
|
// Start execution
|
||||||
const resultPromise = runWithWorkerPool(runner as never, tasks, 1, '/cwd', 'default', undefined, 50);
|
const resultPromise = runWithWorkerPool(runner as never, tasks, 1, '/cwd', undefined, 50);
|
||||||
|
|
||||||
// Wait for task to start
|
// Wait for task to start
|
||||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
@ -149,25 +152,25 @@ describe('worker pool: abort signal propagation', () => {
|
|||||||
const receivedSignalsPar: (AbortSignal | undefined)[] = [];
|
const receivedSignalsPar: (AbortSignal | undefined)[] = [];
|
||||||
|
|
||||||
mockExecuteAndCompleteTask.mockImplementation(
|
mockExecuteAndCompleteTask.mockImplementation(
|
||||||
(_task: unknown, _runner: unknown, _cwd: unknown, _piece: unknown, _opts: unknown, parallelOpts: { abortSignal?: AbortSignal }) => {
|
(_task: unknown, _runner: unknown, _cwd: unknown, _opts: unknown, parallelOpts: { abortSignal?: AbortSignal }) => {
|
||||||
receivedSignalsSeq.push(parallelOpts?.abortSignal);
|
receivedSignalsSeq.push(parallelOpts?.abortSignal);
|
||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sequential mode
|
// Sequential mode
|
||||||
await runWithWorkerPool(runner as never, [...tasks], 1, '/cwd', 'default', undefined, 50);
|
await runWithWorkerPool(runner as never, [...tasks], 1, '/cwd', undefined, 50);
|
||||||
|
|
||||||
mockExecuteAndCompleteTask.mockClear();
|
mockExecuteAndCompleteTask.mockClear();
|
||||||
mockExecuteAndCompleteTask.mockImplementation(
|
mockExecuteAndCompleteTask.mockImplementation(
|
||||||
(_task: unknown, _runner: unknown, _cwd: unknown, _piece: unknown, _opts: unknown, parallelOpts: { abortSignal?: AbortSignal }) => {
|
(_task: unknown, _runner: unknown, _cwd: unknown, _opts: unknown, parallelOpts: { abortSignal?: AbortSignal }) => {
|
||||||
receivedSignalsPar.push(parallelOpts?.abortSignal);
|
receivedSignalsPar.push(parallelOpts?.abortSignal);
|
||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Parallel mode
|
// Parallel mode
|
||||||
await runWithWorkerPool(runner as never, [...tasks], 2, '/cwd', 'default', undefined, 50);
|
await runWithWorkerPool(runner as never, [...tasks], 2, '/cwd', undefined, 50);
|
||||||
|
|
||||||
// Then: Both modes pass AbortSignal
|
// Then: Both modes pass AbortSignal
|
||||||
for (const signal of receivedSignalsSeq) {
|
for (const signal of receivedSignalsSeq) {
|
||||||
|
|||||||
@ -252,22 +252,19 @@ describe('2-stage category selection helpers', () => {
|
|||||||
|
|
||||||
describe('buildTopLevelSelectOptions', () => {
|
describe('buildTopLevelSelectOptions', () => {
|
||||||
it('should encode categories with prefix in value', () => {
|
it('should encode categories with prefix in value', () => {
|
||||||
const options = buildTopLevelSelectOptions(items, '');
|
const options = buildTopLevelSelectOptions(items);
|
||||||
const categoryOption = options.find((o) => o.label.includes('frontend'));
|
const categoryOption = options.find((o) => o.label.includes('frontend'));
|
||||||
expect(categoryOption).toBeDefined();
|
expect(categoryOption).toBeDefined();
|
||||||
expect(categoryOption!.value).toBe('__category__:frontend');
|
expect(categoryOption!.value).toBe('__category__:frontend');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should mark current piece', () => {
|
it('should not include legacy current markers in labels or values', () => {
|
||||||
const options = buildTopLevelSelectOptions(items, 'simple');
|
const options = buildTopLevelSelectOptions(items);
|
||||||
const simpleOption = options.find((o) => o.value === 'simple');
|
const labels = options.map((o) => o.label);
|
||||||
expect(simpleOption!.label).toContain('(current)');
|
const values = options.map((o) => o.value);
|
||||||
});
|
|
||||||
|
|
||||||
it('should mark category containing current piece', () => {
|
expect(labels.some((label) => label.includes('(current)'))).toBe(false);
|
||||||
const options = buildTopLevelSelectOptions(items, 'frontend/react');
|
expect(values).not.toContain('__current__');
|
||||||
const frontendOption = options.find((o) => o.value === '__category__:frontend');
|
|
||||||
expect(frontendOption!.label).toContain('(current)');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -283,21 +280,15 @@ describe('2-stage category selection helpers', () => {
|
|||||||
|
|
||||||
describe('buildCategoryPieceOptions', () => {
|
describe('buildCategoryPieceOptions', () => {
|
||||||
it('should return options for pieces in a category', () => {
|
it('should return options for pieces in a category', () => {
|
||||||
const options = buildCategoryPieceOptions(items, 'frontend', '');
|
const options = buildCategoryPieceOptions(items, 'frontend');
|
||||||
expect(options).not.toBeNull();
|
expect(options).not.toBeNull();
|
||||||
expect(options).toHaveLength(2);
|
expect(options).toHaveLength(2);
|
||||||
expect(options![0]!.value).toBe('frontend/react');
|
expect(options![0]!.value).toBe('frontend/react');
|
||||||
expect(options![0]!.label).toBe('react');
|
expect(options![0]!.label).toBe('react');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should mark current piece in category', () => {
|
|
||||||
const options = buildCategoryPieceOptions(items, 'frontend', 'frontend/vue');
|
|
||||||
const vueOption = options!.find((o) => o.value === 'frontend/vue');
|
|
||||||
expect(vueOption!.label).toContain('(current)');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null for non-existent category', () => {
|
it('should return null for non-existent category', () => {
|
||||||
expect(buildCategoryPieceOptions(items, 'nonexistent', '')).toBeNull();
|
expect(buildCategoryPieceOptions(items, 'nonexistent')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -39,8 +39,6 @@ const configMock = vi.hoisted(() => ({
|
|||||||
loadAllPiecesWithSources: vi.fn(),
|
loadAllPiecesWithSources: vi.fn(),
|
||||||
getPieceCategories: vi.fn(),
|
getPieceCategories: vi.fn(),
|
||||||
buildCategorizedPieces: vi.fn(),
|
buildCategorizedPieces: vi.fn(),
|
||||||
getCurrentPiece: vi.fn(),
|
|
||||||
resolveConfigValue: vi.fn(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../infra/config/index.js', () => configMock);
|
vi.mock('../infra/config/index.js', () => configMock);
|
||||||
@ -63,7 +61,7 @@ describe('selectPieceFromEntries', () => {
|
|||||||
.mockResolvedValueOnce('custom')
|
.mockResolvedValueOnce('custom')
|
||||||
.mockResolvedValueOnce('custom-flow');
|
.mockResolvedValueOnce('custom-flow');
|
||||||
|
|
||||||
const selected = await selectPieceFromEntries(entries, '');
|
const selected = await selectPieceFromEntries(entries);
|
||||||
expect(selected).toBe('custom-flow');
|
expect(selected).toBe('custom-flow');
|
||||||
expect(selectOptionMock).toHaveBeenCalledTimes(2);
|
expect(selectOptionMock).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
@ -75,7 +73,7 @@ describe('selectPieceFromEntries', () => {
|
|||||||
|
|
||||||
selectOptionMock.mockResolvedValueOnce('builtin-flow');
|
selectOptionMock.mockResolvedValueOnce('builtin-flow');
|
||||||
|
|
||||||
const selected = await selectPieceFromEntries(entries, '');
|
const selected = await selectPieceFromEntries(entries);
|
||||||
expect(selected).toBe('builtin-flow');
|
expect(selected).toBe('builtin-flow');
|
||||||
expect(selectOptionMock).toHaveBeenCalledTimes(1);
|
expect(selectOptionMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
@ -116,19 +114,24 @@ describe('selectPieceFromCategorizedPieces', () => {
|
|||||||
missingPieces: [],
|
missingPieces: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
selectOptionMock.mockResolvedValueOnce('__current__');
|
selectOptionMock
|
||||||
|
.mockResolvedValueOnce('__custom_category__:My Pieces')
|
||||||
|
.mockResolvedValueOnce('my-piece');
|
||||||
|
|
||||||
await selectPieceFromCategorizedPieces(categorized, 'my-piece');
|
await selectPieceFromCategorizedPieces(categorized);
|
||||||
|
|
||||||
const firstCallOptions = selectOptionMock.mock.calls[0]![1] as { label: string; value: string }[];
|
const firstCallOptions = selectOptionMock.mock.calls[0]![1] as { label: string; value: string }[];
|
||||||
const labels = firstCallOptions.map((o) => o.label);
|
const labels = firstCallOptions.map((o) => o.label);
|
||||||
|
const values = firstCallOptions.map((o) => o.value);
|
||||||
|
|
||||||
expect(labels[0]).toBe('🎼 my-piece (current)');
|
expect(labels.some((l) => l.includes('My Pieces'))).toBe(true);
|
||||||
expect(labels.some((l) => l.includes('My Pieces'))).toBe(true);
|
expect(labels.some((l) => l.includes('My Pieces'))).toBe(true);
|
||||||
expect(labels.some((l) => l.includes('Quick Start'))).toBe(true);
|
expect(labels.some((l) => l.includes('Quick Start'))).toBe(true);
|
||||||
|
expect(labels.some((l) => l.includes('(current)'))).toBe(false);
|
||||||
|
expect(values).not.toContain('__current__');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show current piece and bookmarks above categories', async () => {
|
it('should show bookmarked pieces', async () => {
|
||||||
bookmarkState.bookmarks = ['research'];
|
bookmarkState.bookmarks = ['research'];
|
||||||
|
|
||||||
const categorized: CategorizedPieces = {
|
const categorized: CategorizedPieces = {
|
||||||
@ -142,17 +145,15 @@ describe('selectPieceFromCategorizedPieces', () => {
|
|||||||
missingPieces: [],
|
missingPieces: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
selectOptionMock.mockResolvedValueOnce('__current__');
|
selectOptionMock.mockResolvedValueOnce('research');
|
||||||
|
|
||||||
const selected = await selectPieceFromCategorizedPieces(categorized, 'default');
|
const selected = await selectPieceFromCategorizedPieces(categorized);
|
||||||
expect(selected).toBe('default');
|
expect(selected).toBe('research');
|
||||||
|
|
||||||
const firstCallOptions = selectOptionMock.mock.calls[0]![1] as { label: string; value: string }[];
|
const firstCallOptions = selectOptionMock.mock.calls[0]![1] as { label: string; value: string }[];
|
||||||
const labels = firstCallOptions.map((o) => o.label);
|
const labels = firstCallOptions.map((o) => o.label);
|
||||||
|
|
||||||
// Current piece first, bookmarks second, categories after
|
expect(labels.some((l) => l.includes('research [*]'))).toBe(true);
|
||||||
expect(labels[0]).toBe('🎼 default (current)');
|
|
||||||
expect(labels[1]).toBe('🎼 research [*]');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should navigate into a category and select a piece', async () => {
|
it('should navigate into a category and select a piece', async () => {
|
||||||
@ -171,7 +172,7 @@ describe('selectPieceFromCategorizedPieces', () => {
|
|||||||
.mockResolvedValueOnce('__custom_category__:Dev')
|
.mockResolvedValueOnce('__custom_category__:Dev')
|
||||||
.mockResolvedValueOnce('my-piece');
|
.mockResolvedValueOnce('my-piece');
|
||||||
|
|
||||||
const selected = await selectPieceFromCategorizedPieces(categorized, '');
|
const selected = await selectPieceFromCategorizedPieces(categorized);
|
||||||
expect(selected).toBe('my-piece');
|
expect(selected).toBe('my-piece');
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -200,7 +201,7 @@ describe('selectPieceFromCategorizedPieces', () => {
|
|||||||
.mockResolvedValueOnce('__category__:Quick Start')
|
.mockResolvedValueOnce('__category__:Quick Start')
|
||||||
.mockResolvedValueOnce('hybrid-default');
|
.mockResolvedValueOnce('hybrid-default');
|
||||||
|
|
||||||
const selected = await selectPieceFromCategorizedPieces(categorized, '');
|
const selected = await selectPieceFromCategorizedPieces(categorized);
|
||||||
expect(selected).toBe('hybrid-default');
|
expect(selected).toBe('hybrid-default');
|
||||||
expect(selectOptionMock).toHaveBeenCalledTimes(3);
|
expect(selectOptionMock).toHaveBeenCalledTimes(3);
|
||||||
});
|
});
|
||||||
@ -228,7 +229,7 @@ describe('selectPieceFromCategorizedPieces', () => {
|
|||||||
.mockResolvedValueOnce('__custom_category__:Dev')
|
.mockResolvedValueOnce('__custom_category__:Dev')
|
||||||
.mockResolvedValueOnce('base-piece');
|
.mockResolvedValueOnce('base-piece');
|
||||||
|
|
||||||
const selected = await selectPieceFromCategorizedPieces(categorized, '');
|
const selected = await selectPieceFromCategorizedPieces(categorized);
|
||||||
expect(selected).toBe('base-piece');
|
expect(selected).toBe('base-piece');
|
||||||
|
|
||||||
// Second call should show Advanced subcategory AND base-piece at same level
|
// Second call should show Advanced subcategory AND base-piece at same level
|
||||||
@ -268,7 +269,7 @@ describe('selectPieceFromCategorizedPieces', () => {
|
|||||||
.mockResolvedValueOnce('__category__:Quick Start')
|
.mockResolvedValueOnce('__category__:Quick Start')
|
||||||
.mockResolvedValueOnce('default');
|
.mockResolvedValueOnce('default');
|
||||||
|
|
||||||
const selected = await selectPieceFromCategorizedPieces(categorized, '');
|
const selected = await selectPieceFromCategorizedPieces(categorized);
|
||||||
expect(selected).toBe('default');
|
expect(selected).toBe('default');
|
||||||
expect(selectOptionMock).toHaveBeenCalledTimes(3);
|
expect(selectOptionMock).toHaveBeenCalledTimes(3);
|
||||||
});
|
});
|
||||||
@ -294,7 +295,7 @@ describe('selectPieceFromCategorizedPieces', () => {
|
|||||||
|
|
||||||
selectOptionMock.mockResolvedValueOnce(null);
|
selectOptionMock.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
await selectPieceFromCategorizedPieces(categorized, '');
|
await selectPieceFromCategorizedPieces(categorized);
|
||||||
|
|
||||||
const firstCallOptions = selectOptionMock.mock.calls[0]![1] as { label: string; value: string }[];
|
const firstCallOptions = selectOptionMock.mock.calls[0]![1] as { label: string; value: string }[];
|
||||||
const labels = firstCallOptions.map((o) => o.label);
|
const labels = firstCallOptions.map((o) => o.label);
|
||||||
@ -317,13 +318,11 @@ describe('selectPiece', () => {
|
|||||||
configMock.loadAllPiecesWithSources.mockReset();
|
configMock.loadAllPiecesWithSources.mockReset();
|
||||||
configMock.getPieceCategories.mockReset();
|
configMock.getPieceCategories.mockReset();
|
||||||
configMock.buildCategorizedPieces.mockReset();
|
configMock.buildCategorizedPieces.mockReset();
|
||||||
configMock.resolveConfigValue.mockReset();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return default piece when no pieces found and fallbackToDefault is true', async () => {
|
it('should return default piece when no pieces found and fallbackToDefault is true', async () => {
|
||||||
configMock.getPieceCategories.mockReturnValue(null);
|
configMock.getPieceCategories.mockReturnValue(null);
|
||||||
configMock.listPieces.mockReturnValue([]);
|
configMock.listPieces.mockReturnValue([]);
|
||||||
configMock.resolveConfigValue.mockReturnValue('default');
|
|
||||||
|
|
||||||
const result = await selectPiece('/cwd');
|
const result = await selectPiece('/cwd');
|
||||||
|
|
||||||
@ -333,7 +332,6 @@ describe('selectPiece', () => {
|
|||||||
it('should return null when no pieces found and fallbackToDefault is false', async () => {
|
it('should return null when no pieces found and fallbackToDefault is false', async () => {
|
||||||
configMock.getPieceCategories.mockReturnValue(null);
|
configMock.getPieceCategories.mockReturnValue(null);
|
||||||
configMock.listPieces.mockReturnValue([]);
|
configMock.listPieces.mockReturnValue([]);
|
||||||
configMock.resolveConfigValue.mockReturnValue('default');
|
|
||||||
|
|
||||||
const result = await selectPiece('/cwd', { fallbackToDefault: false });
|
const result = await selectPiece('/cwd', { fallbackToDefault: false });
|
||||||
|
|
||||||
@ -346,7 +344,6 @@ describe('selectPiece', () => {
|
|||||||
configMock.listPieceEntries.mockReturnValue([
|
configMock.listPieceEntries.mockReturnValue([
|
||||||
{ name: 'only-piece', path: '/tmp/only-piece.yaml', source: 'user' },
|
{ name: 'only-piece', path: '/tmp/only-piece.yaml', source: 'user' },
|
||||||
]);
|
]);
|
||||||
configMock.resolveConfigValue.mockReturnValue('only-piece');
|
|
||||||
selectOptionMock.mockResolvedValueOnce('only-piece');
|
selectOptionMock.mockResolvedValueOnce('only-piece');
|
||||||
|
|
||||||
const result = await selectPiece('/cwd');
|
const result = await selectPiece('/cwd');
|
||||||
@ -366,9 +363,10 @@ describe('selectPiece', () => {
|
|||||||
configMock.getPieceCategories.mockReturnValue({ categories: ['Dev'] });
|
configMock.getPieceCategories.mockReturnValue({ categories: ['Dev'] });
|
||||||
configMock.loadAllPiecesWithSources.mockReturnValue(pieceMap);
|
configMock.loadAllPiecesWithSources.mockReturnValue(pieceMap);
|
||||||
configMock.buildCategorizedPieces.mockReturnValue(categorized);
|
configMock.buildCategorizedPieces.mockReturnValue(categorized);
|
||||||
configMock.resolveConfigValue.mockReturnValue('my-piece');
|
|
||||||
|
|
||||||
selectOptionMock.mockResolvedValueOnce('__current__');
|
selectOptionMock
|
||||||
|
.mockResolvedValueOnce('__custom_category__:Dev')
|
||||||
|
.mockResolvedValueOnce('my-piece');
|
||||||
|
|
||||||
const result = await selectPiece('/cwd');
|
const result = await selectPiece('/cwd');
|
||||||
|
|
||||||
@ -376,30 +374,10 @@ describe('selectPiece', () => {
|
|||||||
expect(configMock.buildCategorizedPieces).toHaveBeenCalled();
|
expect(configMock.buildCategorizedPieces).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fall back to default current piece when config piece is undefined', async () => {
|
|
||||||
const pieceMap = createPieceMap([{ name: 'default', source: 'builtin' }]);
|
|
||||||
const categorized: CategorizedPieces = {
|
|
||||||
categories: [{ name: 'Quick Start', pieces: ['default'], children: [] }],
|
|
||||||
allPieces: pieceMap,
|
|
||||||
missingPieces: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
configMock.getPieceCategories.mockReturnValue({ categories: ['Quick Start'] });
|
|
||||||
configMock.loadAllPiecesWithSources.mockReturnValue(pieceMap);
|
|
||||||
configMock.buildCategorizedPieces.mockReturnValue(categorized);
|
|
||||||
configMock.resolveConfigValue.mockReturnValue(undefined);
|
|
||||||
selectOptionMock.mockResolvedValueOnce('__current__');
|
|
||||||
|
|
||||||
const result = await selectPiece('/cwd');
|
|
||||||
|
|
||||||
expect(result).toBe('default');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use directory-based selection when no category config', async () => {
|
it('should use directory-based selection when no category config', async () => {
|
||||||
configMock.getPieceCategories.mockReturnValue(null);
|
configMock.getPieceCategories.mockReturnValue(null);
|
||||||
configMock.listPieces.mockReturnValue(['piece-a', 'piece-b']);
|
configMock.listPieces.mockReturnValue(['piece-a', 'piece-b']);
|
||||||
configMock.listPieceEntries.mockReturnValue(entries);
|
configMock.listPieceEntries.mockReturnValue(entries);
|
||||||
configMock.resolveConfigValue.mockReturnValue('piece-a');
|
|
||||||
|
|
||||||
selectOptionMock
|
selectOptionMock
|
||||||
.mockResolvedValueOnce('custom')
|
.mockResolvedValueOnce('custom')
|
||||||
|
|||||||
35
src/__tests__/prepareTaskForExecution.test.ts
Normal file
35
src/__tests__/prepareTaskForExecution.test.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { prepareTaskForExecution } from '../features/tasks/list/prepareTaskForExecution.js';
|
||||||
|
import type { TaskInfo } from '../infra/task/types.js';
|
||||||
|
|
||||||
|
function createTaskInfo(data: TaskInfo['data']): TaskInfo {
|
||||||
|
return {
|
||||||
|
filePath: '/project/.takt/tasks.yaml',
|
||||||
|
name: 'task-1',
|
||||||
|
content: 'task content',
|
||||||
|
createdAt: '2026-03-04T00:00:00.000Z',
|
||||||
|
status: 'running',
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('prepareTaskForExecution', () => {
|
||||||
|
it('returns copied task with selected piece', () => {
|
||||||
|
const original = createTaskInfo({ task: 'task content', piece: 'original-piece' });
|
||||||
|
|
||||||
|
const prepared = prepareTaskForExecution(original, 'selected-piece');
|
||||||
|
|
||||||
|
expect(prepared).not.toBe(original);
|
||||||
|
expect(prepared.data).not.toBe(original.data);
|
||||||
|
expect(prepared.data?.piece).toBe('selected-piece');
|
||||||
|
expect(original.data?.piece).toBe('original-piece');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when task data is missing', () => {
|
||||||
|
const original = createTaskInfo(null);
|
||||||
|
|
||||||
|
expect(() => prepareTaskForExecution(original, 'selected-piece')).toThrow(
|
||||||
|
'Task "task-1" is missing required data.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -26,7 +26,6 @@ describe('project provider_profiles', () => {
|
|||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(taktDir, 'config.yaml'),
|
join(taktDir, 'config.yaml'),
|
||||||
[
|
[
|
||||||
'piece: default',
|
|
||||||
'provider_profiles:',
|
'provider_profiles:',
|
||||||
' codex:',
|
' codex:',
|
||||||
' default_permission_mode: full',
|
' default_permission_mode: full',
|
||||||
@ -44,7 +43,6 @@ describe('project provider_profiles', () => {
|
|||||||
|
|
||||||
it('saves providerProfiles as provider_profiles', () => {
|
it('saves providerProfiles as provider_profiles', () => {
|
||||||
saveProjectConfig(testDir, {
|
saveProjectConfig(testDir, {
|
||||||
piece: 'default',
|
|
||||||
providerProfiles: {
|
providerProfiles: {
|
||||||
codex: {
|
codex: {
|
||||||
defaultPermissionMode: 'full',
|
defaultPermissionMode: 'full',
|
||||||
|
|||||||
@ -9,7 +9,7 @@ describe('resolveConfigValue call-chain contract', () => {
|
|||||||
|
|
||||||
it('should fail fast when migrated fallback loader is missing and migrated key is resolved', async () => {
|
it('should fail fast when migrated fallback loader is missing and migrated key is resolved', async () => {
|
||||||
vi.doMock('../infra/config/project/projectConfig.js', () => ({
|
vi.doMock('../infra/config/project/projectConfig.js', () => ({
|
||||||
loadProjectConfig: () => ({ piece: 'default' }),
|
loadProjectConfig: () => ({}),
|
||||||
}));
|
}));
|
||||||
vi.doMock('../infra/config/global/globalConfig.js', () => ({
|
vi.doMock('../infra/config/global/globalConfig.js', () => ({
|
||||||
loadGlobalConfig: () => ({ language: 'en' }),
|
loadGlobalConfig: () => ({ language: 'en' }),
|
||||||
|
|||||||
@ -57,37 +57,6 @@ describe('RESOLUTION_REGISTRY defaultValue removal', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('piece', () => {
|
|
||||||
it('should resolve piece as undefined when not set in project or global config', () => {
|
|
||||||
const value = resolveConfigValue(projectDir, 'piece');
|
|
||||||
expect(value).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should report source as default when piece is not set anywhere', () => {
|
|
||||||
const result = resolveConfigValueWithSource(projectDir, 'piece');
|
|
||||||
expect(result.value).toBeUndefined();
|
|
||||||
expect(result.source).toBe('default');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should resolve explicit project piece over default', () => {
|
|
||||||
const configDir = getProjectConfigDir(projectDir);
|
|
||||||
mkdirSync(configDir, { recursive: true });
|
|
||||||
writeFileSync(join(configDir, 'config.yaml'), 'piece: custom-piece\n');
|
|
||||||
|
|
||||||
const value = resolveConfigValue(projectDir, 'piece');
|
|
||||||
expect(value).toBe('custom-piece');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should resolve piece from global config when global has it', () => {
|
|
||||||
writeFileSync(globalConfigPath, 'language: en\npiece: global-piece\n', 'utf-8');
|
|
||||||
invalidateGlobalConfigCache();
|
|
||||||
|
|
||||||
const result = resolveConfigValueWithSource(projectDir, 'piece');
|
|
||||||
expect(result.value).toBe('global-piece');
|
|
||||||
expect(result.source).toBe('global');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('verbose', () => {
|
describe('verbose', () => {
|
||||||
it('should resolve verbose to false via resolver default when not set anywhere', () => {
|
it('should resolve verbose to false via resolver default when not set anywhere', () => {
|
||||||
const value = resolveConfigValue(projectDir, 'verbose');
|
const value = resolveConfigValue(projectDir, 'verbose');
|
||||||
@ -116,7 +85,7 @@ describe('RESOLUTION_REGISTRY defaultValue removal', () => {
|
|||||||
|
|
||||||
const configDir = getProjectConfigDir(projectDir);
|
const configDir = getProjectConfigDir(projectDir);
|
||||||
mkdirSync(configDir, { recursive: true });
|
mkdirSync(configDir, { recursive: true });
|
||||||
writeFileSync(join(configDir, 'config.yaml'), 'piece: default\nverbose: true\n');
|
writeFileSync(join(configDir, 'config.yaml'), 'verbose: true\n');
|
||||||
|
|
||||||
const value = resolveConfigValue(projectDir, 'verbose');
|
const value = resolveConfigValue(projectDir, 'verbose');
|
||||||
expect(value).toBe(true);
|
expect(value).toBe(true);
|
||||||
@ -227,7 +196,7 @@ describe('RESOLUTION_REGISTRY defaultValue removal', () => {
|
|||||||
it('should resolve migrated non-default keys as undefined when project keys are unset', () => {
|
it('should resolve migrated non-default keys as undefined when project keys are unset', () => {
|
||||||
const configDir = getProjectConfigDir(projectDir);
|
const configDir = getProjectConfigDir(projectDir);
|
||||||
mkdirSync(configDir, { recursive: true });
|
mkdirSync(configDir, { recursive: true });
|
||||||
writeFileSync(join(configDir, 'config.yaml'), 'piece: default\n', 'utf-8');
|
writeFileSync(join(configDir, 'config.yaml'), 'provider: claude\n', 'utf-8');
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
globalConfigPath,
|
globalConfigPath,
|
||||||
['language: en'].join('\n'),
|
['language: en'].join('\n'),
|
||||||
@ -256,7 +225,7 @@ describe('RESOLUTION_REGISTRY defaultValue removal', () => {
|
|||||||
it('should resolve default-backed migrated keys from defaults when project keys are unset', () => {
|
it('should resolve default-backed migrated keys from defaults when project keys are unset', () => {
|
||||||
const configDir = getProjectConfigDir(projectDir);
|
const configDir = getProjectConfigDir(projectDir);
|
||||||
mkdirSync(configDir, { recursive: true });
|
mkdirSync(configDir, { recursive: true });
|
||||||
writeFileSync(join(configDir, 'config.yaml'), 'piece: default\n', 'utf-8');
|
writeFileSync(join(configDir, 'config.yaml'), 'provider: claude\n', 'utf-8');
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
globalConfigPath,
|
globalConfigPath,
|
||||||
['language: en'].join('\n'),
|
['language: en'].join('\n'),
|
||||||
@ -344,7 +313,7 @@ describe('RESOLUTION_REGISTRY defaultValue removal', () => {
|
|||||||
it('should resolve all migrated keys from project or defaults when project config has no migrated keys', () => {
|
it('should resolve all migrated keys from project or defaults when project config has no migrated keys', () => {
|
||||||
const configDir = getProjectConfigDir(projectDir);
|
const configDir = getProjectConfigDir(projectDir);
|
||||||
mkdirSync(configDir, { recursive: true });
|
mkdirSync(configDir, { recursive: true });
|
||||||
writeFileSync(join(configDir, 'config.yaml'), 'piece: default\n', 'utf-8');
|
writeFileSync(join(configDir, 'config.yaml'), 'provider: claude\n', 'utf-8');
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
globalConfigPath,
|
globalConfigPath,
|
||||||
['language: en'].join('\n'),
|
['language: en'].join('\n'),
|
||||||
|
|||||||
@ -21,26 +21,57 @@ function createTempProjectDir(): string {
|
|||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTask(overrides: Partial<TaskInfo>): TaskInfo {
|
function createTask(overrides: Partial<TaskInfo> = {}): TaskInfo {
|
||||||
|
const baseData = { task: 'Run task', piece: 'default' } as NonNullable<TaskInfo['data']>;
|
||||||
|
const data = overrides.data === undefined
|
||||||
|
? baseData
|
||||||
|
: overrides.data === null
|
||||||
|
? null
|
||||||
|
: ({
|
||||||
|
...baseData,
|
||||||
|
...(overrides.data as Record<string, unknown>),
|
||||||
|
} as NonNullable<TaskInfo['data']>);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filePath: '/tasks/task.yaml',
|
filePath: '/tasks/task.yaml',
|
||||||
name: 'task-name',
|
name: 'task-name',
|
||||||
content: 'Run task',
|
content: 'Run task',
|
||||||
createdAt: '2026-01-01T00:00:00.000Z',
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
data: { task: 'Run task' },
|
|
||||||
...overrides,
|
...overrides,
|
||||||
|
data,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolveTaskExecutionWithPiece = resolveTaskExecution as (task: TaskInfo, projectCwd: string) => ReturnType<typeof resolveTaskExecution>;
|
||||||
|
|
||||||
describe('resolveTaskExecution', () => {
|
describe('resolveTaskExecution', () => {
|
||||||
it('should return defaults when task data is null', async () => {
|
it('should throw when task data is null', async () => {
|
||||||
const root = createTempProjectDir();
|
const root = createTempProjectDir();
|
||||||
const task = createTask({ data: null });
|
const task = createTask({ data: null });
|
||||||
|
|
||||||
const result = await resolveTaskExecution(task, root, 'default');
|
await expect(resolveTaskExecutionWithPiece(task, root)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
expect(result).toEqual({
|
it('should throw when task data does not include piece', async () => {
|
||||||
|
const root = createTempProjectDir();
|
||||||
|
const task = createTask({
|
||||||
|
data: ({
|
||||||
|
task: 'Run task without piece',
|
||||||
|
piece: undefined,
|
||||||
|
} as unknown) as NonNullable<TaskInfo['data']>,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(resolveTaskExecutionWithPiece(task, root)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return defaults for valid task data', async () => {
|
||||||
|
const root = createTempProjectDir();
|
||||||
|
const task = createTask();
|
||||||
|
|
||||||
|
const result = await resolveTaskExecutionWithPiece(task, root);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
execCwd: root,
|
execCwd: root,
|
||||||
execPiece: 'default',
|
execPiece: 'default',
|
||||||
isWorktree: false,
|
isWorktree: false,
|
||||||
@ -66,7 +97,7 @@ describe('resolveTaskExecution', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await resolveTaskExecution(task, root, 'default');
|
const result = await resolveTaskExecutionWithPiece(task, root);
|
||||||
const expectedReportOrderPath = path.join(root, '.takt', 'runs', 'issue-task-123', 'context', 'task', 'order.md');
|
const expectedReportOrderPath = path.join(root, '.takt', 'runs', 'issue-task-123', 'context', 'task', 'order.md');
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
@ -107,7 +138,7 @@ describe('resolveTaskExecution', () => {
|
|||||||
branch: 'feature/base-branch',
|
branch: 'feature/base-branch',
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await resolveTaskExecution(task, root, 'default');
|
const result = await resolveTaskExecutionWithPiece(task, root);
|
||||||
|
|
||||||
expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, 'release/main');
|
expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, 'release/main');
|
||||||
expect(mockCreateSharedClone).toHaveBeenCalledWith(
|
expect(mockCreateSharedClone).toHaveBeenCalledWith(
|
||||||
@ -147,7 +178,7 @@ describe('resolveTaskExecution', () => {
|
|||||||
branch: 'feature/base-branch',
|
branch: 'feature/base-branch',
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await resolveTaskExecution(task, root, 'default');
|
const result = await resolveTaskExecutionWithPiece(task, root);
|
||||||
const cloneOptions = mockCreateSharedClone.mock.calls[0]?.[1] as Record<string, unknown> | undefined;
|
const cloneOptions = mockCreateSharedClone.mock.calls[0]?.[1] as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, 'release/main');
|
expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, 'release/main');
|
||||||
@ -187,7 +218,7 @@ describe('resolveTaskExecution', () => {
|
|||||||
branch: 'feature/base-branch',
|
branch: 'feature/base-branch',
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await resolveTaskExecution(task, root, 'default');
|
const result = await resolveTaskExecutionWithPiece(task, root);
|
||||||
const cloneOptions = mockCreateSharedClone.mock.calls[0]?.[1] as Record<string, unknown> | undefined;
|
const cloneOptions = mockCreateSharedClone.mock.calls[0]?.[1] as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, undefined);
|
expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, undefined);
|
||||||
@ -228,7 +259,7 @@ describe('resolveTaskExecution', () => {
|
|||||||
branch: 'feature/base-branch',
|
branch: 'feature/base-branch',
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await resolveTaskExecution(task, root, 'default');
|
const result = await resolveTaskExecutionWithPiece(task, root);
|
||||||
|
|
||||||
expect(result.execCwd).toBe(worktreePath);
|
expect(result.execCwd).toBe(worktreePath);
|
||||||
expect(result.isWorktree).toBe(true);
|
expect(result.isWorktree).toBe(true);
|
||||||
@ -264,7 +295,7 @@ describe('resolveTaskExecution', () => {
|
|||||||
branch: 'feature/base-branch',
|
branch: 'feature/base-branch',
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await resolveTaskExecution(task, root, 'default');
|
const result = await resolveTaskExecutionWithPiece(task, root);
|
||||||
|
|
||||||
expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, 'release/main');
|
expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, 'release/main');
|
||||||
expect(mockCreateSharedClone).not.toHaveBeenCalled();
|
expect(mockCreateSharedClone).not.toHaveBeenCalled();
|
||||||
@ -300,7 +331,7 @@ describe('resolveTaskExecution', () => {
|
|||||||
branch: 'feature/base-branch',
|
branch: 'feature/base-branch',
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await resolveTaskExecution(task, root, 'default');
|
const result = await resolveTaskExecutionWithPiece(task, root);
|
||||||
|
|
||||||
expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, undefined);
|
expect(mockResolveBaseBranch).toHaveBeenCalledWith(root, undefined);
|
||||||
expect(mockCreateSharedClone).not.toHaveBeenCalled();
|
expect(mockCreateSharedClone).not.toHaveBeenCalled();
|
||||||
@ -322,7 +353,7 @@ describe('resolveTaskExecution', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await resolveTaskExecution(task, root, 'default');
|
const result = await resolveTaskExecutionWithPiece(task, root);
|
||||||
|
|
||||||
expect(result.draftPr).toBe(true);
|
expect(result.draftPr).toBe(true);
|
||||||
expect(result.autoPr).toBe(true);
|
expect(result.autoPr).toBe(true);
|
||||||
|
|||||||
@ -40,6 +40,13 @@ vi.mock('../infra/config/index.js', () => ({
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
resolvePieceConfigValue: (_projectDir: string, key: string) => {
|
||||||
|
const raw = mockLoadConfigRaw() as Record<string, unknown>;
|
||||||
|
const config = ('global' in raw && 'project' in raw)
|
||||||
|
? { ...raw.global as Record<string, unknown>, ...raw.project as Record<string, unknown> }
|
||||||
|
: { ...raw, provider: 'claude', verbose: false };
|
||||||
|
return config[key];
|
||||||
|
},
|
||||||
resolveConfigValueWithSource: (_projectDir: string, key: string) => {
|
resolveConfigValueWithSource: (_projectDir: string, key: string) => {
|
||||||
const raw = mockLoadConfigRaw() as Record<string, unknown>;
|
const raw = mockLoadConfigRaw() as Record<string, unknown>;
|
||||||
const config = ('global' in raw && 'project' in raw)
|
const config = ('global' in raw && 'project' in raw)
|
||||||
@ -188,7 +195,10 @@ function createTask(name: string): TaskInfo {
|
|||||||
filePath: `/tasks/${name}.yaml`,
|
filePath: `/tasks/${name}.yaml`,
|
||||||
createdAt: '2026-02-09T00:00:00.000Z',
|
createdAt: '2026-02-09T00:00:00.000Z',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
data: null,
|
data: {
|
||||||
|
task: `Task: ${name}`,
|
||||||
|
piece: 'default',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,61 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for switchPiece behavior.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
vi.mock('../infra/config/index.js', () => ({
|
|
||||||
loadPiece: vi.fn(() => null),
|
|
||||||
resolveConfigValue: vi.fn(() => 'default'),
|
|
||||||
setCurrentPiece: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../features/pieceSelection/index.js', () => ({
|
|
||||||
selectPiece: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../shared/ui/index.js', () => ({
|
|
||||||
info: vi.fn(),
|
|
||||||
success: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { resolveConfigValue, loadPiece, setCurrentPiece } from '../infra/config/index.js';
|
|
||||||
import { selectPiece } from '../features/pieceSelection/index.js';
|
|
||||||
import { switchPiece } from '../features/config/switchPiece.js';
|
|
||||||
|
|
||||||
const mockResolveConfigValue = vi.mocked(resolveConfigValue);
|
|
||||||
const mockLoadPiece = vi.mocked(loadPiece);
|
|
||||||
const mockSetCurrentPiece = vi.mocked(setCurrentPiece);
|
|
||||||
const mockSelectPiece = vi.mocked(selectPiece);
|
|
||||||
|
|
||||||
describe('switchPiece', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockResolveConfigValue.mockReturnValue('default');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call selectPiece with fallbackToDefault: false', async () => {
|
|
||||||
mockSelectPiece.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const switched = await switchPiece('/project');
|
|
||||||
|
|
||||||
expect(switched).toBe(false);
|
|
||||||
expect(mockSelectPiece).toHaveBeenCalledWith('/project', { fallbackToDefault: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should switch to selected piece', async () => {
|
|
||||||
mockSelectPiece.mockResolvedValue('new-piece');
|
|
||||||
mockLoadPiece.mockReturnValue({
|
|
||||||
name: 'new-piece',
|
|
||||||
movements: [],
|
|
||||||
initialMovement: 'start',
|
|
||||||
maxMovements: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const switched = await switchPiece('/project');
|
|
||||||
|
|
||||||
expect(switched).toBe(true);
|
|
||||||
expect(mockSetCurrentPiece).toHaveBeenCalledWith('/project', 'new-piece');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -77,9 +77,17 @@ const createTask = (name: string): TaskInfo => ({
|
|||||||
filePath: `/tasks/${name}.yaml`,
|
filePath: `/tasks/${name}.yaml`,
|
||||||
createdAt: '2026-02-16T00:00:00.000Z',
|
createdAt: '2026-02-16T00:00:00.000Z',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
data: { task: `Task: ${name}` },
|
data: { task: `Task: ${name}`, piece: 'default' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const executeAndCompleteTaskWithoutPiece = executeAndCompleteTask as (
|
||||||
|
task: TaskInfo,
|
||||||
|
taskRunner: unknown,
|
||||||
|
projectCwd: string,
|
||||||
|
executeOptions?: unknown,
|
||||||
|
parallelOptions?: unknown,
|
||||||
|
) => Promise<boolean>;
|
||||||
|
|
||||||
describe('executeAndCompleteTask', () => {
|
describe('executeAndCompleteTask', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@ -130,12 +138,18 @@ describe('executeAndCompleteTask', () => {
|
|||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
|
||||||
// When
|
// When
|
||||||
await executeAndCompleteTask(task, {} as never, '/project', 'default', undefined, {
|
await executeAndCompleteTaskWithoutPiece(
|
||||||
|
task,
|
||||||
|
{} as never,
|
||||||
|
'/project',
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
abortSignal: abortController.signal,
|
abortSignal: abortController.signal,
|
||||||
taskPrefix: taskDisplayLabel,
|
taskPrefix: taskDisplayLabel,
|
||||||
taskColorIndex: 0,
|
taskColorIndex: 0,
|
||||||
taskDisplayLabel,
|
taskDisplayLabel,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Then: executePiece receives the propagated display label.
|
// Then: executePiece receives the propagated display label.
|
||||||
expect(mockExecutePiece).toHaveBeenCalledTimes(1);
|
expect(mockExecutePiece).toHaveBeenCalledTimes(1);
|
||||||
@ -223,7 +237,7 @@ describe('executeAndCompleteTask', () => {
|
|||||||
mockPostExecutionFlow.mockResolvedValue({ prFailed: true, prError: 'Base ref must be a branch' });
|
mockPostExecutionFlow.mockResolvedValue({ prFailed: true, prError: 'Base ref must be a branch' });
|
||||||
|
|
||||||
// When
|
// When
|
||||||
const result = await executeAndCompleteTask(task, {} as never, '/project', 'default');
|
const result = await executeAndCompleteTaskWithoutPiece(task, {} as never, '/project');
|
||||||
|
|
||||||
// Then: code succeeded, task is marked as pr_failed (not failed)
|
// Then: code succeeded, task is marked as pr_failed (not failed)
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
@ -262,7 +276,7 @@ describe('executeAndCompleteTask', () => {
|
|||||||
mockPostExecutionFlow.mockResolvedValue({ prUrl: 'https://github.com/org/repo/pull/1' });
|
mockPostExecutionFlow.mockResolvedValue({ prUrl: 'https://github.com/org/repo/pull/1' });
|
||||||
|
|
||||||
// When
|
// When
|
||||||
const result = await executeAndCompleteTask(task, {} as never, '/project', 'default');
|
const result = await executeAndCompleteTaskWithoutPiece(task, {} as never, '/project');
|
||||||
|
|
||||||
// Then: task should be marked as completed
|
// Then: task should be marked as completed
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
|
|||||||
@ -158,6 +158,33 @@ describe('instructBranch direct execution flow', () => {
|
|||||||
expect(mockExecuteAndCompleteTask).toHaveBeenCalled();
|
expect(mockExecuteAndCompleteTask).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should execute with selected piece without mutating taskInfo', async () => {
|
||||||
|
mockSelectPiece.mockResolvedValue('selected-piece');
|
||||||
|
const originalTaskInfo = {
|
||||||
|
name: 'done-task',
|
||||||
|
content: 'done',
|
||||||
|
data: { task: 'done', piece: 'original-piece' },
|
||||||
|
};
|
||||||
|
mockStartReExecution.mockReturnValue(originalTaskInfo);
|
||||||
|
|
||||||
|
await instructBranch('/project', {
|
||||||
|
kind: 'completed',
|
||||||
|
name: 'done-task',
|
||||||
|
createdAt: '2026-02-14T00:00:00.000Z',
|
||||||
|
filePath: '/project/.takt/tasks.yaml',
|
||||||
|
content: 'done',
|
||||||
|
branch: 'takt/done-task',
|
||||||
|
worktreePath: '/project/.takt/worktrees/done-task',
|
||||||
|
data: { task: 'done' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const executeArg = mockExecuteAndCompleteTask.mock.calls[0]?.[0];
|
||||||
|
expect(executeArg).not.toBe(originalTaskInfo);
|
||||||
|
expect(executeArg.data).not.toBe(originalTaskInfo.data);
|
||||||
|
expect(executeArg.data.piece).toBe('selected-piece');
|
||||||
|
expect(originalTaskInfo.data.piece).toBe('original-piece');
|
||||||
|
});
|
||||||
|
|
||||||
it('should set generated instruction as retry note when no existing note', async () => {
|
it('should set generated instruction as retry note when no existing note', async () => {
|
||||||
await instructBranch('/project', {
|
await instructBranch('/project', {
|
||||||
kind: 'completed',
|
kind: 'completed',
|
||||||
|
|||||||
@ -179,6 +179,25 @@ describe('retryFailedTask', () => {
|
|||||||
expect(mockExecuteAndCompleteTask).toHaveBeenCalled();
|
expect(mockExecuteAndCompleteTask).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should execute with selected piece without mutating taskInfo', async () => {
|
||||||
|
mockSelectPiece.mockResolvedValue('selected-piece');
|
||||||
|
const originalTaskInfo = {
|
||||||
|
name: 'my-task',
|
||||||
|
content: 'Do something',
|
||||||
|
data: { task: 'Do something', piece: 'original-piece' },
|
||||||
|
};
|
||||||
|
mockStartReExecution.mockReturnValue(originalTaskInfo);
|
||||||
|
const task = makeFailedTask();
|
||||||
|
|
||||||
|
await retryFailedTask(task, '/project');
|
||||||
|
|
||||||
|
const executeArg = mockExecuteAndCompleteTask.mock.calls[0]?.[0];
|
||||||
|
expect(executeArg).not.toBe(originalTaskInfo);
|
||||||
|
expect(executeArg.data).not.toBe(originalTaskInfo.data);
|
||||||
|
expect(executeArg.data.piece).toBe('selected-piece');
|
||||||
|
expect(originalTaskInfo.data.piece).toBe('original-piece');
|
||||||
|
});
|
||||||
|
|
||||||
it('should pass failed movement as default to selectOptionWithDefault', async () => {
|
it('should pass failed movement as default to selectOptionWithDefault', async () => {
|
||||||
const task = makeFailedTask(); // failure.movement = 'review'
|
const task = makeFailedTask(); // failure.movement = 'review'
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,6 @@ const {
|
|||||||
mockSuccess,
|
mockSuccess,
|
||||||
mockWarn,
|
mockWarn,
|
||||||
mockError,
|
mockError,
|
||||||
mockResolveConfigValue,
|
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
mockRecoverInterruptedRunningTasks: vi.fn(),
|
mockRecoverInterruptedRunningTasks: vi.fn(),
|
||||||
mockGetTasksFilePath: vi.fn(),
|
mockGetTasksFilePath: vi.fn(),
|
||||||
@ -28,7 +27,6 @@ const {
|
|||||||
mockSuccess: vi.fn(),
|
mockSuccess: vi.fn(),
|
||||||
mockWarn: vi.fn(),
|
mockWarn: vi.fn(),
|
||||||
mockError: vi.fn(),
|
mockError: vi.fn(),
|
||||||
mockResolveConfigValue: vi.fn(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../infra/task/index.js', () => ({
|
vi.mock('../infra/task/index.js', () => ({
|
||||||
@ -60,16 +58,11 @@ vi.mock('../shared/i18n/index.js', () => ({
|
|||||||
getLabel: vi.fn((key: string) => key),
|
getLabel: vi.fn((key: string) => key),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../infra/config/index.js', () => ({
|
|
||||||
resolveConfigValue: mockResolveConfigValue,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { watchTasks } from '../features/tasks/watch/index.js';
|
import { watchTasks } from '../features/tasks/watch/index.js';
|
||||||
|
|
||||||
describe('watchTasks', () => {
|
describe('watchTasks', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockResolveConfigValue.mockReturnValue('default');
|
|
||||||
mockRecoverInterruptedRunningTasks.mockReturnValue(0);
|
mockRecoverInterruptedRunningTasks.mockReturnValue(0);
|
||||||
mockGetTasksFilePath.mockReturnValue('/project/.takt/tasks.yaml');
|
mockGetTasksFilePath.mockReturnValue('/project/.takt/tasks.yaml');
|
||||||
mockExecuteAndCompleteTask.mockResolvedValue(true);
|
mockExecuteAndCompleteTask.mockResolvedValue(true);
|
||||||
@ -97,17 +90,13 @@ describe('watchTasks', () => {
|
|||||||
expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1);
|
expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('piece設定が未定義の場合はデフォルトpiece名を使う', async () => {
|
it('executeAndCompleteTask を watch ループで呼び出す', async () => {
|
||||||
mockResolveConfigValue.mockReturnValue(undefined);
|
|
||||||
|
|
||||||
await watchTasks('/project');
|
await watchTasks('/project');
|
||||||
|
|
||||||
expect(mockInfo).toHaveBeenCalledWith('Piece: default');
|
|
||||||
expect(mockExecuteAndCompleteTask).toHaveBeenCalledWith(
|
expect(mockExecuteAndCompleteTask).toHaveBeenCalledWith(
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
'/project',
|
'/project',
|
||||||
'default',
|
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -54,6 +54,7 @@ function createTask(name: string, options?: { issue?: number }): TaskInfo {
|
|||||||
status: 'pending',
|
status: 'pending',
|
||||||
data: {
|
data: {
|
||||||
task: `Task: ${name}`,
|
task: `Task: ${name}`,
|
||||||
|
piece: 'default',
|
||||||
...(options?.issue !== undefined ? { issue: options.issue } : {}),
|
...(options?.issue !== undefined ? { issue: options.issue } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -85,7 +86,7 @@ describe('runWithWorkerPool', () => {
|
|||||||
const runner = createMockTaskRunner([]);
|
const runner = createMockTaskRunner([]);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
const result = await runWithWorkerPool(runner as never, tasks, 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS);
|
const result = await runWithWorkerPool(runner as never, tasks, 2, '/cwd', undefined, TEST_POLL_INTERVAL_MS);
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
expect(result).toEqual({ success: 2, fail: 0 });
|
expect(result).toEqual({ success: 2, fail: 0 });
|
||||||
@ -102,7 +103,7 @@ describe('runWithWorkerPool', () => {
|
|||||||
const runner = createMockTaskRunner([]);
|
const runner = createMockTaskRunner([]);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
const result = await runWithWorkerPool(runner as never, tasks, 3, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS);
|
const result = await runWithWorkerPool(runner as never, tasks, 3, '/cwd', undefined, TEST_POLL_INTERVAL_MS);
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
expect(result).toEqual({ success: 2, fail: 1 });
|
expect(result).toEqual({ success: 2, fail: 1 });
|
||||||
@ -119,7 +120,7 @@ describe('runWithWorkerPool', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// When
|
// When
|
||||||
await runWithWorkerPool(runner as never, tasks, 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS);
|
await runWithWorkerPool(runner as never, tasks, 2, '/cwd', undefined, TEST_POLL_INTERVAL_MS);
|
||||||
|
|
||||||
// Then: Task names appear in prefixed stdout output
|
// Then: Task names appear in prefixed stdout output
|
||||||
writeSpy.mockRestore();
|
writeSpy.mockRestore();
|
||||||
@ -136,11 +137,11 @@ describe('runWithWorkerPool', () => {
|
|||||||
const runner = createMockTaskRunner([]);
|
const runner = createMockTaskRunner([]);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
await runWithWorkerPool(runner as never, tasks, 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS);
|
await runWithWorkerPool(runner as never, tasks, 2, '/cwd', undefined, TEST_POLL_INTERVAL_MS);
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1);
|
expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1);
|
||||||
const parallelOpts = mockExecuteAndCompleteTask.mock.calls[0]?.[5];
|
const parallelOpts = mockExecuteAndCompleteTask.mock.calls[0]?.[4];
|
||||||
expect(parallelOpts).toMatchObject({
|
expect(parallelOpts).toMatchObject({
|
||||||
abortSignal: expect.any(AbortSignal),
|
abortSignal: expect.any(AbortSignal),
|
||||||
taskPrefix: 'my-task',
|
taskPrefix: 'my-task',
|
||||||
@ -161,7 +162,7 @@ describe('runWithWorkerPool', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// When
|
// When
|
||||||
await runWithWorkerPool(runner as never, tasks, 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS);
|
await runWithWorkerPool(runner as never, tasks, 2, '/cwd', undefined, TEST_POLL_INTERVAL_MS);
|
||||||
|
|
||||||
// Then: Issue label is used instead of truncated task name
|
// Then: Issue label is used instead of truncated task name
|
||||||
writeSpy.mockRestore();
|
writeSpy.mockRestore();
|
||||||
@ -170,7 +171,7 @@ describe('runWithWorkerPool', () => {
|
|||||||
expect(allOutput).not.toContain('[#123]');
|
expect(allOutput).not.toContain('[#123]');
|
||||||
|
|
||||||
expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1);
|
expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1);
|
||||||
const parallelOpts = mockExecuteAndCompleteTask.mock.calls[0]?.[5];
|
const parallelOpts = mockExecuteAndCompleteTask.mock.calls[0]?.[4];
|
||||||
expect(parallelOpts).toEqual({
|
expect(parallelOpts).toEqual({
|
||||||
abortSignal: expect.any(AbortSignal),
|
abortSignal: expect.any(AbortSignal),
|
||||||
taskPrefix: `#${issueNumber}`,
|
taskPrefix: `#${issueNumber}`,
|
||||||
@ -185,11 +186,11 @@ describe('runWithWorkerPool', () => {
|
|||||||
const runner = createMockTaskRunner([]);
|
const runner = createMockTaskRunner([]);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
await runWithWorkerPool(runner as never, tasks, 1, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS);
|
await runWithWorkerPool(runner as never, tasks, 1, '/cwd', undefined, TEST_POLL_INTERVAL_MS);
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1);
|
expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1);
|
||||||
const parallelOpts = mockExecuteAndCompleteTask.mock.calls[0]?.[5];
|
const parallelOpts = mockExecuteAndCompleteTask.mock.calls[0]?.[4];
|
||||||
expect(parallelOpts).toMatchObject({
|
expect(parallelOpts).toMatchObject({
|
||||||
abortSignal: expect.any(AbortSignal),
|
abortSignal: expect.any(AbortSignal),
|
||||||
taskPrefix: undefined,
|
taskPrefix: undefined,
|
||||||
@ -205,7 +206,7 @@ describe('runWithWorkerPool', () => {
|
|||||||
const runner = createMockTaskRunner([[task2]]);
|
const runner = createMockTaskRunner([[task2]]);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
await runWithWorkerPool(runner as never, [task1], 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS);
|
await runWithWorkerPool(runner as never, [task1], 2, '/cwd', undefined, TEST_POLL_INTERVAL_MS);
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(2);
|
expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(2);
|
||||||
@ -233,7 +234,7 @@ describe('runWithWorkerPool', () => {
|
|||||||
const runner = createMockTaskRunner([]);
|
const runner = createMockTaskRunner([]);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
await runWithWorkerPool(runner as never, tasks, 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS);
|
await runWithWorkerPool(runner as never, tasks, 2, '/cwd', undefined, TEST_POLL_INTERVAL_MS);
|
||||||
|
|
||||||
// Then: Never exceeded concurrency of 2
|
// Then: Never exceeded concurrency of 2
|
||||||
expect(maxActive).toBeLessThanOrEqual(2);
|
expect(maxActive).toBeLessThanOrEqual(2);
|
||||||
@ -246,13 +247,13 @@ describe('runWithWorkerPool', () => {
|
|||||||
const runner = createMockTaskRunner([]);
|
const runner = createMockTaskRunner([]);
|
||||||
|
|
||||||
const receivedSignals: (AbortSignal | undefined)[] = [];
|
const receivedSignals: (AbortSignal | undefined)[] = [];
|
||||||
mockExecuteAndCompleteTask.mockImplementation((_task, _runner, _cwd, _piece, _opts, parallelOpts) => {
|
mockExecuteAndCompleteTask.mockImplementation((_task, _runner, _cwd, _opts, parallelOpts) => {
|
||||||
receivedSignals.push(parallelOpts?.abortSignal);
|
receivedSignals.push(parallelOpts?.abortSignal);
|
||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
// When
|
// When
|
||||||
await runWithWorkerPool(runner as never, tasks, 3, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS);
|
await runWithWorkerPool(runner as never, tasks, 3, '/cwd', undefined, TEST_POLL_INTERVAL_MS);
|
||||||
|
|
||||||
// Then: All tasks received the same AbortSignal
|
// Then: All tasks received the same AbortSignal
|
||||||
expect(receivedSignals).toHaveLength(3);
|
expect(receivedSignals).toHaveLength(3);
|
||||||
@ -268,7 +269,7 @@ describe('runWithWorkerPool', () => {
|
|||||||
const runner = createMockTaskRunner([]);
|
const runner = createMockTaskRunner([]);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
const result = await runWithWorkerPool(runner as never, [], 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS);
|
const result = await runWithWorkerPool(runner as never, [], 2, '/cwd', undefined, TEST_POLL_INTERVAL_MS);
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
expect(result).toEqual({ success: 0, fail: 0 });
|
expect(result).toEqual({ success: 0, fail: 0 });
|
||||||
@ -282,7 +283,7 @@ describe('runWithWorkerPool', () => {
|
|||||||
const runner = createMockTaskRunner([]);
|
const runner = createMockTaskRunner([]);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
const result = await runWithWorkerPool(runner as never, tasks, 1, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS);
|
const result = await runWithWorkerPool(runner as never, tasks, 1, '/cwd', undefined, TEST_POLL_INTERVAL_MS);
|
||||||
|
|
||||||
// Then: Treated as failure
|
// Then: Treated as failure
|
||||||
expect(result).toEqual({ success: 0, fail: 1 });
|
expect(result).toEqual({ success: 0, fail: 1 });
|
||||||
@ -295,7 +296,7 @@ describe('runWithWorkerPool', () => {
|
|||||||
const deferred: Array<() => void> = [];
|
const deferred: Array<() => void> = [];
|
||||||
const startedSignals: AbortSignal[] = [];
|
const startedSignals: AbortSignal[] = [];
|
||||||
|
|
||||||
mockExecuteAndCompleteTask.mockImplementation((_task, _runner, _cwd, _piece, _opts, parallelOpts) => {
|
mockExecuteAndCompleteTask.mockImplementation((_task, _runner, _cwd, _opts, parallelOpts) => {
|
||||||
const signal = parallelOpts?.abortSignal;
|
const signal = parallelOpts?.abortSignal;
|
||||||
if (signal) startedSignals.push(signal);
|
if (signal) startedSignals.push(signal);
|
||||||
return new Promise<boolean>((resolve) => {
|
return new Promise<boolean>((resolve) => {
|
||||||
@ -308,7 +309,7 @@ describe('runWithWorkerPool', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const resultPromise = runWithWorkerPool(
|
const resultPromise = runWithWorkerPool(
|
||||||
runner as never, tasks, 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS,
|
runner as never, tasks, 2, '/cwd', undefined, TEST_POLL_INTERVAL_MS,
|
||||||
);
|
);
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
@ -366,7 +367,7 @@ describe('runWithWorkerPool', () => {
|
|||||||
|
|
||||||
// When: pollIntervalMs=30 so polling fires before task1 completes (80ms)
|
// When: pollIntervalMs=30 so polling fires before task1 completes (80ms)
|
||||||
const result = await runWithWorkerPool(
|
const result = await runWithWorkerPool(
|
||||||
runner as never, [task1], 2, '/cwd', 'default', undefined, 30,
|
runner as never, [task1], 2, '/cwd', undefined, 30,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Then: Both tasks were executed
|
// Then: Both tasks were executed
|
||||||
@ -399,7 +400,7 @@ describe('runWithWorkerPool', () => {
|
|||||||
|
|
||||||
// When
|
// When
|
||||||
const result = await runWithWorkerPool(
|
const result = await runWithWorkerPool(
|
||||||
runner as never, [task1], 1, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS,
|
runner as never, [task1], 1, '/cwd', undefined, TEST_POLL_INTERVAL_MS,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Then: Tasks executed sequentially — task2 starts after task1 ends
|
// Then: Tasks executed sequentially — task2 starts after task1 ends
|
||||||
@ -423,7 +424,7 @@ describe('runWithWorkerPool', () => {
|
|||||||
|
|
||||||
// When: Task completes before poll timer fires; cancel() cleans up timer
|
// When: Task completes before poll timer fires; cancel() cleans up timer
|
||||||
const result = await runWithWorkerPool(
|
const result = await runWithWorkerPool(
|
||||||
runner as never, [task1], 1, '/cwd', 'default', undefined, 5000,
|
runner as never, [task1], 1, '/cwd', undefined, 5000,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Then: Result is returned without hanging (timer was cleaned up by cancel())
|
// Then: Result is returned without hanging (timer was cleaned up by cancel())
|
||||||
|
|||||||
@ -99,6 +99,7 @@ function writeExceededRecord(testDir: string, overrides: Record<string, unknown>
|
|||||||
name: 'task-a',
|
name: 'task-a',
|
||||||
status: 'exceeded',
|
status: 'exceeded',
|
||||||
content: 'Do work',
|
content: 'Do work',
|
||||||
|
piece: 'test-piece',
|
||||||
created_at: '2026-02-09T00:00:00.000Z',
|
created_at: '2026-02-09T00:00:00.000Z',
|
||||||
started_at: '2026-02-09T00:01:00.000Z',
|
started_at: '2026-02-09T00:01:00.000Z',
|
||||||
completed_at: '2026-02-09T00:05:00.000Z',
|
completed_at: '2026-02-09T00:05:00.000Z',
|
||||||
@ -166,7 +167,7 @@ describe('シナリオ1・2: exceeded status transition via executeAndCompleteTa
|
|||||||
|
|
||||||
it('scenario 1: task transitions to exceeded status when executePiece returns exceeded result', async () => {
|
it('scenario 1: task transitions to exceeded status when executePiece returns exceeded result', async () => {
|
||||||
// Given: a pending task
|
// Given: a pending task
|
||||||
runner.addTask('Do work');
|
runner.addTask('Do work', { piece: 'test-piece' });
|
||||||
const [task] = runner.claimNextTasks(1);
|
const [task] = runner.claimNextTasks(1);
|
||||||
if (!task) throw new Error('No task claimed');
|
if (!task) throw new Error('No task claimed');
|
||||||
|
|
||||||
@ -182,7 +183,7 @@ describe('シナリオ1・2: exceeded status transition via executeAndCompleteTa
|
|||||||
});
|
});
|
||||||
|
|
||||||
// When: executeAndCompleteTask processes the exceeded result
|
// When: executeAndCompleteTask processes the exceeded result
|
||||||
const result = await executeAndCompleteTask(task, runner, testDir, 'test-piece');
|
const result = await executeAndCompleteTask(task, runner, testDir);
|
||||||
|
|
||||||
// Then: returns false (task did not succeed)
|
// Then: returns false (task did not succeed)
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
@ -196,7 +197,7 @@ describe('シナリオ1・2: exceeded status transition via executeAndCompleteTa
|
|||||||
|
|
||||||
it('scenario 2: exceeded metadata is recorded in tasks.yaml for resumption', async () => {
|
it('scenario 2: exceeded metadata is recorded in tasks.yaml for resumption', async () => {
|
||||||
// Given: a pending task
|
// Given: a pending task
|
||||||
runner.addTask('Do work');
|
runner.addTask('Do work', { piece: 'test-piece' });
|
||||||
const [task] = runner.claimNextTasks(1);
|
const [task] = runner.claimNextTasks(1);
|
||||||
if (!task) throw new Error('No task claimed');
|
if (!task) throw new Error('No task claimed');
|
||||||
|
|
||||||
@ -212,7 +213,7 @@ describe('シナリオ1・2: exceeded status transition via executeAndCompleteTa
|
|||||||
});
|
});
|
||||||
|
|
||||||
// When: executeAndCompleteTask records the exceeded result
|
// When: executeAndCompleteTask records the exceeded result
|
||||||
await executeAndCompleteTask(task, runner, testDir, 'test-piece');
|
await executeAndCompleteTask(task, runner, testDir);
|
||||||
|
|
||||||
// Then: YAML contains the three resumption fields
|
// Then: YAML contains the three resumption fields
|
||||||
const file = loadTasksFile(testDir);
|
const file = loadTasksFile(testDir);
|
||||||
@ -267,7 +268,7 @@ describe('シナリオ3・4: requeue → re-execution passes exceeded metadata t
|
|||||||
vi.mocked(executePiece).mockResolvedValueOnce({ success: true });
|
vi.mocked(executePiece).mockResolvedValueOnce({ success: true });
|
||||||
|
|
||||||
// When: executeAndCompleteTask runs the requeued task
|
// When: executeAndCompleteTask runs the requeued task
|
||||||
await executeAndCompleteTask(task, runner, testDir, 'test-piece');
|
await executeAndCompleteTask(task, runner, testDir);
|
||||||
|
|
||||||
// Then: executePiece received the correct exceeded override options
|
// Then: executePiece received the correct exceeded override options
|
||||||
expect(vi.mocked(executePiece)).toHaveBeenCalledOnce();
|
expect(vi.mocked(executePiece)).toHaveBeenCalledOnce();
|
||||||
@ -297,7 +298,7 @@ describe('シナリオ3・4: requeue → re-execution passes exceeded metadata t
|
|||||||
vi.mocked(executePiece).mockResolvedValueOnce({ success: true });
|
vi.mocked(executePiece).mockResolvedValueOnce({ success: true });
|
||||||
|
|
||||||
// When: executeAndCompleteTask runs the requeued task
|
// When: executeAndCompleteTask runs the requeued task
|
||||||
await executeAndCompleteTask(task, runner, testDir, 'test-piece');
|
await executeAndCompleteTask(task, runner, testDir);
|
||||||
|
|
||||||
// Then: executePiece received startMovement='implement' to resume from where it stopped
|
// Then: executePiece received startMovement='implement' to resume from where it stopped
|
||||||
expect(vi.mocked(executePiece)).toHaveBeenCalledOnce();
|
expect(vi.mocked(executePiece)).toHaveBeenCalledOnce();
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* CLI subcommand definitions
|
* CLI subcommand definitions
|
||||||
*
|
*
|
||||||
* Registers all named subcommands (run, watch, add, list, switch, clear, eject, prompt, catalog).
|
* Registers all named subcommands (run, watch, add, list, clear, eject, prompt, catalog).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
@ -9,7 +9,7 @@ import { clearPersonaSessions, resolveConfigValue } from '../../infra/config/ind
|
|||||||
import { getGlobalConfigDir } from '../../infra/config/paths.js';
|
import { getGlobalConfigDir } from '../../infra/config/paths.js';
|
||||||
import { success, info } from '../../shared/ui/index.js';
|
import { success, info } from '../../shared/ui/index.js';
|
||||||
import { runAllTasks, addTask, watchTasks, listTasks } from '../../features/tasks/index.js';
|
import { runAllTasks, addTask, watchTasks, listTasks } from '../../features/tasks/index.js';
|
||||||
import { switchPiece, ejectBuiltin, ejectFacet, parseFacetType, VALID_FACET_TYPES, resetCategoriesToDefault, resetConfigToDefault, deploySkill } from '../../features/config/index.js';
|
import { ejectBuiltin, ejectFacet, parseFacetType, VALID_FACET_TYPES, resetCategoriesToDefault, resetConfigToDefault, deploySkill } from '../../features/config/index.js';
|
||||||
import { previewPrompts } from '../../features/prompt/index.js';
|
import { previewPrompts } from '../../features/prompt/index.js';
|
||||||
import { showCatalog } from '../../features/catalog/index.js';
|
import { showCatalog } from '../../features/catalog/index.js';
|
||||||
import { computeReviewMetrics, formatReviewMetrics, parseSinceDuration, purgeOldEvents } from '../../features/analytics/index.js';
|
import { computeReviewMetrics, formatReviewMetrics, parseSinceDuration, purgeOldEvents } from '../../features/analytics/index.js';
|
||||||
@ -23,8 +23,7 @@ program
|
|||||||
.command('run')
|
.command('run')
|
||||||
.description('Run all pending tasks from .takt/tasks.yaml')
|
.description('Run all pending tasks from .takt/tasks.yaml')
|
||||||
.action(async () => {
|
.action(async () => {
|
||||||
const piece = resolveConfigValue(resolvedCwd, 'piece');
|
await runAllTasks(resolvedCwd, resolveAgentOverrides(program));
|
||||||
await runAllTasks(resolvedCwd, piece, resolveAgentOverrides(program));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
@ -65,14 +64,6 @@ program
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
program
|
|
||||||
.command('switch')
|
|
||||||
.description('Switch piece interactively')
|
|
||||||
.argument('[piece]', 'Piece name')
|
|
||||||
.action(async (piece?: string) => {
|
|
||||||
await switchPiece(resolvedCwd, piece);
|
|
||||||
});
|
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('clear')
|
.command('clear')
|
||||||
.description('Clear agent conversation sessions')
|
.description('Clear agent conversation sessions')
|
||||||
@ -124,7 +115,7 @@ reset
|
|||||||
program
|
program
|
||||||
.command('prompt')
|
.command('prompt')
|
||||||
.description('Preview assembled prompts for each movement and phase')
|
.description('Preview assembled prompts for each movement and phase')
|
||||||
.argument('[piece]', 'Piece name or path (defaults to current)')
|
.argument('[piece]', 'Piece name or path (defaults to "default")')
|
||||||
.action(async (piece?: string) => {
|
.action(async (piece?: string) => {
|
||||||
await previewPrompts(resolvedCwd, piece);
|
await previewPrompts(resolvedCwd, piece);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import type { TaskExecutionOptions } from '../../features/tasks/index.js';
|
|||||||
import type { ProviderType } from '../../infra/providers/index.js';
|
import type { ProviderType } from '../../infra/providers/index.js';
|
||||||
import { isIssueReference } from '../../infra/github/index.js';
|
import { isIssueReference } from '../../infra/github/index.js';
|
||||||
|
|
||||||
|
const REMOVED_ROOT_COMMANDS = new Set(['switch']);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve --provider and --model options into TaskExecutionOptions.
|
* Resolve --provider and --model options into TaskExecutionOptions.
|
||||||
* Returns undefined if neither is specified.
|
* Returns undefined if neither is specified.
|
||||||
@ -42,3 +44,25 @@ export function resolveAgentOverrides(program: Command): TaskExecutionOptions |
|
|||||||
export function isDirectTask(input: string): boolean {
|
export function isDirectTask(input: string): boolean {
|
||||||
return isIssueReference(input) || input.trim().split(/\s+/).every((t: string) => isIssueReference(t));
|
return isIssueReference(input) || input.trim().split(/\s+/).every((t: string) => isIssueReference(t));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveSlashFallbackTask(args: string[], knownCommands: string[]): string | null {
|
||||||
|
const firstArg = args[0];
|
||||||
|
if (!firstArg?.startsWith('/')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandName = firstArg.slice(1);
|
||||||
|
if (knownCommands.includes(commandName)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return args.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRemovedRootCommand(args: string[]): string | null {
|
||||||
|
const firstArg = args[0];
|
||||||
|
if (!firstArg) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return REMOVED_ROOT_COMMANDS.has(firstArg) ? firstArg : null;
|
||||||
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
import { checkForUpdates } from '../../shared/utils/index.js';
|
import { checkForUpdates } from '../../shared/utils/index.js';
|
||||||
import { getErrorMessage } from '../../shared/utils/error.js';
|
import { getErrorMessage } from '../../shared/utils/error.js';
|
||||||
import { error as errorLog } from '../../shared/ui/index.js';
|
import { error as errorLog } from '../../shared/ui/index.js';
|
||||||
|
import { resolveRemovedRootCommand, resolveSlashFallbackTask } from './helpers.js';
|
||||||
|
|
||||||
checkForUpdates();
|
checkForUpdates();
|
||||||
|
|
||||||
@ -19,20 +20,20 @@ import { executeDefaultAction } from './routing.js';
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const firstArg = args[0];
|
const { operands } = program.parseOptions(args);
|
||||||
|
const removedRootCommand = resolveRemovedRootCommand(operands);
|
||||||
// Handle '/' prefixed inputs that are not known commands
|
if (removedRootCommand !== null) {
|
||||||
if (firstArg?.startsWith('/')) {
|
errorLog(`error: unknown command '${removedRootCommand}'`);
|
||||||
const commandName = firstArg.slice(1);
|
process.exit(1);
|
||||||
const knownCommands = program.commands.map((cmd) => cmd.name());
|
|
||||||
|
|
||||||
if (!knownCommands.includes(commandName)) {
|
|
||||||
// Treat as task instruction
|
|
||||||
const task = args.join(' ');
|
|
||||||
await runPreActionHook();
|
|
||||||
await executeDefaultAction(task);
|
|
||||||
process.exit(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const knownCommands = program.commands.map((cmd) => cmd.name());
|
||||||
|
const slashFallbackTask = resolveSlashFallbackTask(args, knownCommands);
|
||||||
|
|
||||||
|
if (slashFallbackTask !== null) {
|
||||||
|
await runPreActionHook();
|
||||||
|
await executeDefaultAction(slashFallbackTask);
|
||||||
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normal parsing for all other cases (including '#' prefixed inputs)
|
// Normal parsing for all other cases (including '#' prefixed inputs)
|
||||||
|
|||||||
@ -19,7 +19,6 @@ import { program, resolvedCwd, pipelineMode } from './program.js';
|
|||||||
import { resolveAgentOverrides } from './helpers.js';
|
import { resolveAgentOverrides } from './helpers.js';
|
||||||
import { loadTaskHistory } from './taskHistory.js';
|
import { loadTaskHistory } from './taskHistory.js';
|
||||||
import { resolveIssueInput, resolvePrInput } from './routing-inputs.js';
|
import { resolveIssueInput, resolvePrInput } from './routing-inputs.js';
|
||||||
import { DEFAULT_PIECE_NAME } from '../../shared/constants.js';
|
|
||||||
export async function executeDefaultAction(task?: string): Promise<void> {
|
export async function executeDefaultAction(task?: string): Promise<void> {
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
if (!pipelineMode && (opts.autoPr === true || opts.draft === true)) {
|
if (!pipelineMode && (opts.autoPr === true || opts.draft === true)) {
|
||||||
@ -39,9 +38,11 @@ export async function executeDefaultAction(task?: string): Promise<void> {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const agentOverrides = resolveAgentOverrides(program);
|
const agentOverrides = resolveAgentOverrides(program);
|
||||||
const resolvedPipelinePiece = (opts.piece as string | undefined)
|
const resolvedPipelinePiece = opts.piece as string | undefined;
|
||||||
?? resolveConfigValue(resolvedCwd, 'piece')
|
if (pipelineMode && resolvedPipelinePiece === undefined) {
|
||||||
?? DEFAULT_PIECE_NAME;
|
logError('--piece (-w) is required in pipeline mode');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
const resolvedPipelineAutoPr = opts.autoPr === true
|
const resolvedPipelineAutoPr = opts.autoPr === true
|
||||||
? true
|
? true
|
||||||
: (resolveConfigValue(resolvedCwd, 'autoPr') ?? false);
|
: (resolveConfigValue(resolvedCwd, 'autoPr') ?? false);
|
||||||
@ -57,7 +58,7 @@ export async function executeDefaultAction(task?: string): Promise<void> {
|
|||||||
issueNumber,
|
issueNumber,
|
||||||
prNumber,
|
prNumber,
|
||||||
task: opts.task as string | undefined,
|
task: opts.task as string | undefined,
|
||||||
piece: resolvedPipelinePiece,
|
piece: resolvedPipelinePiece!,
|
||||||
branch: opts.branch as string | undefined,
|
branch: opts.branch as string | undefined,
|
||||||
autoPr: resolvedPipelineAutoPr,
|
autoPr: resolvedPipelineAutoPr,
|
||||||
draftPr: resolvedPipelineDraftPr,
|
draftPr: resolvedPipelineDraftPr,
|
||||||
|
|||||||
@ -92,8 +92,6 @@ export interface PersistedGlobalConfig {
|
|||||||
language: Language;
|
language: Language;
|
||||||
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
|
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
|
||||||
model?: string;
|
model?: string;
|
||||||
/** Default piece name for new tasks (resolved via config layers: project > global > 'default') */
|
|
||||||
piece?: string;
|
|
||||||
/** @globalOnly */
|
/** @globalOnly */
|
||||||
observability?: ObservabilityConfig;
|
observability?: ObservabilityConfig;
|
||||||
analytics?: AnalyticsConfig;
|
analytics?: AnalyticsConfig;
|
||||||
@ -181,7 +179,6 @@ export interface PersistedGlobalConfig {
|
|||||||
|
|
||||||
/** Project-level configuration */
|
/** Project-level configuration */
|
||||||
export interface ProjectConfig {
|
export interface ProjectConfig {
|
||||||
piece?: string;
|
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
|
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
|
||||||
model?: string;
|
model?: string;
|
||||||
|
|||||||
@ -500,8 +500,6 @@ export const GlobalConfigSchema = z.object({
|
|||||||
language: LanguageSchema.optional().default(DEFAULT_LANGUAGE),
|
language: LanguageSchema.optional().default(DEFAULT_LANGUAGE),
|
||||||
provider: ProviderReferenceSchema.optional().default('claude'),
|
provider: ProviderReferenceSchema.optional().default('claude'),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
/** Default piece name for new tasks */
|
|
||||||
piece: z.string().optional(),
|
|
||||||
observability: ObservabilityConfigSchema.optional(),
|
observability: ObservabilityConfigSchema.optional(),
|
||||||
analytics: AnalyticsConfigSchema.optional(),
|
analytics: AnalyticsConfigSchema.optional(),
|
||||||
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
|
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
|
||||||
@ -572,7 +570,6 @@ export const GlobalConfigSchema = z.object({
|
|||||||
|
|
||||||
/** Project config schema */
|
/** Project config schema */
|
||||||
export const ProjectConfigSchema = z.object({
|
export const ProjectConfigSchema = z.object({
|
||||||
piece: z.string().optional(),
|
|
||||||
log_level: z.enum(['debug', 'info', 'warn', 'error']).optional(),
|
log_level: z.enum(['debug', 'info', 'warn', 'error']).optional(),
|
||||||
verbose: z.boolean().optional(),
|
verbose: z.boolean().optional(),
|
||||||
provider: ProviderReferenceSchema.optional(),
|
provider: ProviderReferenceSchema.optional(),
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
* Config feature exports
|
* Config feature exports
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { switchPiece } from './switchPiece.js';
|
|
||||||
export { ejectBuiltin, ejectFacet, parseFacetType, VALID_FACET_TYPES } from './ejectBuiltin.js';
|
export { ejectBuiltin, ejectFacet, parseFacetType, VALID_FACET_TYPES } from './ejectBuiltin.js';
|
||||||
export { resetCategoriesToDefault } from './resetCategories.js';
|
export { resetCategoriesToDefault } from './resetCategories.js';
|
||||||
export { resetConfigToDefault } from './resetConfig.js';
|
export { resetConfigToDefault } from './resetConfig.js';
|
||||||
|
|||||||
@ -1,44 +0,0 @@
|
|||||||
/**
|
|
||||||
* Piece switching command
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
loadPiece,
|
|
||||||
resolveConfigValue,
|
|
||||||
setCurrentPiece,
|
|
||||||
} from '../../infra/config/index.js';
|
|
||||||
import { info, success, error } from '../../shared/ui/index.js';
|
|
||||||
import { selectPiece } from '../pieceSelection/index.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Switch to a different piece
|
|
||||||
* @returns true if switch was successful
|
|
||||||
*/
|
|
||||||
export async function switchPiece(cwd: string, pieceName?: string): Promise<boolean> {
|
|
||||||
if (!pieceName) {
|
|
||||||
const current = resolveConfigValue(cwd, 'piece');
|
|
||||||
info(`Current piece: ${current}`);
|
|
||||||
|
|
||||||
const selected = await selectPiece(cwd, { fallbackToDefault: false });
|
|
||||||
if (!selected) {
|
|
||||||
info('Cancelled');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
pieceName = selected;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if piece exists
|
|
||||||
const config = loadPiece(pieceName, cwd);
|
|
||||||
|
|
||||||
if (!config) {
|
|
||||||
error(`Piece "${pieceName}" not found`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save to project config
|
|
||||||
setCurrentPiece(cwd, pieceName);
|
|
||||||
success(`Switched to piece: ${pieceName}`);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
@ -16,7 +16,6 @@ import {
|
|||||||
loadAllPiecesWithSources,
|
loadAllPiecesWithSources,
|
||||||
getPieceCategories,
|
getPieceCategories,
|
||||||
buildCategorizedPieces,
|
buildCategorizedPieces,
|
||||||
resolveConfigValue,
|
|
||||||
type PieceDirEntry,
|
type PieceDirEntry,
|
||||||
type PieceCategoryNode,
|
type PieceCategoryNode,
|
||||||
type CategorizedPieces,
|
type CategorizedPieces,
|
||||||
@ -71,16 +70,12 @@ const CATEGORY_VALUE_PREFIX = '__category__:';
|
|||||||
*/
|
*/
|
||||||
export function buildTopLevelSelectOptions(
|
export function buildTopLevelSelectOptions(
|
||||||
items: PieceSelectionItem[],
|
items: PieceSelectionItem[],
|
||||||
currentPiece: string,
|
|
||||||
): SelectionOption[] {
|
): SelectionOption[] {
|
||||||
return items.map((item) => {
|
return items.map((item) => {
|
||||||
if (item.type === 'piece') {
|
if (item.type === 'piece') {
|
||||||
const isCurrent = item.name === currentPiece;
|
return { label: item.name, value: item.name };
|
||||||
const label = isCurrent ? `${item.name} (current)` : item.name;
|
|
||||||
return { label, value: item.name };
|
|
||||||
}
|
}
|
||||||
const containsCurrent = item.pieces.some((w) => w === currentPiece);
|
const label = `📁 ${item.name}/`;
|
||||||
const label = containsCurrent ? `📁 ${item.name}/ (current)` : `📁 ${item.name}/`;
|
|
||||||
return { label, value: `${CATEGORY_VALUE_PREFIX}${item.name}` };
|
return { label, value: `${CATEGORY_VALUE_PREFIX}${item.name}` };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -102,7 +97,6 @@ export function parseCategorySelection(selected: string): string | null {
|
|||||||
export function buildCategoryPieceOptions(
|
export function buildCategoryPieceOptions(
|
||||||
items: PieceSelectionItem[],
|
items: PieceSelectionItem[],
|
||||||
categoryName: string,
|
categoryName: string,
|
||||||
currentPiece: string,
|
|
||||||
): SelectionOption[] | null {
|
): SelectionOption[] | null {
|
||||||
const categoryItem = items.find(
|
const categoryItem = items.find(
|
||||||
(item) => item.type === 'category' && item.name === categoryName,
|
(item) => item.type === 'category' && item.name === categoryName,
|
||||||
@ -111,9 +105,7 @@ export function buildCategoryPieceOptions(
|
|||||||
|
|
||||||
return categoryItem.pieces.map((qualifiedName) => {
|
return categoryItem.pieces.map((qualifiedName) => {
|
||||||
const displayName = qualifiedName.split('/').pop() ?? qualifiedName;
|
const displayName = qualifiedName.split('/').pop() ?? qualifiedName;
|
||||||
const isCurrent = qualifiedName === currentPiece;
|
return { label: displayName, value: qualifiedName };
|
||||||
const label = isCurrent ? `${displayName} (current)` : displayName;
|
|
||||||
return { label, value: qualifiedName };
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -147,18 +139,9 @@ export function warnMissingPieces(missing: MissingPiece[]): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function categoryContainsPiece(node: PieceCategoryNode, piece: string): boolean {
|
|
||||||
if (node.pieces.includes(piece)) return true;
|
|
||||||
for (const child of node.children) {
|
|
||||||
if (categoryContainsPiece(child, piece)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCategoryLevelOptions(
|
function buildCategoryLevelOptions(
|
||||||
categories: PieceCategoryNode[],
|
categories: PieceCategoryNode[],
|
||||||
pieces: string[],
|
pieces: string[],
|
||||||
currentPiece: string,
|
|
||||||
): {
|
): {
|
||||||
options: SelectionOption[];
|
options: SelectionOption[];
|
||||||
categoryMap: Map<string, PieceCategoryNode>;
|
categoryMap: Map<string, PieceCategoryNode>;
|
||||||
@ -167,18 +150,14 @@ function buildCategoryLevelOptions(
|
|||||||
const categoryMap = new Map<string, PieceCategoryNode>();
|
const categoryMap = new Map<string, PieceCategoryNode>();
|
||||||
|
|
||||||
for (const category of categories) {
|
for (const category of categories) {
|
||||||
const containsCurrent = currentPiece.length > 0 && categoryContainsPiece(category, currentPiece);
|
const label = `📁 ${category.name}/`;
|
||||||
const label = containsCurrent
|
|
||||||
? `📁 ${category.name}/ (current)`
|
|
||||||
: `📁 ${category.name}/`;
|
|
||||||
const value = `${CATEGORY_VALUE_PREFIX}${category.name}`;
|
const value = `${CATEGORY_VALUE_PREFIX}${category.name}`;
|
||||||
options.push({ label, value });
|
options.push({ label, value });
|
||||||
categoryMap.set(category.name, category);
|
categoryMap.set(category.name, category);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const pieceName of pieces) {
|
for (const pieceName of pieces) {
|
||||||
const isCurrent = pieceName === currentPiece;
|
const label = `🎼 ${pieceName}`;
|
||||||
const label = isCurrent ? `🎼 ${pieceName} (current)` : `🎼 ${pieceName}`;
|
|
||||||
options.push({ label, value: pieceName });
|
options.push({ label, value: pieceName });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -187,7 +166,6 @@ function buildCategoryLevelOptions(
|
|||||||
|
|
||||||
async function selectPieceFromCategoryTree(
|
async function selectPieceFromCategoryTree(
|
||||||
categories: PieceCategoryNode[],
|
categories: PieceCategoryNode[],
|
||||||
currentPiece: string,
|
|
||||||
hasSourceSelection: boolean,
|
hasSourceSelection: boolean,
|
||||||
rootPieces: string[] = [],
|
rootPieces: string[] = [],
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
@ -207,7 +185,6 @@ async function selectPieceFromCategoryTree(
|
|||||||
const { options, categoryMap } = buildCategoryLevelOptions(
|
const { options, categoryMap } = buildCategoryLevelOptions(
|
||||||
currentCategories,
|
currentCategories,
|
||||||
currentPieces,
|
currentPieces,
|
||||||
currentPiece,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (options.length === 0) {
|
if (options.length === 0) {
|
||||||
@ -268,33 +245,21 @@ async function selectPieceFromCategoryTree(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const CURRENT_PIECE_VALUE = '__current__';
|
|
||||||
const CUSTOM_CATEGORY_PREFIX = '__custom_category__:';
|
const CUSTOM_CATEGORY_PREFIX = '__custom_category__:';
|
||||||
|
|
||||||
type TopLevelSelection =
|
type TopLevelSelection =
|
||||||
| { type: 'current' }
|
|
||||||
| { type: 'piece'; name: string }
|
| { type: 'piece'; name: string }
|
||||||
| { type: 'category'; node: PieceCategoryNode };
|
| { type: 'category'; node: PieceCategoryNode };
|
||||||
|
|
||||||
async function selectTopLevelPieceOption(
|
async function selectTopLevelPieceOption(
|
||||||
categorized: CategorizedPieces,
|
categorized: CategorizedPieces,
|
||||||
currentPiece: string,
|
|
||||||
): Promise<TopLevelSelection | null> {
|
): Promise<TopLevelSelection | null> {
|
||||||
const buildOptions = (): SelectOptionItem<string>[] => {
|
const buildOptions = (): SelectOptionItem<string>[] => {
|
||||||
const options: SelectOptionItem<string>[] = [];
|
const options: SelectOptionItem<string>[] = [];
|
||||||
const bookmarkedPieces = getBookmarkedPieces();
|
const bookmarkedPieces = getBookmarkedPieces();
|
||||||
|
|
||||||
// 1. Current piece
|
|
||||||
if (currentPiece) {
|
|
||||||
options.push({
|
|
||||||
label: `🎼 ${currentPiece} (current)`,
|
|
||||||
value: CURRENT_PIECE_VALUE,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Bookmarked pieces (individual items)
|
// 2. Bookmarked pieces (individual items)
|
||||||
for (const pieceName of bookmarkedPieces) {
|
for (const pieceName of bookmarkedPieces) {
|
||||||
if (pieceName === currentPiece) continue;
|
|
||||||
options.push({
|
options.push({
|
||||||
label: `🎼 ${pieceName} [*]`,
|
label: `🎼 ${pieceName} [*]`,
|
||||||
value: pieceName,
|
value: pieceName,
|
||||||
@ -316,7 +281,7 @@ async function selectTopLevelPieceOption(
|
|||||||
|
|
||||||
const result = await selectOption<string>('Select piece:', buildOptions(), {
|
const result = await selectOption<string>('Select piece:', buildOptions(), {
|
||||||
onKeyPress: (key: string, value: string): SelectOptionItem<string>[] | null => {
|
onKeyPress: (key: string, value: string): SelectOptionItem<string>[] | null => {
|
||||||
if (value === CURRENT_PIECE_VALUE || value.startsWith(CUSTOM_CATEGORY_PREFIX)) {
|
if (value.startsWith(CUSTOM_CATEGORY_PREFIX)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -336,10 +301,6 @@ async function selectTopLevelPieceOption(
|
|||||||
|
|
||||||
if (!result) return null;
|
if (!result) return null;
|
||||||
|
|
||||||
if (result === CURRENT_PIECE_VALUE) {
|
|
||||||
return { type: 'current' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.startsWith(CUSTOM_CATEGORY_PREFIX)) {
|
if (result.startsWith(CUSTOM_CATEGORY_PREFIX)) {
|
||||||
const categoryName = result.slice(CUSTOM_CATEGORY_PREFIX.length);
|
const categoryName = result.slice(CUSTOM_CATEGORY_PREFIX.length);
|
||||||
const node = categorized.categories.find(c => c.name === categoryName);
|
const node = categorized.categories.find(c => c.name === categoryName);
|
||||||
@ -355,20 +316,16 @@ async function selectTopLevelPieceOption(
|
|||||||
*/
|
*/
|
||||||
export async function selectPieceFromCategorizedPieces(
|
export async function selectPieceFromCategorizedPieces(
|
||||||
categorized: CategorizedPieces,
|
categorized: CategorizedPieces,
|
||||||
currentPiece: string,
|
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
while (true) {
|
while (true) {
|
||||||
const selection = await selectTopLevelPieceOption(categorized, currentPiece);
|
const selection = await selectTopLevelPieceOption(categorized);
|
||||||
if (!selection) return null;
|
if (!selection) return null;
|
||||||
|
|
||||||
if (selection.type === 'current') return currentPiece;
|
|
||||||
|
|
||||||
if (selection.type === 'piece') return selection.name;
|
if (selection.type === 'piece') return selection.name;
|
||||||
|
|
||||||
if (selection.type === 'category') {
|
if (selection.type === 'category') {
|
||||||
const piece = await selectPieceFromCategoryTree(
|
const piece = await selectPieceFromCategoryTree(
|
||||||
selection.node.children,
|
selection.node.children,
|
||||||
currentPiece,
|
|
||||||
true,
|
true,
|
||||||
selection.node.pieces,
|
selection.node.pieces,
|
||||||
);
|
);
|
||||||
@ -380,7 +337,6 @@ export async function selectPieceFromCategorizedPieces(
|
|||||||
|
|
||||||
async function selectPieceFromEntriesWithCategories(
|
async function selectPieceFromEntriesWithCategories(
|
||||||
entries: PieceDirEntry[],
|
entries: PieceDirEntry[],
|
||||||
currentPiece: string,
|
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
if (entries.length === 0) return null;
|
if (entries.length === 0) return null;
|
||||||
|
|
||||||
@ -390,7 +346,7 @@ async function selectPieceFromEntriesWithCategories(
|
|||||||
|
|
||||||
if (!hasCategories) {
|
if (!hasCategories) {
|
||||||
const baseOptions: SelectionOption[] = availablePieces.map((name) => ({
|
const baseOptions: SelectionOption[] = availablePieces.map((name) => ({
|
||||||
label: name === currentPiece ? `🎼 ${name} (current)` : `🎼 ${name}`,
|
label: `🎼 ${name}`,
|
||||||
value: name,
|
value: name,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -415,7 +371,7 @@ async function selectPieceFromEntriesWithCategories(
|
|||||||
// Loop until user selects a piece or cancels at top level
|
// Loop until user selects a piece or cancels at top level
|
||||||
while (true) {
|
while (true) {
|
||||||
const buildTopLevelOptions = (): SelectionOption[] =>
|
const buildTopLevelOptions = (): SelectionOption[] =>
|
||||||
applyBookmarks(buildTopLevelSelectOptions(items, currentPiece), getBookmarkedPieces());
|
applyBookmarks(buildTopLevelSelectOptions(items), getBookmarkedPieces());
|
||||||
|
|
||||||
const selected = await selectOption<string>('Select piece:', buildTopLevelOptions(), {
|
const selected = await selectOption<string>('Select piece:', buildTopLevelOptions(), {
|
||||||
onKeyPress: (key: string, value: string): SelectOptionItem<string>[] | null => {
|
onKeyPress: (key: string, value: string): SelectOptionItem<string>[] | null => {
|
||||||
@ -441,7 +397,7 @@ async function selectPieceFromEntriesWithCategories(
|
|||||||
|
|
||||||
const categoryName = parseCategorySelection(selected);
|
const categoryName = parseCategorySelection(selected);
|
||||||
if (categoryName) {
|
if (categoryName) {
|
||||||
const categoryOptions = buildCategoryPieceOptions(items, categoryName, currentPiece);
|
const categoryOptions = buildCategoryPieceOptions(items, categoryName);
|
||||||
if (!categoryOptions) continue;
|
if (!categoryOptions) continue;
|
||||||
|
|
||||||
const buildCategoryOptions = (): SelectionOption[] =>
|
const buildCategoryOptions = (): SelectionOption[] =>
|
||||||
@ -476,7 +432,6 @@ async function selectPieceFromEntriesWithCategories(
|
|||||||
*/
|
*/
|
||||||
export async function selectPieceFromEntries(
|
export async function selectPieceFromEntries(
|
||||||
entries: PieceDirEntry[],
|
entries: PieceDirEntry[],
|
||||||
currentPiece: string,
|
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const builtinEntries = entries.filter((entry) => entry.source === 'builtin');
|
const builtinEntries = entries.filter((entry) => entry.source === 'builtin');
|
||||||
const customEntries = entries.filter((entry) => entry.source !== 'builtin');
|
const customEntries = entries.filter((entry) => entry.source !== 'builtin');
|
||||||
@ -488,11 +443,11 @@ export async function selectPieceFromEntries(
|
|||||||
]);
|
]);
|
||||||
if (!selectedSource) return null;
|
if (!selectedSource) return null;
|
||||||
const sourceEntries = selectedSource === 'custom' ? customEntries : builtinEntries;
|
const sourceEntries = selectedSource === 'custom' ? customEntries : builtinEntries;
|
||||||
return selectPieceFromEntriesWithCategories(sourceEntries, currentPiece);
|
return selectPieceFromEntriesWithCategories(sourceEntries);
|
||||||
}
|
}
|
||||||
|
|
||||||
const entriesToUse = customEntries.length > 0 ? customEntries : builtinEntries;
|
const entriesToUse = customEntries.length > 0 ? customEntries : builtinEntries;
|
||||||
return selectPieceFromEntriesWithCategories(entriesToUse, currentPiece);
|
return selectPieceFromEntriesWithCategories(entriesToUse);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SelectPieceOptions {
|
export interface SelectPieceOptions {
|
||||||
@ -505,7 +460,6 @@ export async function selectPiece(
|
|||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const fallbackToDefault = options?.fallbackToDefault !== false;
|
const fallbackToDefault = options?.fallbackToDefault !== false;
|
||||||
const categoryConfig = getPieceCategories(cwd);
|
const categoryConfig = getPieceCategories(cwd);
|
||||||
const currentPiece = resolveConfigValue(cwd, 'piece') ?? DEFAULT_PIECE_NAME;
|
|
||||||
|
|
||||||
if (categoryConfig) {
|
if (categoryConfig) {
|
||||||
const allPieces = loadAllPiecesWithSources(cwd);
|
const allPieces = loadAllPiecesWithSources(cwd);
|
||||||
@ -519,7 +473,7 @@ export async function selectPiece(
|
|||||||
}
|
}
|
||||||
const categorized = buildCategorizedPieces(allPieces, categoryConfig, cwd);
|
const categorized = buildCategorizedPieces(allPieces, categoryConfig, cwd);
|
||||||
warnMissingPieces(categorized.missingPieces.filter((missing) => missing.source === 'user'));
|
warnMissingPieces(categorized.missingPieces.filter((missing) => missing.source === 'user'));
|
||||||
return selectPieceFromCategorizedPieces(categorized, currentPiece);
|
return selectPieceFromCategorizedPieces(categorized);
|
||||||
}
|
}
|
||||||
|
|
||||||
const availablePieces = listPieces(cwd);
|
const availablePieces = listPieces(cwd);
|
||||||
@ -533,5 +487,5 @@ export async function selectPiece(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const entries = listPieceEntries(cwd);
|
const entries = listPieceEntries(cwd);
|
||||||
return selectPieceFromEntries(entries, currentPiece);
|
return selectPieceFromEntries(entries);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,7 +22,7 @@ import { DEFAULT_PIECE_NAME } from '../../shared/constants.js';
|
|||||||
* the Phase 1, Phase 2, and Phase 3 prompts with sample variable values.
|
* the Phase 1, Phase 2, and Phase 3 prompts with sample variable values.
|
||||||
*/
|
*/
|
||||||
export async function previewPrompts(cwd: string, pieceIdentifier?: string): Promise<void> {
|
export async function previewPrompts(cwd: string, pieceIdentifier?: string): Promise<void> {
|
||||||
const identifier = pieceIdentifier ?? resolvePieceConfigValue(cwd, 'piece') ?? DEFAULT_PIECE_NAME;
|
const identifier = pieceIdentifier ?? DEFAULT_PIECE_NAME;
|
||||||
const config = loadPieceByIdentifier(identifier, cwd);
|
const config = loadPieceByIdentifier(identifier, cwd);
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
|
|||||||
@ -93,7 +93,6 @@ export async function runWithWorkerPool(
|
|||||||
initialTasks: TaskInfo[],
|
initialTasks: TaskInfo[],
|
||||||
concurrency: number,
|
concurrency: number,
|
||||||
cwd: string,
|
cwd: string,
|
||||||
pieceName: string,
|
|
||||||
options: TaskExecutionOptions | undefined,
|
options: TaskExecutionOptions | undefined,
|
||||||
pollIntervalMs: number,
|
pollIntervalMs: number,
|
||||||
): Promise<WorkerPoolResult> {
|
): Promise<WorkerPoolResult> {
|
||||||
@ -119,7 +118,7 @@ export async function runWithWorkerPool(
|
|||||||
try {
|
try {
|
||||||
while (queue.length > 0 || active.size > 0) {
|
while (queue.length > 0 || active.size > 0) {
|
||||||
if (!abortController.signal.aborted) {
|
if (!abortController.signal.aborted) {
|
||||||
fillSlots(queue, active, concurrency, taskRunner, cwd, pieceName, options, abortController, colorCounter);
|
fillSlots(queue, active, concurrency, taskRunner, cwd, options, abortController, colorCounter);
|
||||||
if ((selfSigintOnce || selfSigintTwice) && !selfSigintInjected && active.size > 0) {
|
if ((selfSigintOnce || selfSigintTwice) && !selfSigintInjected && active.size > 0) {
|
||||||
selfSigintInjected = true;
|
selfSigintInjected = true;
|
||||||
process.emit('SIGINT');
|
process.emit('SIGINT');
|
||||||
@ -197,7 +196,6 @@ function fillSlots(
|
|||||||
concurrency: number,
|
concurrency: number,
|
||||||
taskRunner: TaskRunner,
|
taskRunner: TaskRunner,
|
||||||
cwd: string,
|
cwd: string,
|
||||||
pieceName: string,
|
|
||||||
options: TaskExecutionOptions | undefined,
|
options: TaskExecutionOptions | undefined,
|
||||||
abortController: AbortController,
|
abortController: AbortController,
|
||||||
colorCounter: { value: number },
|
colorCounter: { value: number },
|
||||||
@ -223,7 +221,7 @@ function fillSlots(
|
|||||||
info(`=== Task: ${task.name} ===`);
|
info(`=== Task: ${task.name} ===`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const promise = executeAndCompleteTask(task, taskRunner, cwd, pieceName, options, {
|
const promise = executeAndCompleteTask(task, taskRunner, cwd, options, {
|
||||||
abortSignal: abortController.signal,
|
abortSignal: abortController.signal,
|
||||||
taskPrefix: isParallel ? taskPrefix : undefined,
|
taskPrefix: isParallel ? taskPrefix : undefined,
|
||||||
taskColorIndex: isParallel ? colorIndex : undefined,
|
taskColorIndex: isParallel ? colorIndex : undefined,
|
||||||
|
|||||||
@ -87,14 +87,17 @@ export function resolveTaskIssue(issueNumber: number | undefined): Issue[] | und
|
|||||||
export async function resolveTaskExecution(
|
export async function resolveTaskExecution(
|
||||||
task: TaskInfo,
|
task: TaskInfo,
|
||||||
defaultCwd: string,
|
defaultCwd: string,
|
||||||
defaultPiece: string,
|
|
||||||
abortSignal?: AbortSignal,
|
abortSignal?: AbortSignal,
|
||||||
): Promise<ResolvedTaskExecution> {
|
): Promise<ResolvedTaskExecution> {
|
||||||
throwIfAborted(abortSignal);
|
throwIfAborted(abortSignal);
|
||||||
|
|
||||||
const data = task.data;
|
const data = task.data;
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return { execCwd: defaultCwd, execPiece: defaultPiece, isWorktree: false, autoPr: false, draftPr: false };
|
throw new Error(`Task "${task.name}" is missing required data, including piece.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.piece || typeof data.piece !== 'string' || data.piece.trim() === '') {
|
||||||
|
throw new Error(`Task "${task.name}" is missing required piece.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let execCwd = defaultCwd;
|
let execCwd = defaultCwd;
|
||||||
@ -153,7 +156,7 @@ export async function resolveTaskExecution(
|
|||||||
taskPrompt = stageTaskSpecForExecution(defaultCwd, execCwd, task.taskDir, reportDirName);
|
taskPrompt = stageTaskSpecForExecution(defaultCwd, execCwd, task.taskDir, reportDirName);
|
||||||
}
|
}
|
||||||
|
|
||||||
const execPiece = data.piece || defaultPiece;
|
const execPiece = data.piece;
|
||||||
const startMovement = data.start_movement;
|
const startMovement = data.start_movement;
|
||||||
const retryNote = data.retry_note;
|
const retryNote = data.retry_note;
|
||||||
const maxMovementsOverride = data.exceeded_max_movements;
|
const maxMovementsOverride = data.exceeded_max_movements;
|
||||||
|
|||||||
@ -14,7 +14,6 @@ import {
|
|||||||
import { createLogger, getErrorMessage, getSlackWebhookUrl, notifyError, notifySuccess, sendSlackNotification, buildSlackRunSummary } from '../../../shared/utils/index.js';
|
import { createLogger, getErrorMessage, getSlackWebhookUrl, notifyError, notifySuccess, sendSlackNotification, buildSlackRunSummary } from '../../../shared/utils/index.js';
|
||||||
import { getLabel } from '../../../shared/i18n/index.js';
|
import { getLabel } from '../../../shared/i18n/index.js';
|
||||||
import { executePiece } from './pieceExecution.js';
|
import { executePiece } from './pieceExecution.js';
|
||||||
import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js';
|
|
||||||
import type { TaskExecutionOptions, ExecuteTaskOptions, PieceExecutionResult } from './types.js';
|
import type { TaskExecutionOptions, ExecuteTaskOptions, PieceExecutionResult } from './types.js';
|
||||||
import { runWithWorkerPool } from './parallelExecution.js';
|
import { runWithWorkerPool } from './parallelExecution.js';
|
||||||
import { resolveTaskExecution, resolveTaskIssue } from './resolveTask.js';
|
import { resolveTaskExecution, resolveTaskIssue } from './resolveTask.js';
|
||||||
@ -26,6 +25,13 @@ export type { TaskExecutionOptions, ExecuteTaskOptions };
|
|||||||
|
|
||||||
const log = createLogger('task');
|
const log = createLogger('task');
|
||||||
|
|
||||||
|
type TaskExecutionParallelOptions = {
|
||||||
|
abortSignal?: AbortSignal;
|
||||||
|
taskPrefix?: string;
|
||||||
|
taskColorIndex?: number;
|
||||||
|
taskDisplayLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
async function executeTaskWithResult(options: ExecuteTaskOptions): Promise<PieceExecutionResult> {
|
async function executeTaskWithResult(options: ExecuteTaskOptions): Promise<PieceExecutionResult> {
|
||||||
const {
|
const {
|
||||||
task,
|
task,
|
||||||
@ -54,7 +60,7 @@ async function executeTaskWithResult(options: ExecuteTaskOptions): Promise<Piece
|
|||||||
} else {
|
} else {
|
||||||
error(`Piece "${pieceIdentifier}" not found.`);
|
error(`Piece "${pieceIdentifier}" not found.`);
|
||||||
info('Available pieces are in ~/.takt/pieces/ or .takt/pieces/');
|
info('Available pieces are in ~/.takt/pieces/ or .takt/pieces/');
|
||||||
info('Use "takt switch" to select a piece.');
|
info('Specify a valid piece when creating tasks (e.g., via "takt add").');
|
||||||
return { success: false, reason: `Piece "${pieceIdentifier}" not found.` };
|
return { success: false, reason: `Piece "${pieceIdentifier}" not found.` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -109,9 +115,8 @@ export async function executeAndCompleteTask(
|
|||||||
task: TaskInfo,
|
task: TaskInfo,
|
||||||
taskRunner: TaskRunner,
|
taskRunner: TaskRunner,
|
||||||
cwd: string,
|
cwd: string,
|
||||||
pieceName: string,
|
taskExecutionOptions?: TaskExecutionOptions,
|
||||||
options?: TaskExecutionOptions,
|
parallelOptions?: TaskExecutionParallelOptions,
|
||||||
parallelOptions?: { abortSignal?: AbortSignal; taskPrefix?: string; taskColorIndex?: number; taskDisplayLabel?: string },
|
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const startedAt = new Date().toISOString();
|
const startedAt = new Date().toISOString();
|
||||||
const taskAbortController = new AbortController();
|
const taskAbortController = new AbortController();
|
||||||
@ -147,7 +152,7 @@ export async function executeAndCompleteTask(
|
|||||||
issueNumber,
|
issueNumber,
|
||||||
maxMovementsOverride,
|
maxMovementsOverride,
|
||||||
initialIterationOverride,
|
initialIterationOverride,
|
||||||
} = await resolveTaskExecution(task, cwd, pieceName, taskAbortSignal);
|
} = await resolveTaskExecution(task, cwd, taskAbortSignal);
|
||||||
|
|
||||||
// cwd is always the project root; pass it as projectCwd so reports/sessions go there
|
// cwd is always the project root; pass it as projectCwd so reports/sessions go there
|
||||||
const taskRunResult = await executeTaskWithResult({
|
const taskRunResult = await executeTaskWithResult({
|
||||||
@ -155,7 +160,7 @@ export async function executeAndCompleteTask(
|
|||||||
cwd: execCwd,
|
cwd: execCwd,
|
||||||
pieceIdentifier: execPiece,
|
pieceIdentifier: execPiece,
|
||||||
projectCwd: cwd,
|
projectCwd: cwd,
|
||||||
agentOverrides: options,
|
agentOverrides: taskExecutionOptions,
|
||||||
startMovement,
|
startMovement,
|
||||||
retryNote,
|
retryNote,
|
||||||
reportDirName,
|
reportDirName,
|
||||||
@ -233,7 +238,6 @@ export async function executeAndCompleteTask(
|
|||||||
*/
|
*/
|
||||||
export async function runAllTasks(
|
export async function runAllTasks(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
pieceName: string = DEFAULT_PIECE_NAME,
|
|
||||||
options?: TaskExecutionOptions,
|
options?: TaskExecutionOptions,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const taskRunner = new TaskRunner(cwd);
|
const taskRunner = new TaskRunner(cwd);
|
||||||
@ -286,7 +290,14 @@ export async function runAllTasks(
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await runWithWorkerPool(taskRunner, initialTasks, concurrency, cwd, pieceName, options, globalConfig.taskPollIntervalMs);
|
const result = await runWithWorkerPool(
|
||||||
|
taskRunner,
|
||||||
|
initialTasks,
|
||||||
|
concurrency,
|
||||||
|
cwd,
|
||||||
|
options,
|
||||||
|
globalConfig.taskPollIntervalMs,
|
||||||
|
);
|
||||||
|
|
||||||
const totalCount = result.success + result.fail;
|
const totalCount = result.success + result.fail;
|
||||||
blankLine();
|
blankLine();
|
||||||
|
|||||||
15
src/features/tasks/list/prepareTaskForExecution.ts
Normal file
15
src/features/tasks/list/prepareTaskForExecution.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import type { TaskInfo } from '../../../infra/task/index.js';
|
||||||
|
|
||||||
|
export function prepareTaskForExecution(taskInfo: TaskInfo, selectedPiece: string): TaskInfo {
|
||||||
|
if (!taskInfo.data) {
|
||||||
|
throw new Error(`Task "${taskInfo.name}" is missing required data.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...taskInfo,
|
||||||
|
data: {
|
||||||
|
...taskInfo.data,
|
||||||
|
piece: selectedPiece,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -27,6 +27,7 @@ import {
|
|||||||
selectRunSessionContext,
|
selectRunSessionContext,
|
||||||
} from './requeueHelpers.js';
|
} from './requeueHelpers.js';
|
||||||
import { executeAndCompleteTask } from '../execute/taskExecution.js';
|
import { executeAndCompleteTask } from '../execute/taskExecution.js';
|
||||||
|
import { prepareTaskForExecution } from './prepareTaskForExecution.js';
|
||||||
|
|
||||||
const log = createLogger('list-tasks');
|
const log = createLogger('list-tasks');
|
||||||
|
|
||||||
@ -128,6 +129,7 @@ export async function instructBranch(
|
|||||||
const retryNote = appendRetryNote(target.data?.retry_note, instruction);
|
const retryNote = appendRetryNote(target.data?.retry_note, instruction);
|
||||||
const runner = new TaskRunner(projectDir);
|
const runner = new TaskRunner(projectDir);
|
||||||
const taskInfo = runner.startReExecution(target.name, ['completed', 'failed'], undefined, retryNote);
|
const taskInfo = runner.startReExecution(target.name, ['completed', 'failed'], undefined, retryNote);
|
||||||
|
const taskForExecution = prepareTaskForExecution(taskInfo, selectedPiece);
|
||||||
|
|
||||||
log.info('Starting re-execution of instructed task', {
|
log.info('Starting re-execution of instructed task', {
|
||||||
name: target.name,
|
name: target.name,
|
||||||
@ -136,7 +138,7 @@ export async function instructBranch(
|
|||||||
piece: selectedPiece,
|
piece: selectedPiece,
|
||||||
});
|
});
|
||||||
|
|
||||||
return executeAndCompleteTask(taskInfo, runner, projectDir, selectedPiece);
|
return executeAndCompleteTask(taskForExecution, runner, projectDir);
|
||||||
};
|
};
|
||||||
|
|
||||||
return dispatchConversationAction(result, {
|
return dispatchConversationAction(result, {
|
||||||
|
|||||||
@ -32,6 +32,7 @@ import {
|
|||||||
DEPRECATED_PROVIDER_CONFIG_WARNING,
|
DEPRECATED_PROVIDER_CONFIG_WARNING,
|
||||||
hasDeprecatedProviderConfig,
|
hasDeprecatedProviderConfig,
|
||||||
} from './requeueHelpers.js';
|
} from './requeueHelpers.js';
|
||||||
|
import { prepareTaskForExecution } from './prepareTaskForExecution.js';
|
||||||
|
|
||||||
const log = createLogger('list-tasks');
|
const log = createLogger('list-tasks');
|
||||||
|
|
||||||
@ -227,6 +228,7 @@ export async function retryFailedTask(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const taskInfo = runner.startReExecution(task.name, ['failed'], startMovement, retryNote);
|
const taskInfo = runner.startReExecution(task.name, ['failed'], startMovement, retryNote);
|
||||||
|
const taskForExecution = prepareTaskForExecution(taskInfo, selectedPiece);
|
||||||
|
|
||||||
log.info('Starting re-execution of failed task', {
|
log.info('Starting re-execution of failed task', {
|
||||||
name: task.name,
|
name: task.name,
|
||||||
@ -234,5 +236,5 @@ export async function retryFailedTask(
|
|||||||
startMovement,
|
startMovement,
|
||||||
});
|
});
|
||||||
|
|
||||||
return executeAndCompleteTask(taskInfo, runner, projectDir, selectedPiece);
|
return executeAndCompleteTask(taskForExecution, runner, projectDir);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { TaskRunner, type TaskInfo, TaskWatcher } from '../../../infra/task/index.js';
|
import { TaskRunner, type TaskInfo, TaskWatcher } from '../../../infra/task/index.js';
|
||||||
import { resolveConfigValue } from '../../../infra/config/index.js';
|
|
||||||
import {
|
import {
|
||||||
header,
|
header,
|
||||||
info,
|
info,
|
||||||
@ -18,14 +17,12 @@ import { executeAndCompleteTask } from '../execute/taskExecution.js';
|
|||||||
import { EXIT_SIGINT } from '../../../shared/exitCodes.js';
|
import { EXIT_SIGINT } from '../../../shared/exitCodes.js';
|
||||||
import { ShutdownManager } from '../execute/shutdownManager.js';
|
import { ShutdownManager } from '../execute/shutdownManager.js';
|
||||||
import type { TaskExecutionOptions } from '../execute/types.js';
|
import type { TaskExecutionOptions } from '../execute/types.js';
|
||||||
import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Watch for tasks and execute them as they appear.
|
* Watch for tasks and execute them as they appear.
|
||||||
* Runs until Ctrl+C.
|
* Runs until Ctrl+C.
|
||||||
*/
|
*/
|
||||||
export async function watchTasks(cwd: string, options?: TaskExecutionOptions): Promise<void> {
|
export async function watchTasks(cwd: string, options?: TaskExecutionOptions): Promise<void> {
|
||||||
const pieceName = resolveConfigValue(cwd, 'piece') ?? DEFAULT_PIECE_NAME;
|
|
||||||
const taskRunner = new TaskRunner(cwd);
|
const taskRunner = new TaskRunner(cwd);
|
||||||
const watcher = new TaskWatcher(cwd);
|
const watcher = new TaskWatcher(cwd);
|
||||||
const recovered = taskRunner.recoverInterruptedRunningTasks();
|
const recovered = taskRunner.recoverInterruptedRunningTasks();
|
||||||
@ -35,7 +32,6 @@ export async function watchTasks(cwd: string, options?: TaskExecutionOptions): P
|
|||||||
let failCount = 0;
|
let failCount = 0;
|
||||||
|
|
||||||
header('TAKT Watch Mode');
|
header('TAKT Watch Mode');
|
||||||
info(`Piece: ${pieceName}`);
|
|
||||||
info(`Watching: ${taskRunner.getTasksFilePath()}`);
|
info(`Watching: ${taskRunner.getTasksFilePath()}`);
|
||||||
if (recovered > 0) {
|
if (recovered > 0) {
|
||||||
info(`Recovered ${recovered} interrupted running task(s) to pending.`);
|
info(`Recovered ${recovered} interrupted running task(s) to pending.`);
|
||||||
@ -65,7 +61,7 @@ export async function watchTasks(cwd: string, options?: TaskExecutionOptions): P
|
|||||||
info(`=== Task ${taskCount}: ${task.name} ===`);
|
info(`=== Task ${taskCount}: ${task.name} ===`);
|
||||||
blankLine();
|
blankLine();
|
||||||
|
|
||||||
const taskSuccess = await executeAndCompleteTask(task, taskRunner, cwd, pieceName, options);
|
const taskSuccess = await executeAndCompleteTask(task, taskRunner, cwd, options);
|
||||||
|
|
||||||
if (taskSuccess) {
|
if (taskSuccess) {
|
||||||
successCount++;
|
successCount++;
|
||||||
|
|||||||
@ -32,7 +32,6 @@ export type { PieceSource, PieceWithSource, PieceDirEntry } from './infra/config
|
|||||||
export {
|
export {
|
||||||
saveProjectConfig,
|
saveProjectConfig,
|
||||||
updateProjectConfig,
|
updateProjectConfig,
|
||||||
setCurrentPiece,
|
|
||||||
isVerboseMode,
|
isVerboseMode,
|
||||||
type ProjectLocalConfig,
|
type ProjectLocalConfig,
|
||||||
} from './infra/config/project/index.js';
|
} from './infra/config/project/index.js';
|
||||||
|
|||||||
1
src/infra/config/env/config-env-overrides.ts
vendored
1
src/infra/config/env/config-env-overrides.ts
vendored
@ -126,7 +126,6 @@ const GLOBAL_ENV_SPECS: readonly EnvSpec[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const PROJECT_ENV_SPECS: readonly EnvSpec[] = [
|
const PROJECT_ENV_SPECS: readonly EnvSpec[] = [
|
||||||
{ path: 'piece', type: 'string' },
|
|
||||||
{ path: 'log_level', type: 'string' },
|
{ path: 'log_level', type: 'string' },
|
||||||
{ path: 'provider', type: 'string' },
|
{ path: 'provider', type: 'string' },
|
||||||
{ path: 'model', type: 'string' },
|
{ path: 'model', type: 'string' },
|
||||||
|
|||||||
@ -79,7 +79,6 @@ export class GlobalConfigManager {
|
|||||||
language: parsed.language,
|
language: parsed.language,
|
||||||
provider: normalizedProvider.provider,
|
provider: normalizedProvider.provider,
|
||||||
model: normalizedProvider.model,
|
model: normalizedProvider.model,
|
||||||
piece: parsed.piece,
|
|
||||||
observability: parsed.observability ? {
|
observability: parsed.observability ? {
|
||||||
providerEvents: parsed.observability.provider_events,
|
providerEvents: parsed.observability.provider_events,
|
||||||
} : undefined,
|
} : undefined,
|
||||||
@ -149,9 +148,6 @@ export class GlobalConfigManager {
|
|||||||
if (config.model) {
|
if (config.model) {
|
||||||
raw.model = config.model;
|
raw.model = config.model;
|
||||||
}
|
}
|
||||||
if (config.piece) {
|
|
||||||
raw.piece = config.piece;
|
|
||||||
}
|
|
||||||
if (config.observability && config.observability.providerEvents !== undefined) {
|
if (config.observability && config.observability.providerEvents !== undefined) {
|
||||||
raw.observability = {
|
raw.observability = {
|
||||||
provider_events: config.observability.providerEvents,
|
provider_events: config.observability.providerEvents,
|
||||||
|
|||||||
@ -145,7 +145,6 @@ export {
|
|||||||
loadProjectConfig,
|
loadProjectConfig,
|
||||||
saveProjectConfig,
|
saveProjectConfig,
|
||||||
updateProjectConfig,
|
updateProjectConfig,
|
||||||
setCurrentPiece,
|
|
||||||
type ProjectLocalConfig,
|
type ProjectLocalConfig,
|
||||||
} from './project/projectConfig.js';
|
} from './project/projectConfig.js';
|
||||||
export {
|
export {
|
||||||
|
|||||||
@ -6,7 +6,6 @@ export {
|
|||||||
loadProjectConfig,
|
loadProjectConfig,
|
||||||
saveProjectConfig,
|
saveProjectConfig,
|
||||||
updateProjectConfig,
|
updateProjectConfig,
|
||||||
setCurrentPiece,
|
|
||||||
type ProjectLocalConfig,
|
type ProjectLocalConfig,
|
||||||
} from './projectConfig.js';
|
} from './projectConfig.js';
|
||||||
export {
|
export {
|
||||||
|
|||||||
@ -283,7 +283,3 @@ export function updateProjectConfig<K extends keyof ProjectLocalConfig>(
|
|||||||
config[key] = value;
|
config[key] = value;
|
||||||
saveProjectConfig(projectDir, config);
|
saveProjectConfig(projectDir, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setCurrentPiece(projectDir: string, piece: string): void {
|
|
||||||
updateProjectConfig(projectDir, 'piece', piece);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -75,7 +75,6 @@ const MIGRATED_PROJECT_LOCAL_CONFIG_KEY_SET = new Set(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const RESOLUTION_REGISTRY: Partial<{ [K in ConfigParameterKey]: ResolutionRule<K> }> = {
|
const RESOLUTION_REGISTRY: Partial<{ [K in ConfigParameterKey]: ResolutionRule<K> }> = {
|
||||||
piece: { layers: ['local', 'global'] },
|
|
||||||
provider: {
|
provider: {
|
||||||
layers: ['local', 'piece', 'global'],
|
layers: ['local', 'piece', 'global'],
|
||||||
pieceValue: (pieceContext) => pieceContext?.provider,
|
pieceValue: (pieceContext) => pieceContext?.provider,
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import type { MigratedProjectLocalConfigKey } from './migratedProjectLocalKeys.j
|
|||||||
export interface LoadedConfig
|
export interface LoadedConfig
|
||||||
extends PersistedGlobalConfig,
|
extends PersistedGlobalConfig,
|
||||||
Pick<ProjectLocalConfig, MigratedProjectLocalConfigKey> {
|
Pick<ProjectLocalConfig, MigratedProjectLocalConfigKey> {
|
||||||
piece?: string;
|
|
||||||
logLevel: NonNullable<ProjectLocalConfig['logLevel']>;
|
logLevel: NonNullable<ProjectLocalConfig['logLevel']>;
|
||||||
minimalOutput: NonNullable<ProjectLocalConfig['minimalOutput']>;
|
minimalOutput: NonNullable<ProjectLocalConfig['minimalOutput']>;
|
||||||
verbose: NonNullable<ProjectLocalConfig['verbose']>;
|
verbose: NonNullable<ProjectLocalConfig['verbose']>;
|
||||||
|
|||||||
@ -14,8 +14,6 @@ import type {
|
|||||||
|
|
||||||
/** Project configuration stored in .takt/config.yaml */
|
/** Project configuration stored in .takt/config.yaml */
|
||||||
export interface ProjectLocalConfig {
|
export interface ProjectLocalConfig {
|
||||||
/** Current piece name */
|
|
||||||
piece?: string;
|
|
||||||
/** Provider selection for agent runtime */
|
/** Provider selection for agent runtime */
|
||||||
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
|
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
|
||||||
/** Model selection for agent runtime */
|
/** Model selection for agent runtime */
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user