takt: github-issue-198-e2e-config-yaml (#208)
This commit is contained in:
parent
194610018a
commit
6e67f864f5
@ -565,6 +565,12 @@ model: sonnet # Default model (optional)
|
||||
branch_name_strategy: romaji # Branch name generation: 'romaji' (fast) or 'ai' (slow)
|
||||
prevent_sleep: false # Prevent macOS idle sleep during execution (caffeinate)
|
||||
notification_sound: true # Enable/disable notification sounds
|
||||
notification_sound_events: # Optional per-event toggles
|
||||
iteration_limit: false
|
||||
piece_complete: true
|
||||
piece_abort: true
|
||||
run_complete: true # Enabled by default; set false to disable
|
||||
run_abort: true # Enabled by default; set false to disable
|
||||
concurrency: 1 # Parallel task count for takt run (1-10, default: 1 = sequential)
|
||||
task_poll_interval_ms: 500 # Polling interval for new tasks during takt run (100-5000, default: 500)
|
||||
interactive_preview_movements: 3 # Movement previews in interactive mode (0-10, default: 3)
|
||||
|
||||
@ -565,6 +565,12 @@ model: sonnet # デフォルトモデル(オプション)
|
||||
branch_name_strategy: romaji # ブランチ名生成: 'romaji'(高速)または 'ai'(低速)
|
||||
prevent_sleep: false # macOS の実行中スリープ防止(caffeinate)
|
||||
notification_sound: true # 通知音の有効/無効
|
||||
notification_sound_events: # タイミング別の通知音制御
|
||||
iteration_limit: false
|
||||
piece_complete: true
|
||||
piece_abort: true
|
||||
run_complete: true # 未設定時は有効。false を指定すると無効
|
||||
run_abort: true # 未設定時は有効。false を指定すると無効
|
||||
concurrency: 1 # takt run の並列タスク数(1-10、デフォルト: 1 = 逐次実行)
|
||||
task_poll_interval_ms: 500 # takt run 中の新タスク検出ポーリング間隔(100-5000、デフォルト: 500)
|
||||
interactive_preview_movements: 3 # 対話モードでのムーブメントプレビュー数(0-10、デフォルト: 3)
|
||||
|
||||
@ -13,6 +13,13 @@ E2Eテストを追加・変更した場合は、このドキュメントも更
|
||||
- リポジトリクローン: `$(os.tmpdir())/takt-e2e-repo-<random>/`
|
||||
- 実行環境: `$(os.tmpdir())/takt-e2e-<runId>-<random>/`
|
||||
|
||||
## E2E用config.yaml
|
||||
- E2Eのグローバル設定は `e2e/fixtures/config.e2e.yaml` を基準に生成する。
|
||||
- `createIsolatedEnv()` は毎回一時ディレクトリ配下(`$TAKT_CONFIG_DIR/config.yaml`)にこの基準設定を書き出す。
|
||||
- 通知音は `notification_sound_events` でタイミング別に制御し、E2E既定では道中(`iteration_limit` / `piece_complete` / `piece_abort`)をOFF、全体終了時(`run_complete` / `run_abort`)のみONにする。
|
||||
- 各スペックで `provider` や `concurrency` を変更する場合は、`updateIsolatedConfig()` を使って差分のみ上書きする。
|
||||
- `~/.takt/config.yaml` はE2Eでは参照されないため、通常実行の設定には影響しない。
|
||||
|
||||
## 実行コマンド
|
||||
- `npm run test:e2e`: E2E全体を実行。
|
||||
- `npm run test:e2e:mock`: mock固定のE2Eのみ実行。
|
||||
|
||||
11
e2e/fixtures/config.e2e.yaml
Normal file
11
e2e/fixtures/config.e2e.yaml
Normal file
@ -0,0 +1,11 @@
|
||||
provider: claude
|
||||
language: en
|
||||
log_level: info
|
||||
default_piece: default
|
||||
notification_sound: true
|
||||
notification_sound_events:
|
||||
iteration_limit: false
|
||||
piece_complete: false
|
||||
piece_abort: false
|
||||
run_complete: true
|
||||
run_abort: true
|
||||
@ -1,6 +1,8 @@
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
||||
|
||||
export interface IsolatedEnv {
|
||||
runId: string;
|
||||
@ -9,6 +11,73 @@ export interface IsolatedEnv {
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
type E2EConfig = Record<string, unknown>;
|
||||
type NotificationSoundEvents = Record<string, unknown>;
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const E2E_CONFIG_FIXTURE_PATH = resolve(__dirname, '../fixtures/config.e2e.yaml');
|
||||
|
||||
function readE2EFixtureConfig(): E2EConfig {
|
||||
const raw = readFileSync(E2E_CONFIG_FIXTURE_PATH, 'utf-8');
|
||||
const parsed = parseYaml(raw);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error(`Invalid E2E config fixture: ${E2E_CONFIG_FIXTURE_PATH}`);
|
||||
}
|
||||
return parsed as E2EConfig;
|
||||
}
|
||||
|
||||
function writeConfigFile(taktDir: string, config: E2EConfig): void {
|
||||
writeFileSync(join(taktDir, 'config.yaml'), `${stringifyYaml(config)}`);
|
||||
}
|
||||
|
||||
function parseNotificationSoundEvents(
|
||||
source: E2EConfig,
|
||||
sourceName: string,
|
||||
): NotificationSoundEvents | undefined {
|
||||
const value = source.notification_sound_events;
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(
|
||||
`Invalid notification_sound_events in ${sourceName}: expected object`,
|
||||
);
|
||||
}
|
||||
return value as NotificationSoundEvents;
|
||||
}
|
||||
|
||||
function mergeIsolatedConfig(
|
||||
fixture: E2EConfig,
|
||||
current: E2EConfig,
|
||||
patch: E2EConfig,
|
||||
): E2EConfig {
|
||||
const merged: E2EConfig = { ...fixture, ...current, ...patch };
|
||||
const fixtureEvents = parseNotificationSoundEvents(fixture, 'fixture');
|
||||
const currentEvents = parseNotificationSoundEvents(current, 'current config');
|
||||
const patchEvents = parseNotificationSoundEvents(patch, 'patch');
|
||||
if (!fixtureEvents && !currentEvents && !patchEvents) {
|
||||
return merged;
|
||||
}
|
||||
merged.notification_sound_events = {
|
||||
...(fixtureEvents ?? {}),
|
||||
...(currentEvents ?? {}),
|
||||
...(patchEvents ?? {}),
|
||||
};
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function updateIsolatedConfig(taktDir: string, patch: E2EConfig): void {
|
||||
const current = readE2EFixtureConfig();
|
||||
const configPath = join(taktDir, 'config.yaml');
|
||||
const raw = readFileSync(configPath, 'utf-8');
|
||||
const parsed = parseYaml(raw);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error(`Invalid isolated config: ${configPath}`);
|
||||
}
|
||||
writeConfigFile(taktDir, mergeIsolatedConfig(current, parsed as E2EConfig, patch));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an isolated environment for E2E testing.
|
||||
*
|
||||
@ -24,18 +93,12 @@ export function createIsolatedEnv(): IsolatedEnv {
|
||||
const gitConfigPath = join(baseDir, '.gitconfig');
|
||||
|
||||
// Create TAKT config directory and config.yaml
|
||||
// Use TAKT_E2E_PROVIDER to match config provider with the actual provider being tested
|
||||
const configProvider = process.env.TAKT_E2E_PROVIDER ?? 'claude';
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(taktDir, 'config.yaml'),
|
||||
[
|
||||
`provider: ${configProvider}`,
|
||||
'language: en',
|
||||
'log_level: info',
|
||||
'default_piece: default',
|
||||
].join('\n'),
|
||||
);
|
||||
const baseConfig = readE2EFixtureConfig();
|
||||
const config = process.env.TAKT_E2E_PROVIDER
|
||||
? { ...baseConfig, provider: process.env.TAKT_E2E_PROVIDER }
|
||||
: baseConfig;
|
||||
writeConfigFile(taktDir, config);
|
||||
|
||||
// Create isolated Git config file
|
||||
writeFileSync(
|
||||
@ -58,11 +121,7 @@ export function createIsolatedEnv(): IsolatedEnv {
|
||||
taktDir,
|
||||
env,
|
||||
cleanup: () => {
|
||||
try {
|
||||
rmSync(baseDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Best-effort cleanup; ignore errors (e.g., already deleted)
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join, dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
|
||||
import {
|
||||
createIsolatedEnv,
|
||||
updateIsolatedConfig,
|
||||
type IsolatedEnv,
|
||||
} from '../helpers/isolated-env';
|
||||
import { createTestRepo, type TestRepo } from '../helpers/test-repo';
|
||||
import { runTakt } from '../helpers/takt-runner';
|
||||
|
||||
@ -22,16 +26,10 @@ describe('E2E: Add task from GitHub issue (takt add)', () => {
|
||||
testRepo = createTestRepo();
|
||||
|
||||
// Use mock provider to stabilize summarizer
|
||||
writeFileSync(
|
||||
join(isolatedEnv.taktDir, 'config.yaml'),
|
||||
[
|
||||
'provider: mock',
|
||||
'model: mock-model',
|
||||
'language: en',
|
||||
'log_level: info',
|
||||
'default_piece: default',
|
||||
].join('\n'),
|
||||
);
|
||||
updateIsolatedConfig(isolatedEnv.taktDir, {
|
||||
provider: 'mock',
|
||||
model: 'mock-model',
|
||||
});
|
||||
|
||||
const createOutput = execFileSync(
|
||||
'gh',
|
||||
|
||||
@ -5,7 +5,11 @@ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
|
||||
import {
|
||||
createIsolatedEnv,
|
||||
updateIsolatedConfig,
|
||||
type IsolatedEnv,
|
||||
} from '../helpers/isolated-env';
|
||||
import { runTakt } from '../helpers/takt-runner';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@ -44,15 +48,9 @@ describe('E2E: Provider error handling (mock)', () => {
|
||||
|
||||
it('should override config provider with --provider flag', () => {
|
||||
// Given: config.yaml has provider: claude, but CLI flag specifies mock
|
||||
writeFileSync(
|
||||
join(isolatedEnv.taktDir, 'config.yaml'),
|
||||
[
|
||||
'provider: claude',
|
||||
'language: en',
|
||||
'log_level: info',
|
||||
'default_piece: default',
|
||||
].join('\n'),
|
||||
);
|
||||
updateIsolatedConfig(isolatedEnv.taktDir, {
|
||||
provider: 'claude',
|
||||
});
|
||||
|
||||
const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml');
|
||||
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json');
|
||||
|
||||
@ -5,7 +5,11 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
|
||||
import {
|
||||
createIsolatedEnv,
|
||||
updateIsolatedConfig,
|
||||
type IsolatedEnv,
|
||||
} from '../helpers/isolated-env';
|
||||
import { runTakt } from '../helpers/takt-runner';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@ -39,15 +43,9 @@ describe('E2E: Run multiple tasks (takt run)', () => {
|
||||
repo = createLocalRepo();
|
||||
|
||||
// Override config to use mock provider
|
||||
writeFileSync(
|
||||
join(isolatedEnv.taktDir, 'config.yaml'),
|
||||
[
|
||||
'provider: mock',
|
||||
'language: en',
|
||||
'log_level: info',
|
||||
'default_piece: default',
|
||||
].join('\n'),
|
||||
);
|
||||
updateIsolatedConfig(isolatedEnv.taktDir, {
|
||||
provider: 'mock',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@ -3,7 +3,11 @@ import { spawn } from 'node:child_process';
|
||||
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
import { join, resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
|
||||
import {
|
||||
createIsolatedEnv,
|
||||
updateIsolatedConfig,
|
||||
type IsolatedEnv,
|
||||
} from '../helpers/isolated-env';
|
||||
import { createTestRepo, type TestRepo } from '../helpers/test-repo';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@ -50,18 +54,12 @@ describe('E2E: Run tasks graceful shutdown on SIGINT (parallel)', () => {
|
||||
isolatedEnv = createIsolatedEnv();
|
||||
testRepo = createTestRepo();
|
||||
|
||||
writeFileSync(
|
||||
join(isolatedEnv.taktDir, 'config.yaml'),
|
||||
[
|
||||
'provider: mock',
|
||||
'model: mock-model',
|
||||
'language: en',
|
||||
'log_level: info',
|
||||
'default_piece: default',
|
||||
'concurrency: 2',
|
||||
'task_poll_interval_ms: 100',
|
||||
].join('\n'),
|
||||
);
|
||||
updateIsolatedConfig(isolatedEnv.taktDir, {
|
||||
provider: 'mock',
|
||||
model: 'mock-model',
|
||||
concurrency: 2,
|
||||
task_poll_interval_ms: 100,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@ -5,7 +5,11 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
|
||||
import {
|
||||
createIsolatedEnv,
|
||||
updateIsolatedConfig,
|
||||
type IsolatedEnv,
|
||||
} from '../helpers/isolated-env';
|
||||
import { runTakt } from '../helpers/takt-runner';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@ -38,15 +42,9 @@ describe('E2E: Task content_file reference (mock)', () => {
|
||||
isolatedEnv = createIsolatedEnv();
|
||||
repo = createLocalRepo();
|
||||
|
||||
writeFileSync(
|
||||
join(isolatedEnv.taktDir, 'config.yaml'),
|
||||
[
|
||||
'provider: mock',
|
||||
'language: en',
|
||||
'log_level: info',
|
||||
'default_piece: default',
|
||||
].join('\n'),
|
||||
);
|
||||
updateIsolatedConfig(isolatedEnv.taktDir, {
|
||||
provider: 'mock',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import { injectProviderArgs } from '../../e2e/helpers/takt-runner.js';
|
||||
import { createIsolatedEnv } from '../../e2e/helpers/isolated-env.js';
|
||||
import {
|
||||
createIsolatedEnv,
|
||||
updateIsolatedConfig,
|
||||
} from '../../e2e/helpers/isolated-env.js';
|
||||
|
||||
describe('injectProviderArgs', () => {
|
||||
it('should prepend --provider when provider is specified', () => {
|
||||
@ -70,4 +75,112 @@ describe('createIsolatedEnv', () => {
|
||||
expect(isolated.env.GIT_CONFIG_GLOBAL).toBeDefined();
|
||||
expect(isolated.env.GIT_CONFIG_GLOBAL).toContain('takt-e2e-');
|
||||
});
|
||||
|
||||
it('should create config.yaml from E2E fixture with notification_sound timing controls', () => {
|
||||
const isolated = createIsolatedEnv();
|
||||
cleanups.push(isolated.cleanup);
|
||||
|
||||
const configRaw = readFileSync(`${isolated.taktDir}/config.yaml`, 'utf-8');
|
||||
const config = parseYaml(configRaw) as Record<string, unknown>;
|
||||
|
||||
expect(config.language).toBe('en');
|
||||
expect(config.log_level).toBe('info');
|
||||
expect(config.default_piece).toBe('default');
|
||||
expect(config.notification_sound).toBe(true);
|
||||
expect(config.notification_sound_events).toEqual({
|
||||
iteration_limit: false,
|
||||
piece_complete: false,
|
||||
piece_abort: false,
|
||||
run_complete: true,
|
||||
run_abort: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should override provider in config.yaml when TAKT_E2E_PROVIDER is set', () => {
|
||||
process.env = { ...originalEnv, TAKT_E2E_PROVIDER: 'mock' };
|
||||
const isolated = createIsolatedEnv();
|
||||
cleanups.push(isolated.cleanup);
|
||||
|
||||
const configRaw = readFileSync(`${isolated.taktDir}/config.yaml`, 'utf-8');
|
||||
const config = parseYaml(configRaw) as Record<string, unknown>;
|
||||
expect(config.provider).toBe('mock');
|
||||
});
|
||||
|
||||
it('should preserve base settings when updateIsolatedConfig applies patch', () => {
|
||||
const isolated = createIsolatedEnv();
|
||||
cleanups.push(isolated.cleanup);
|
||||
|
||||
updateIsolatedConfig(isolated.taktDir, {
|
||||
provider: 'mock',
|
||||
concurrency: 2,
|
||||
});
|
||||
|
||||
const configRaw = readFileSync(`${isolated.taktDir}/config.yaml`, 'utf-8');
|
||||
const config = parseYaml(configRaw) as Record<string, unknown>;
|
||||
|
||||
expect(config.provider).toBe('mock');
|
||||
expect(config.concurrency).toBe(2);
|
||||
expect(config.notification_sound).toBe(true);
|
||||
expect(config.notification_sound_events).toEqual({
|
||||
iteration_limit: false,
|
||||
piece_complete: false,
|
||||
piece_abort: false,
|
||||
run_complete: true,
|
||||
run_abort: true,
|
||||
});
|
||||
expect(config.language).toBe('en');
|
||||
});
|
||||
|
||||
it('should deep-merge notification_sound_events patch and preserve unspecified keys', () => {
|
||||
const isolated = createIsolatedEnv();
|
||||
cleanups.push(isolated.cleanup);
|
||||
|
||||
updateIsolatedConfig(isolated.taktDir, {
|
||||
notification_sound_events: {
|
||||
run_complete: false,
|
||||
},
|
||||
});
|
||||
|
||||
const configRaw = readFileSync(`${isolated.taktDir}/config.yaml`, 'utf-8');
|
||||
const config = parseYaml(configRaw) as Record<string, unknown>;
|
||||
|
||||
expect(config.notification_sound_events).toEqual({
|
||||
iteration_limit: false,
|
||||
piece_complete: false,
|
||||
piece_abort: false,
|
||||
run_complete: false,
|
||||
run_abort: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw when patch.notification_sound_events is not an object', () => {
|
||||
const isolated = createIsolatedEnv();
|
||||
cleanups.push(isolated.cleanup);
|
||||
|
||||
expect(() => {
|
||||
updateIsolatedConfig(isolated.taktDir, {
|
||||
notification_sound_events: true,
|
||||
});
|
||||
}).toThrow('Invalid notification_sound_events in patch: expected object');
|
||||
});
|
||||
|
||||
it('should throw when current config notification_sound_events is invalid', () => {
|
||||
const isolated = createIsolatedEnv();
|
||||
cleanups.push(isolated.cleanup);
|
||||
|
||||
writeFileSync(
|
||||
`${isolated.taktDir}/config.yaml`,
|
||||
[
|
||||
'language: en',
|
||||
'log_level: info',
|
||||
'default_piece: default',
|
||||
'notification_sound: true',
|
||||
'notification_sound_events: true',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
updateIsolatedConfig(isolated.taktDir, { provider: 'mock' });
|
||||
}).toThrow('Invalid notification_sound_events in current config: expected object');
|
||||
});
|
||||
});
|
||||
|
||||
@ -287,6 +287,59 @@ describe('loadGlobalConfig', () => {
|
||||
expect(config.notificationSound).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should load notification_sound_events config from config.yaml', () => {
|
||||
const taktDir = join(testHomeDir, '.takt');
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
writeFileSync(
|
||||
getGlobalConfigPath(),
|
||||
[
|
||||
'language: en',
|
||||
'notification_sound_events:',
|
||||
' iteration_limit: false',
|
||||
' piece_complete: true',
|
||||
' piece_abort: true',
|
||||
' run_complete: true',
|
||||
' run_abort: false',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const config = loadGlobalConfig();
|
||||
expect(config.notificationSoundEvents).toEqual({
|
||||
iterationLimit: false,
|
||||
pieceComplete: true,
|
||||
pieceAbort: true,
|
||||
runComplete: true,
|
||||
runAbort: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should save and reload notification_sound_events config', () => {
|
||||
const taktDir = join(testHomeDir, '.takt');
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
|
||||
|
||||
const config = loadGlobalConfig();
|
||||
config.notificationSoundEvents = {
|
||||
iterationLimit: false,
|
||||
pieceComplete: true,
|
||||
pieceAbort: false,
|
||||
runComplete: true,
|
||||
runAbort: true,
|
||||
};
|
||||
saveGlobalConfig(config);
|
||||
invalidateGlobalConfigCache();
|
||||
|
||||
const reloaded = loadGlobalConfig();
|
||||
expect(reloaded.notificationSoundEvents).toEqual({
|
||||
iterationLimit: false,
|
||||
pieceComplete: true,
|
||||
pieceAbort: false,
|
||||
runComplete: true,
|
||||
runAbort: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should load interactive_preview_movements config from config.yaml', () => {
|
||||
const taktDir = join(testHomeDir, '.takt');
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
|
||||
@ -282,6 +282,22 @@ describe('executePiece: notification sound behavior', () => {
|
||||
|
||||
expect(mockNotifySuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should NOT call notifySuccess when piece_complete event is disabled', async () => {
|
||||
mockLoadGlobalConfig.mockReturnValue({
|
||||
provider: 'claude',
|
||||
notificationSound: true,
|
||||
notificationSoundEvents: { pieceComplete: false },
|
||||
});
|
||||
|
||||
const resultPromise = executePiece(makeConfig(), 'test task', tmpDir, { projectCwd: tmpDir });
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
MockPieceEngine.latestInstance!.complete();
|
||||
await resultPromise;
|
||||
|
||||
expect(mockNotifySuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('notifyError on piece:abort', () => {
|
||||
@ -320,6 +336,22 @@ describe('executePiece: notification sound behavior', () => {
|
||||
|
||||
expect(mockNotifyError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should NOT call notifyError when piece_abort event is disabled', async () => {
|
||||
mockLoadGlobalConfig.mockReturnValue({
|
||||
provider: 'claude',
|
||||
notificationSound: true,
|
||||
notificationSoundEvents: { pieceAbort: false },
|
||||
});
|
||||
|
||||
const resultPromise = executePiece(makeConfig(), 'test task', tmpDir, { projectCwd: tmpDir });
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
MockPieceEngine.latestInstance!.abort();
|
||||
await resultPromise;
|
||||
|
||||
expect(mockNotifyError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('playWarningSound on iteration limit', () => {
|
||||
@ -361,5 +393,22 @@ describe('executePiece: notification sound behavior', () => {
|
||||
|
||||
expect(mockPlayWarningSound).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should NOT call playWarningSound when iteration_limit event is disabled', async () => {
|
||||
mockLoadGlobalConfig.mockReturnValue({
|
||||
provider: 'claude',
|
||||
notificationSound: true,
|
||||
notificationSoundEvents: { iterationLimit: false },
|
||||
});
|
||||
|
||||
const resultPromise = executePiece(makeConfig(), 'test task', tmpDir, { projectCwd: tmpDir });
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
await MockPieceEngine.latestInstance!.triggerIterationLimit();
|
||||
MockPieceEngine.latestInstance!.abort();
|
||||
await resultPromise;
|
||||
|
||||
expect(mockPlayWarningSound).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -21,10 +21,21 @@ vi.mock('../infra/config/index.js', () => ({
|
||||
import { loadGlobalConfig } from '../infra/config/index.js';
|
||||
const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig);
|
||||
|
||||
const mockClaimNextTasks = vi.fn();
|
||||
const mockCompleteTask = vi.fn();
|
||||
const mockFailTask = vi.fn();
|
||||
const mockRecoverInterruptedRunningTasks = vi.fn();
|
||||
const {
|
||||
mockClaimNextTasks,
|
||||
mockCompleteTask,
|
||||
mockFailTask,
|
||||
mockRecoverInterruptedRunningTasks,
|
||||
mockNotifySuccess,
|
||||
mockNotifyError,
|
||||
} = vi.hoisted(() => ({
|
||||
mockClaimNextTasks: vi.fn(),
|
||||
mockCompleteTask: vi.fn(),
|
||||
mockFailTask: vi.fn(),
|
||||
mockRecoverInterruptedRunningTasks: vi.fn(),
|
||||
mockNotifySuccess: vi.fn(),
|
||||
mockNotifyError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../infra/task/index.js', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
@ -75,6 +86,8 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
||||
error: vi.fn(),
|
||||
}),
|
||||
getErrorMessage: vi.fn((e) => e.message),
|
||||
notifySuccess: mockNotifySuccess,
|
||||
notifyError: mockNotifyError,
|
||||
}));
|
||||
|
||||
vi.mock('../features/tasks/execute/pieceExecution.js', () => ({
|
||||
@ -149,6 +162,8 @@ describe('runAllTasks concurrency', () => {
|
||||
language: 'en',
|
||||
defaultPiece: 'default',
|
||||
logLevel: 'info',
|
||||
notificationSound: true,
|
||||
notificationSoundEvents: { runComplete: true, runAbort: true },
|
||||
concurrency: 1,
|
||||
taskPollIntervalMs: 500,
|
||||
});
|
||||
@ -190,6 +205,8 @@ describe('runAllTasks concurrency', () => {
|
||||
language: 'en',
|
||||
defaultPiece: 'default',
|
||||
logLevel: 'info',
|
||||
notificationSound: true,
|
||||
notificationSoundEvents: { runComplete: true, runAbort: true },
|
||||
concurrency: 3,
|
||||
taskPollIntervalMs: 500,
|
||||
});
|
||||
@ -266,6 +283,7 @@ describe('runAllTasks concurrency', () => {
|
||||
language: 'en',
|
||||
defaultPiece: 'default',
|
||||
logLevel: 'info',
|
||||
notificationSound: false,
|
||||
concurrency: 1,
|
||||
taskPollIntervalMs: 500,
|
||||
});
|
||||
@ -283,6 +301,8 @@ describe('runAllTasks concurrency', () => {
|
||||
(call) => typeof call[0] === 'string' && call[0].startsWith('Concurrency:')
|
||||
);
|
||||
expect(concurrencyInfoCalls).toHaveLength(0);
|
||||
expect(mockNotifySuccess).not.toHaveBeenCalled();
|
||||
expect(mockNotifyError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -384,6 +404,16 @@ describe('runAllTasks concurrency', () => {
|
||||
|
||||
it('should count partial failures correctly', async () => {
|
||||
// Given: 3 tasks, 1 fails, 2 succeed
|
||||
mockLoadGlobalConfig.mockReturnValue({
|
||||
language: 'en',
|
||||
defaultPiece: 'default',
|
||||
logLevel: 'info',
|
||||
notificationSound: true,
|
||||
notificationSoundEvents: { runAbort: true },
|
||||
concurrency: 3,
|
||||
taskPollIntervalMs: 500,
|
||||
});
|
||||
|
||||
const task1 = createTask('pass-1');
|
||||
const task2 = createTask('fail-1');
|
||||
const task3 = createTask('pass-2');
|
||||
@ -406,6 +436,8 @@ describe('runAllTasks concurrency', () => {
|
||||
expect(mockStatus).toHaveBeenCalledWith('Total', '3');
|
||||
expect(mockStatus).toHaveBeenCalledWith('Success', '2', undefined);
|
||||
expect(mockStatus).toHaveBeenCalledWith('Failed', '1', 'red');
|
||||
expect(mockNotifySuccess).not.toHaveBeenCalled();
|
||||
expect(mockNotifyError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should persist failure reason and movement when piece aborts', async () => {
|
||||
@ -458,6 +490,8 @@ describe('runAllTasks concurrency', () => {
|
||||
language: 'en',
|
||||
defaultPiece: 'default',
|
||||
logLevel: 'info',
|
||||
notificationSound: true,
|
||||
notificationSoundEvents: { runComplete: true, runAbort: true },
|
||||
concurrency: 1,
|
||||
taskPollIntervalMs: 500,
|
||||
});
|
||||
@ -480,5 +514,145 @@ describe('runAllTasks concurrency', () => {
|
||||
expect(pieceOptions?.abortSignal).toBeInstanceOf(AbortSignal);
|
||||
expect(pieceOptions?.taskPrefix).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should only notify once at run completion when multiple tasks succeed', async () => {
|
||||
mockLoadGlobalConfig.mockReturnValue({
|
||||
language: 'en',
|
||||
defaultPiece: 'default',
|
||||
logLevel: 'info',
|
||||
notificationSound: true,
|
||||
notificationSoundEvents: { runComplete: true },
|
||||
concurrency: 3,
|
||||
taskPollIntervalMs: 500,
|
||||
});
|
||||
|
||||
const task1 = createTask('task-1');
|
||||
const task2 = createTask('task-2');
|
||||
const task3 = createTask('task-3');
|
||||
|
||||
mockClaimNextTasks
|
||||
.mockReturnValueOnce([task1, task2, task3])
|
||||
.mockReturnValueOnce([]);
|
||||
|
||||
await runAllTasks('/project');
|
||||
|
||||
expect(mockNotifySuccess).toHaveBeenCalledTimes(1);
|
||||
expect(mockNotifyError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not notify run completion when runComplete is explicitly false', async () => {
|
||||
mockLoadGlobalConfig.mockReturnValue({
|
||||
language: 'en',
|
||||
defaultPiece: 'default',
|
||||
logLevel: 'info',
|
||||
notificationSound: true,
|
||||
notificationSoundEvents: { runComplete: false },
|
||||
concurrency: 1,
|
||||
taskPollIntervalMs: 500,
|
||||
});
|
||||
|
||||
const task1 = createTask('task-1');
|
||||
mockClaimNextTasks
|
||||
.mockReturnValueOnce([task1])
|
||||
.mockReturnValueOnce([]);
|
||||
|
||||
await runAllTasks('/project');
|
||||
|
||||
expect(mockNotifySuccess).not.toHaveBeenCalled();
|
||||
expect(mockNotifyError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should notify run completion by default when notification_sound_events is not set', async () => {
|
||||
mockLoadGlobalConfig.mockReturnValue({
|
||||
language: 'en',
|
||||
defaultPiece: 'default',
|
||||
logLevel: 'info',
|
||||
notificationSound: true,
|
||||
concurrency: 1,
|
||||
taskPollIntervalMs: 500,
|
||||
});
|
||||
|
||||
const task1 = createTask('task-1');
|
||||
mockClaimNextTasks
|
||||
.mockReturnValueOnce([task1])
|
||||
.mockReturnValueOnce([]);
|
||||
|
||||
await runAllTasks('/project');
|
||||
|
||||
expect(mockNotifySuccess).toHaveBeenCalledTimes(1);
|
||||
expect(mockNotifySuccess).toHaveBeenCalledWith('TAKT', 'run.notifyComplete');
|
||||
expect(mockNotifyError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should notify run abort by default when notification_sound_events is not set', async () => {
|
||||
mockLoadGlobalConfig.mockReturnValue({
|
||||
language: 'en',
|
||||
defaultPiece: 'default',
|
||||
logLevel: 'info',
|
||||
notificationSound: true,
|
||||
concurrency: 1,
|
||||
taskPollIntervalMs: 500,
|
||||
});
|
||||
|
||||
const task1 = createTask('task-1');
|
||||
mockExecutePiece.mockResolvedValueOnce({ success: false, reason: 'failed' });
|
||||
mockClaimNextTasks
|
||||
.mockReturnValueOnce([task1])
|
||||
.mockReturnValueOnce([]);
|
||||
|
||||
await runAllTasks('/project');
|
||||
|
||||
expect(mockNotifySuccess).not.toHaveBeenCalled();
|
||||
expect(mockNotifyError).toHaveBeenCalledTimes(1);
|
||||
expect(mockNotifyError).toHaveBeenCalledWith('TAKT', 'run.notifyAbort');
|
||||
});
|
||||
|
||||
it('should not notify run abort when runAbort is explicitly false', async () => {
|
||||
mockLoadGlobalConfig.mockReturnValue({
|
||||
language: 'en',
|
||||
defaultPiece: 'default',
|
||||
logLevel: 'info',
|
||||
notificationSound: true,
|
||||
notificationSoundEvents: { runAbort: false },
|
||||
concurrency: 1,
|
||||
taskPollIntervalMs: 500,
|
||||
});
|
||||
|
||||
const task1 = createTask('task-1');
|
||||
mockExecutePiece.mockResolvedValueOnce({ success: false, reason: 'failed' });
|
||||
mockClaimNextTasks
|
||||
.mockReturnValueOnce([task1])
|
||||
.mockReturnValueOnce([]);
|
||||
|
||||
await runAllTasks('/project');
|
||||
|
||||
expect(mockNotifySuccess).not.toHaveBeenCalled();
|
||||
expect(mockNotifyError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should notify run abort and rethrow when worker pool throws', async () => {
|
||||
mockLoadGlobalConfig.mockReturnValue({
|
||||
language: 'en',
|
||||
defaultPiece: 'default',
|
||||
logLevel: 'info',
|
||||
notificationSound: true,
|
||||
notificationSoundEvents: { runAbort: true },
|
||||
concurrency: 1,
|
||||
taskPollIntervalMs: 500,
|
||||
});
|
||||
|
||||
const task1 = createTask('task-1');
|
||||
const poolError = new Error('worker pool crashed');
|
||||
|
||||
mockClaimNextTasks
|
||||
.mockReturnValueOnce([task1])
|
||||
.mockImplementationOnce(() => {
|
||||
throw poolError;
|
||||
});
|
||||
|
||||
await expect(runAllTasks('/project')).rejects.toThrow('worker pool crashed');
|
||||
expect(mockNotifyError).toHaveBeenCalledTimes(1);
|
||||
expect(mockNotifyError).toHaveBeenCalledWith('TAKT', 'run.notifyAbort');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -33,6 +33,20 @@ export interface PipelineConfig {
|
||||
prBodyTemplate?: string;
|
||||
}
|
||||
|
||||
/** Notification sound toggles per event timing */
|
||||
export interface NotificationSoundEventsConfig {
|
||||
/** Warning when iteration limit is reached */
|
||||
iterationLimit?: boolean;
|
||||
/** Success notification when piece execution completes */
|
||||
pieceComplete?: boolean;
|
||||
/** Error notification when piece execution aborts */
|
||||
pieceAbort?: boolean;
|
||||
/** Success notification when runAllTasks finishes without failures */
|
||||
runComplete?: boolean;
|
||||
/** Error notification when runAllTasks finishes with failures or aborts */
|
||||
runAbort?: boolean;
|
||||
}
|
||||
|
||||
/** Global configuration for takt */
|
||||
export interface GlobalConfig {
|
||||
language: Language;
|
||||
@ -69,6 +83,8 @@ export interface GlobalConfig {
|
||||
preventSleep?: boolean;
|
||||
/** Enable notification sounds (default: true when undefined) */
|
||||
notificationSound?: boolean;
|
||||
/** Notification sound toggles per event timing */
|
||||
notificationSoundEvents?: NotificationSoundEventsConfig;
|
||||
/** Number of movement previews to inject into interactive mode (0 to disable, max 10) */
|
||||
interactivePreviewMovements?: number;
|
||||
/** Number of tasks to run concurrently in takt run (default: 1 = sequential) */
|
||||
|
||||
@ -369,6 +369,14 @@ export const GlobalConfigSchema = z.object({
|
||||
prevent_sleep: z.boolean().optional(),
|
||||
/** Enable notification sounds (default: true when undefined) */
|
||||
notification_sound: z.boolean().optional(),
|
||||
/** Notification sound toggles per event timing */
|
||||
notification_sound_events: z.object({
|
||||
iteration_limit: z.boolean().optional(),
|
||||
piece_complete: z.boolean().optional(),
|
||||
piece_abort: z.boolean().optional(),
|
||||
run_complete: z.boolean().optional(),
|
||||
run_abort: z.boolean().optional(),
|
||||
}).optional(),
|
||||
/** Number of movement previews to inject into interactive mode (0 to disable, max 10) */
|
||||
interactive_preview_movements: z.number().int().min(0).max(10).optional().default(3),
|
||||
/** Number of tasks to run concurrently in takt run (default: 1 = sequential, max: 10) */
|
||||
|
||||
@ -296,6 +296,10 @@ export async function executePiece(
|
||||
const isWorktree = cwd !== projectCwd;
|
||||
const globalConfig = loadGlobalConfig();
|
||||
const shouldNotify = globalConfig.notificationSound !== false;
|
||||
const notificationSoundEvents = globalConfig.notificationSoundEvents;
|
||||
const shouldNotifyIterationLimit = shouldNotify && notificationSoundEvents?.iterationLimit !== false;
|
||||
const shouldNotifyPieceComplete = shouldNotify && notificationSoundEvents?.pieceComplete !== false;
|
||||
const shouldNotifyPieceAbort = shouldNotify && notificationSoundEvents?.pieceAbort !== false;
|
||||
const currentProvider = globalConfig.provider ?? 'claude';
|
||||
|
||||
// Prevent macOS idle sleep if configured
|
||||
@ -333,7 +337,7 @@ export async function executePiece(
|
||||
);
|
||||
out.info(getLabel('piece.iterationLimit.currentMovement', undefined, { currentMovement: request.currentMovement }));
|
||||
|
||||
if (shouldNotify) {
|
||||
if (shouldNotifyIterationLimit) {
|
||||
playWarningSound();
|
||||
}
|
||||
|
||||
@ -613,7 +617,7 @@ export async function executePiece(
|
||||
|
||||
out.success(`Piece completed (${state.iteration} iterations${elapsedDisplay})`);
|
||||
out.info(`Session log: ${ndjsonLogPath}`);
|
||||
if (shouldNotify) {
|
||||
if (shouldNotifyPieceComplete) {
|
||||
notifySuccess('TAKT', getLabel('piece.notifyComplete', undefined, { iteration: String(state.iteration) }));
|
||||
}
|
||||
});
|
||||
@ -661,7 +665,7 @@ export async function executePiece(
|
||||
|
||||
out.error(`Piece aborted after ${state.iteration} iterations${elapsedDisplay}: ${reason}`);
|
||||
out.info(`Session log: ${ndjsonLogPath}`);
|
||||
if (shouldNotify) {
|
||||
if (shouldNotifyPieceAbort) {
|
||||
notifyError('TAKT', getLabel('piece.notifyAbort', undefined, { reason }));
|
||||
}
|
||||
});
|
||||
|
||||
@ -12,7 +12,8 @@ import {
|
||||
status,
|
||||
blankLine,
|
||||
} from '../../../shared/ui/index.js';
|
||||
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
|
||||
import { createLogger, getErrorMessage, notifyError, notifySuccess } from '../../../shared/utils/index.js';
|
||||
import { getLabel } from '../../../shared/i18n/index.js';
|
||||
import { executePiece } from './pieceExecution.js';
|
||||
import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js';
|
||||
import type { TaskExecutionOptions, ExecuteTaskOptions, PieceExecutionResult } from './types.js';
|
||||
@ -241,6 +242,10 @@ export async function runAllTasks(
|
||||
): Promise<void> {
|
||||
const taskRunner = new TaskRunner(cwd);
|
||||
const globalConfig = loadGlobalConfig();
|
||||
const shouldNotifyRunComplete = globalConfig.notificationSound !== false
|
||||
&& globalConfig.notificationSoundEvents?.runComplete !== false;
|
||||
const shouldNotifyRunAbort = globalConfig.notificationSound !== false
|
||||
&& globalConfig.notificationSoundEvents?.runAbort !== false;
|
||||
const concurrency = globalConfig.concurrency;
|
||||
const recovered = taskRunner.recoverInterruptedRunningTasks();
|
||||
if (recovered > 0) {
|
||||
@ -260,6 +265,7 @@ export async function runAllTasks(
|
||||
info(`Concurrency: ${concurrency}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runWithWorkerPool(taskRunner, initialTasks, concurrency, cwd, pieceName, options, globalConfig.taskPollIntervalMs);
|
||||
|
||||
const totalCount = result.success + result.fail;
|
||||
@ -269,6 +275,20 @@ export async function runAllTasks(
|
||||
status('Success', String(result.success), result.success === totalCount ? 'green' : undefined);
|
||||
if (result.fail > 0) {
|
||||
status('Failed', String(result.fail), 'red');
|
||||
if (shouldNotifyRunAbort) {
|
||||
notifyError('TAKT', getLabel('run.notifyAbort', undefined, { failed: String(result.fail) }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldNotifyRunComplete) {
|
||||
notifySuccess('TAKT', getLabel('run.notifyComplete', undefined, { total: String(totalCount) }));
|
||||
}
|
||||
} catch (e) {
|
||||
if (shouldNotifyRunAbort) {
|
||||
notifyError('TAKT', getLabel('run.notifyAbort', undefined, { failed: getErrorMessage(e) }));
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -110,6 +110,13 @@ export class GlobalConfigManager {
|
||||
branchNameStrategy: parsed.branch_name_strategy,
|
||||
preventSleep: parsed.prevent_sleep,
|
||||
notificationSound: parsed.notification_sound,
|
||||
notificationSoundEvents: parsed.notification_sound_events ? {
|
||||
iterationLimit: parsed.notification_sound_events.iteration_limit,
|
||||
pieceComplete: parsed.notification_sound_events.piece_complete,
|
||||
pieceAbort: parsed.notification_sound_events.piece_abort,
|
||||
runComplete: parsed.notification_sound_events.run_complete,
|
||||
runAbort: parsed.notification_sound_events.run_abort,
|
||||
} : undefined,
|
||||
interactivePreviewMovements: parsed.interactive_preview_movements,
|
||||
concurrency: parsed.concurrency,
|
||||
taskPollIntervalMs: parsed.task_poll_interval_ms,
|
||||
@ -185,6 +192,27 @@ export class GlobalConfigManager {
|
||||
if (config.notificationSound !== undefined) {
|
||||
raw.notification_sound = config.notificationSound;
|
||||
}
|
||||
if (config.notificationSoundEvents) {
|
||||
const eventRaw: Record<string, unknown> = {};
|
||||
if (config.notificationSoundEvents.iterationLimit !== undefined) {
|
||||
eventRaw.iteration_limit = config.notificationSoundEvents.iterationLimit;
|
||||
}
|
||||
if (config.notificationSoundEvents.pieceComplete !== undefined) {
|
||||
eventRaw.piece_complete = config.notificationSoundEvents.pieceComplete;
|
||||
}
|
||||
if (config.notificationSoundEvents.pieceAbort !== undefined) {
|
||||
eventRaw.piece_abort = config.notificationSoundEvents.pieceAbort;
|
||||
}
|
||||
if (config.notificationSoundEvents.runComplete !== undefined) {
|
||||
eventRaw.run_complete = config.notificationSoundEvents.runComplete;
|
||||
}
|
||||
if (config.notificationSoundEvents.runAbort !== undefined) {
|
||||
eventRaw.run_abort = config.notificationSoundEvents.runAbort;
|
||||
}
|
||||
if (Object.keys(eventRaw).length > 0) {
|
||||
raw.notification_sound_events = eventRaw;
|
||||
}
|
||||
}
|
||||
if (config.interactivePreviewMovements !== undefined) {
|
||||
raw.interactive_preview_movements = config.interactivePreviewMovements;
|
||||
}
|
||||
|
||||
@ -58,3 +58,7 @@ piece:
|
||||
notifyAbort: "Aborted: {reason}"
|
||||
sigintGraceful: "Ctrl+C: Aborting piece..."
|
||||
sigintForce: "Ctrl+C: Force exit"
|
||||
|
||||
run:
|
||||
notifyComplete: "Run complete ({total} tasks)"
|
||||
notifyAbort: "Run finished with errors ({failed})"
|
||||
|
||||
@ -58,3 +58,7 @@ piece:
|
||||
notifyAbort: "中断: {reason}"
|
||||
sigintGraceful: "Ctrl+C: ピースを中断しています..."
|
||||
sigintForce: "Ctrl+C: 強制終了します"
|
||||
|
||||
run:
|
||||
notifyComplete: "run完了 ({total} tasks)"
|
||||
notifyAbort: "runはエラー終了 ({failed})"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user