feat(runtime): add configurable prepare presets and provider e2e
This commit is contained in:
parent
18bad35489
commit
6e14cd3c38
@ -45,6 +45,12 @@ provider: claude
|
||||
# Prevent macOS idle sleep during execution using caffeinate (default: false)
|
||||
# prevent_sleep: false
|
||||
|
||||
# Runtime environment defaults (applies to all pieces unless piece_config.runtime overrides)
|
||||
# runtime:
|
||||
# prepare:
|
||||
# - gradle
|
||||
# - node
|
||||
|
||||
# ── Parallel Execution (takt run) ──
|
||||
|
||||
# Number of tasks to run concurrently (1 = sequential, max: 10)
|
||||
|
||||
@ -45,6 +45,12 @@ provider: claude
|
||||
# macOS のアイドルスリープを防止(デフォルト: false)
|
||||
# prevent_sleep: false
|
||||
|
||||
# 実行時ランタイム環境のデフォルト(piece_config.runtime があればそちらを優先)
|
||||
# runtime:
|
||||
# prepare:
|
||||
# - gradle
|
||||
# - node
|
||||
|
||||
# ── 並列実行 (takt run) ──
|
||||
|
||||
# タスクの同時実行数(1 = 逐次実行、最大: 10)
|
||||
|
||||
@ -113,6 +113,14 @@ E2Eテストを追加・変更した場合は、このドキュメントも更
|
||||
- `takt run --provider mock` を起動し、`=== Running Piece:` が出たら `Ctrl+C` を送る。
|
||||
- 3件目タスク(`sigint-c`)が開始されないことを確認する。
|
||||
- `=== Tasks Summary ===` 以降に新規タスク開始やクローン作成ログが出ないことを確認する。
|
||||
- Runtime config injection with provider(`e2e/specs/runtime-config-provider.e2e.ts`)
|
||||
- 目的: `config.yaml` の `runtime.prepare` が provider 実行時に反映される正例と、未設定時の失敗再現(env未注入)を確認。
|
||||
- LLM: 条件付き(`TAKT_E2E_PROVIDER` が `claude` / `codex` / `opencode` の場合に実行、未指定時は skip)
|
||||
- 手順(ユーザー行動/コマンド):
|
||||
- E2E用 `config.yaml` に `runtime.prepare: [gradle, node]` を設定する。
|
||||
- `takt --task '<gradle/npm を実行する指示>' --piece e2e/fixtures/pieces/simple.yaml --create-worktree no` を実行する。
|
||||
- 正例では、作業リポジトリに `.runtime/env.sh` と `.runtime/{tmp,cache,config,state,gradle,npm}` が作成されていることを確認する。
|
||||
- 負例(`runtime.prepare` 未設定)では、`GRADLE_USER_HOME is required` と npm キャッシュ書き込み失敗が出力され、`.runtime/env.sh` が生成されないことを確認する。
|
||||
- List tasks non-interactive(`e2e/specs/list-non-interactive.e2e.ts`)
|
||||
- 目的: `takt list` の非対話モードでブランチ操作ができることを確認。
|
||||
- LLM: 呼び出さない(LLM不使用の操作のみ)
|
||||
|
||||
168
e2e/specs/runtime-config-provider.e2e.ts
Normal file
168
e2e/specs/runtime-config-provider.e2e.ts
Normal file
@ -0,0 +1,168 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs';
|
||||
import { join, resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createIsolatedEnv, type IsolatedEnv, updateIsolatedConfig } from '../helpers/isolated-env';
|
||||
import { runTakt } from '../helpers/takt-runner';
|
||||
import { createLocalRepo, type LocalRepo } from '../helpers/test-repo';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const provider = process.env.TAKT_E2E_PROVIDER;
|
||||
const providerEnabled = provider != null && provider !== 'mock';
|
||||
const providerIt = providerEnabled ? it : it.skip;
|
||||
|
||||
// E2E更新時は docs/testing/e2e.md も更新すること
|
||||
describe('E2E: runtime.prepare with provider', () => {
|
||||
let isolatedEnv: IsolatedEnv;
|
||||
let repo: LocalRepo;
|
||||
|
||||
beforeEach(() => {
|
||||
isolatedEnv = createIsolatedEnv();
|
||||
repo = createLocalRepo();
|
||||
mkdirSync(join(repo.path, 'scripts'), { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
join(repo.path, 'gradlew'),
|
||||
[
|
||||
'#!/usr/bin/env bash',
|
||||
'set -euo pipefail',
|
||||
'if [ -z "${GRADLE_USER_HOME:-}" ]; then echo "GRADLE_USER_HOME is required"; exit 2; fi',
|
||||
'if [ -z "${TMPDIR:-}" ]; then echo "TMPDIR is required"; exit 3; fi',
|
||||
'mkdir -p "$GRADLE_USER_HOME"',
|
||||
'mkdir -p "$TMPDIR"',
|
||||
'echo "ok" > "$GRADLE_USER_HOME/gradle-ok.txt"',
|
||||
'echo "ok" > "$TMPDIR/gradle-tmp-ok.txt"',
|
||||
'echo "BUILD SUCCESSFUL"',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
chmodSync(join(repo.path, 'gradlew'), 0o755);
|
||||
|
||||
writeFileSync(
|
||||
join(repo.path, 'scripts/check-node-env.js'),
|
||||
[
|
||||
"const fs = require('node:fs');",
|
||||
"const path = require('node:path');",
|
||||
"const cache = process.env.npm_config_cache;",
|
||||
"if (!cache) { console.error('npm_config_cache is required'); process.exit(2); }",
|
||||
"fs.mkdirSync(cache, { recursive: true });",
|
||||
"fs.writeFileSync(path.join(cache, 'npm-ok.txt'), 'ok');",
|
||||
"console.log('node-env-ok');",
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
join(repo.path, 'package.json'),
|
||||
JSON.stringify({
|
||||
name: 'runtime-e2e',
|
||||
private: true,
|
||||
version: '1.0.0',
|
||||
scripts: {
|
||||
test: 'node scripts/check-node-env.js',
|
||||
},
|
||||
}, null, 2),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
join(repo.path, 'runtime-e2e-piece.yaml'),
|
||||
[
|
||||
'name: runtime-e2e',
|
||||
'description: Runtime env injection verification piece',
|
||||
'max_movements: 3',
|
||||
'initial_movement: execute',
|
||||
'movements:',
|
||||
' - name: execute',
|
||||
' edit: false',
|
||||
' persona: ../fixtures/agents/test-coder.md',
|
||||
' allowed_tools:',
|
||||
' - Read',
|
||||
' - Bash',
|
||||
' permission_mode: edit',
|
||||
' instruction_template: |',
|
||||
' {task}',
|
||||
' rules:',
|
||||
' - condition: Task completed',
|
||||
' next: COMPLETE',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try { repo.cleanup(); } catch { /* best-effort */ }
|
||||
try { isolatedEnv.cleanup(); } catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
providerIt('should apply runtime.prepare from config.yaml during provider execution', () => {
|
||||
updateIsolatedConfig(isolatedEnv.taktDir, {
|
||||
runtime: {
|
||||
prepare: ['gradle', 'node'],
|
||||
},
|
||||
});
|
||||
|
||||
const piecePath = join(repo.path, 'runtime-e2e-piece.yaml');
|
||||
const result = runTakt({
|
||||
args: [
|
||||
'--task',
|
||||
[
|
||||
'Run `./gradlew test` and `npm test` in the repository root.',
|
||||
'If both commands succeed, respond exactly with: Task completed',
|
||||
].join(' '),
|
||||
'--piece', piecePath,
|
||||
'--create-worktree', 'no',
|
||||
],
|
||||
cwd: repo.path,
|
||||
env: isolatedEnv.env,
|
||||
timeout: 240_000,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
||||
const runtimeRoot = join(repo.path, '.runtime');
|
||||
const envFile = join(runtimeRoot, 'env.sh');
|
||||
expect(existsSync(runtimeRoot)).toBe(true);
|
||||
expect(existsSync(join(runtimeRoot, 'tmp'))).toBe(true);
|
||||
expect(existsSync(join(runtimeRoot, 'cache'))).toBe(true);
|
||||
expect(existsSync(join(runtimeRoot, 'config'))).toBe(true);
|
||||
expect(existsSync(join(runtimeRoot, 'state'))).toBe(true);
|
||||
expect(existsSync(join(runtimeRoot, 'gradle'))).toBe(true);
|
||||
expect(existsSync(join(runtimeRoot, 'npm'))).toBe(true);
|
||||
expect(existsSync(join(runtimeRoot, 'gradle', 'gradle-ok.txt'))).toBe(true);
|
||||
expect(existsSync(join(runtimeRoot, 'npm', 'npm-ok.txt'))).toBe(true);
|
||||
expect(existsSync(envFile)).toBe(true);
|
||||
|
||||
const envContent = readFileSync(envFile, 'utf-8');
|
||||
expect(envContent).toContain('export TMPDIR=');
|
||||
expect(envContent).toContain('export GRADLE_USER_HOME=');
|
||||
expect(envContent).toContain('export npm_config_cache=');
|
||||
}, 240_000);
|
||||
|
||||
providerIt('should reproduce missing runtime env failure when runtime.prepare is unset', () => {
|
||||
const piecePath = join(repo.path, 'runtime-e2e-piece.yaml');
|
||||
const result = runTakt({
|
||||
args: [
|
||||
'--task',
|
||||
[
|
||||
'Run `./gradlew test` and `npm test` in the repository root without setting or overriding environment variables.',
|
||||
'If both commands succeed, respond exactly with: Task completed',
|
||||
].join(' '),
|
||||
'--piece', piecePath,
|
||||
'--create-worktree', 'no',
|
||||
],
|
||||
cwd: repo.path,
|
||||
env: isolatedEnv.env,
|
||||
timeout: 240_000,
|
||||
});
|
||||
|
||||
const combined = `${result.stdout}\n${result.stderr}`;
|
||||
|
||||
expect(combined).toContain('GRADLE_USER_HOME is required');
|
||||
|
||||
const runtimeRoot = join(repo.path, '.runtime');
|
||||
expect(existsSync(join(runtimeRoot, 'env.sh'))).toBe(false);
|
||||
}, 240_000);
|
||||
});
|
||||
@ -10,7 +10,7 @@
|
||||
"takt-cli": "./dist/app/cli/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc && mkdir -p dist/shared/prompts/en dist/shared/prompts/ja dist/shared/i18n && cp src/shared/prompts/en/*.md dist/shared/prompts/en/ && cp src/shared/prompts/ja/*.md dist/shared/prompts/ja/ && cp src/shared/i18n/labels_en.yaml src/shared/i18n/labels_ja.yaml dist/shared/i18n/",
|
||||
"build": "tsc && mkdir -p dist/shared/prompts/en dist/shared/prompts/ja dist/shared/i18n dist/core/runtime/presets && cp src/shared/prompts/en/*.md dist/shared/prompts/en/ && cp src/shared/prompts/ja/*.md dist/shared/prompts/ja/ && cp src/shared/i18n/labels_en.yaml src/shared/i18n/labels_ja.yaml dist/shared/i18n/ && cp src/core/runtime/presets/*.sh dist/core/runtime/presets/",
|
||||
"watch": "tsc --watch",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
|
||||
@ -483,6 +483,41 @@ describe('loadGlobalConfig', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('runtime', () => {
|
||||
it('should load runtime.prepare from config.yaml', () => {
|
||||
const taktDir = join(testHomeDir, '.takt');
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
writeFileSync(
|
||||
getGlobalConfigPath(),
|
||||
[
|
||||
'language: en',
|
||||
'runtime:',
|
||||
' prepare:',
|
||||
' - gradle',
|
||||
' - node',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const config = loadGlobalConfig();
|
||||
expect(config.runtime).toEqual({ prepare: ['gradle', 'node'] });
|
||||
});
|
||||
|
||||
it('should save and reload runtime.prepare', () => {
|
||||
const taktDir = join(testHomeDir, '.takt');
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
|
||||
|
||||
const config = loadGlobalConfig();
|
||||
config.runtime = { prepare: ['gradle', 'node'] };
|
||||
saveGlobalConfig(config);
|
||||
invalidateGlobalConfigCache();
|
||||
|
||||
const reloaded = loadGlobalConfig();
|
||||
expect(reloaded.runtime).toEqual({ prepare: ['gradle', 'node'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider/model compatibility validation', () => {
|
||||
it('should throw when provider is codex but model is a Claude alias (opus)', () => {
|
||||
const taktDir = join(testHomeDir, '.takt');
|
||||
|
||||
@ -153,6 +153,30 @@ describe('PieceConfigRawSchema', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse piece-level piece_config.runtime.prepare', () => {
|
||||
const config = {
|
||||
name: 'test-piece',
|
||||
piece_config: {
|
||||
runtime: {
|
||||
prepare: ['gradle', 'node'],
|
||||
},
|
||||
},
|
||||
movements: [
|
||||
{
|
||||
name: 'implement',
|
||||
instruction: '{task}',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = PieceConfigRawSchema.parse(config);
|
||||
expect(result.piece_config).toEqual({
|
||||
runtime: {
|
||||
prepare: ['gradle', 'node'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow omitting permission_mode', () => {
|
||||
const config = {
|
||||
name: 'test-piece',
|
||||
|
||||
@ -91,6 +91,29 @@ describe('normalizePieceConfig provider_options', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('piece-level runtime.prepare を正規化し重複を除去する', () => {
|
||||
const raw = {
|
||||
name: 'runtime-prepare',
|
||||
piece_config: {
|
||||
runtime: {
|
||||
prepare: ['gradle', 'node', 'gradle'],
|
||||
},
|
||||
},
|
||||
movements: [
|
||||
{
|
||||
name: 'implement',
|
||||
instruction: '{task}',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const config = normalizePieceConfig(raw, process.cwd());
|
||||
|
||||
expect(config.runtime).toEqual({
|
||||
prepare: ['gradle', 'node'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeProviderOptions', () => {
|
||||
|
||||
113
src/__tests__/runtime-environment.test.ts
Normal file
113
src/__tests__/runtime-environment.test.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import { mkdtempSync, existsSync, readFileSync, rmSync, writeFileSync, chmodSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { prepareRuntimeEnvironment, resolveRuntimeConfig } from '../core/runtime/runtime-environment.js';
|
||||
|
||||
describe('prepareRuntimeEnvironment', () => {
|
||||
const tempDirs: string[] = [];
|
||||
const systemTmpDir = tmpdir();
|
||||
const envKeys = [
|
||||
'TMPDIR',
|
||||
'XDG_CACHE_HOME',
|
||||
'XDG_CONFIG_HOME',
|
||||
'XDG_STATE_HOME',
|
||||
'CI',
|
||||
'JAVA_TOOL_OPTIONS',
|
||||
'GRADLE_USER_HOME',
|
||||
'npm_config_cache',
|
||||
] as const;
|
||||
const originalEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]]));
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
for (const key of envKeys) {
|
||||
const value = originalEnv[key];
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should return undefined when runtime.prepare is not set', () => {
|
||||
const cwd = mkdtempSync(join(systemTmpDir, 'takt-runtime-env-'));
|
||||
tempDirs.push(cwd);
|
||||
|
||||
const result = prepareRuntimeEnvironment(cwd, undefined);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should create .runtime files and inject tool-specific env', () => {
|
||||
const cwd = mkdtempSync(join(systemTmpDir, 'takt-runtime-env-'));
|
||||
tempDirs.push(cwd);
|
||||
|
||||
const result = prepareRuntimeEnvironment(cwd, {
|
||||
prepare: ['gradle', 'node'],
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.prepare).toEqual(['gradle', 'node']);
|
||||
|
||||
const runtimeRoot = join(cwd, '.runtime');
|
||||
expect(existsSync(runtimeRoot)).toBe(true);
|
||||
expect(existsSync(join(runtimeRoot, 'tmp'))).toBe(true);
|
||||
expect(existsSync(join(runtimeRoot, 'cache'))).toBe(true);
|
||||
expect(existsSync(join(runtimeRoot, 'config'))).toBe(true);
|
||||
expect(existsSync(join(runtimeRoot, 'state'))).toBe(true);
|
||||
expect(existsSync(join(runtimeRoot, 'gradle'))).toBe(true);
|
||||
expect(existsSync(join(runtimeRoot, 'npm'))).toBe(true);
|
||||
|
||||
const envFile = join(runtimeRoot, 'env.sh');
|
||||
expect(existsSync(envFile)).toBe(true);
|
||||
const envContent = readFileSync(envFile, 'utf-8');
|
||||
expect(envContent).toContain('export TMPDIR=');
|
||||
expect(envContent).toContain('export GRADLE_USER_HOME=');
|
||||
expect(envContent).toContain('export npm_config_cache=');
|
||||
});
|
||||
|
||||
it('should execute custom prepare script path and merge exported env', () => {
|
||||
const cwd = mkdtempSync(join(systemTmpDir, 'takt-runtime-env-'));
|
||||
tempDirs.push(cwd);
|
||||
|
||||
const scriptPath = join(cwd, 'prepare-custom.sh');
|
||||
writeFileSync(scriptPath, [
|
||||
'#!/usr/bin/env bash',
|
||||
'set -euo pipefail',
|
||||
'runtime_root="${TAKT_RUNTIME_ROOT:?}"',
|
||||
'custom_dir="$runtime_root/custom-cache"',
|
||||
'mkdir -p "$custom_dir"',
|
||||
'echo "CUSTOM_CACHE_DIR=$custom_dir"',
|
||||
].join('\n'), 'utf-8');
|
||||
chmodSync(scriptPath, 0o755);
|
||||
|
||||
const result = prepareRuntimeEnvironment(cwd, {
|
||||
prepare: [scriptPath],
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.injectedEnv.CUSTOM_CACHE_DIR).toBe(join(cwd, '.runtime', 'custom-cache'));
|
||||
expect(existsSync(join(cwd, '.runtime', 'custom-cache'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRuntimeConfig', () => {
|
||||
it('should use piece runtime when both global and piece are defined', () => {
|
||||
const resolved = resolveRuntimeConfig(
|
||||
{ prepare: ['gradle', 'node'] },
|
||||
{ prepare: ['node', 'pnpm'] },
|
||||
);
|
||||
expect(resolved).toEqual({ prepare: ['node', 'pnpm'] });
|
||||
});
|
||||
|
||||
it('should fallback to global runtime when piece runtime is missing', () => {
|
||||
const resolved = resolveRuntimeConfig(
|
||||
{ prepare: ['gradle', 'node', 'gradle'] },
|
||||
undefined,
|
||||
);
|
||||
expect(resolved).toEqual({ prepare: ['gradle', 'node'] });
|
||||
});
|
||||
});
|
||||
@ -2,7 +2,7 @@
|
||||
* Configuration types (global and project)
|
||||
*/
|
||||
|
||||
import type { MovementProviderOptions } from './piece-types.js';
|
||||
import type { MovementProviderOptions, PieceRuntimeConfig } from './piece-types.js';
|
||||
|
||||
/** Custom agent configuration */
|
||||
export interface CustomAgentConfig {
|
||||
@ -90,6 +90,8 @@ export interface GlobalConfig {
|
||||
personaProviders?: Record<string, 'claude' | 'codex' | 'opencode' | 'mock'>;
|
||||
/** Global provider-specific options (lowest priority) */
|
||||
providerOptions?: MovementProviderOptions;
|
||||
/** Global runtime environment defaults (can be overridden by piece runtime) */
|
||||
runtime?: PieceRuntimeConfig;
|
||||
/** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */
|
||||
branchNameStrategy?: 'romaji' | 'ai';
|
||||
/** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */
|
||||
|
||||
@ -8,6 +8,9 @@ export type {
|
||||
OutputContractItem,
|
||||
OutputContractEntry,
|
||||
McpServerConfig,
|
||||
RuntimePreparePreset,
|
||||
RuntimePrepareEntry,
|
||||
PieceRuntimeConfig,
|
||||
AgentResponse,
|
||||
SessionState,
|
||||
PartDefinition,
|
||||
|
||||
@ -92,6 +92,17 @@ export interface OpenCodeProviderOptions {
|
||||
networkAccess?: boolean;
|
||||
}
|
||||
|
||||
/** Runtime prepare preset identifiers */
|
||||
export type RuntimePreparePreset = 'gradle' | 'node';
|
||||
/** Runtime prepare entry: preset name or executable script path */
|
||||
export type RuntimePrepareEntry = RuntimePreparePreset | string;
|
||||
|
||||
/** Piece-level runtime environment settings */
|
||||
export interface PieceRuntimeConfig {
|
||||
/** Preset(s) or script path(s) to prepare .runtime and inject env vars */
|
||||
prepare?: RuntimePrepareEntry[];
|
||||
}
|
||||
|
||||
/** Claude sandbox settings (maps to SDK SandboxSettings) */
|
||||
export interface ClaudeSandboxSettings {
|
||||
/** Allow all Bash commands to run outside the sandbox */
|
||||
@ -237,6 +248,8 @@ export interface PieceConfig {
|
||||
description?: string;
|
||||
/** Piece-level default provider options (used as movement defaults) */
|
||||
providerOptions?: MovementProviderOptions;
|
||||
/** Piece-level runtime preparation and env injection settings */
|
||||
runtime?: PieceRuntimeConfig;
|
||||
/** Persona definitions — map of name to file path or inline content (raw, not content-resolved) */
|
||||
personas?: Record<string, string>;
|
||||
/** Resolved policy definitions — map of name to file content (resolved at parse time) */
|
||||
|
||||
@ -78,9 +78,23 @@ export const MovementProviderOptionsSchema = z.object({
|
||||
}).optional(),
|
||||
}).optional();
|
||||
|
||||
/** Runtime prepare preset identifiers */
|
||||
export const RuntimePreparePresetSchema = z.enum(['gradle', 'node']);
|
||||
/** Runtime prepare entry: preset name or script path */
|
||||
export const RuntimePrepareEntrySchema = z.union([
|
||||
RuntimePreparePresetSchema,
|
||||
z.string().min(1),
|
||||
]);
|
||||
|
||||
/** Piece-level runtime settings */
|
||||
export const RuntimeConfigSchema = z.object({
|
||||
prepare: z.array(RuntimePrepareEntrySchema).optional(),
|
||||
}).optional();
|
||||
|
||||
/** Piece-level provider options schema */
|
||||
export const PieceProviderOptionsSchema = z.object({
|
||||
provider_options: MovementProviderOptionsSchema,
|
||||
runtime: RuntimeConfigSchema,
|
||||
}).optional();
|
||||
|
||||
/**
|
||||
@ -425,6 +439,8 @@ export const GlobalConfigSchema = z.object({
|
||||
persona_providers: z.record(z.string(), z.enum(['claude', 'codex', 'opencode', 'mock'])).optional(),
|
||||
/** Global provider-specific options (lowest priority) */
|
||||
provider_options: MovementProviderOptionsSchema,
|
||||
/** Global runtime defaults (piece runtime overrides this) */
|
||||
runtime: RuntimeConfigSchema,
|
||||
/** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */
|
||||
branch_name_strategy: z.enum(['romaji', 'ai']).optional(),
|
||||
/** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */
|
||||
|
||||
@ -37,6 +37,9 @@ export type {
|
||||
OutputContractItem,
|
||||
OutputContractEntry,
|
||||
McpServerConfig,
|
||||
RuntimePreparePreset,
|
||||
RuntimePrepareEntry,
|
||||
PieceRuntimeConfig,
|
||||
MovementProviderOptions,
|
||||
PieceMovement,
|
||||
ArpeggioMovementConfig,
|
||||
|
||||
@ -33,6 +33,7 @@ import { ParallelRunner } from './ParallelRunner.js';
|
||||
import { ArpeggioRunner } from './ArpeggioRunner.js';
|
||||
import { TeamLeaderRunner } from './TeamLeaderRunner.js';
|
||||
import { buildRunPaths, type RunPaths } from '../run/run-paths.js';
|
||||
import { prepareRuntimeEnvironment } from '../../runtime/runtime-environment.js';
|
||||
|
||||
const log = createLogger('engine');
|
||||
|
||||
@ -89,6 +90,7 @@ export class PieceEngine extends EventEmitter {
|
||||
this.runPaths = buildRunPaths(this.cwd, reportDirName);
|
||||
this.reportDir = this.runPaths.reportsRel;
|
||||
this.ensureRunDirsExist();
|
||||
this.applyRuntimeEnvironment('init');
|
||||
this.validateConfig();
|
||||
this.state = createInitialState(config, options);
|
||||
this.detectRuleIndex = options.detectRuleIndex ?? (() => {
|
||||
@ -215,6 +217,20 @@ export class PieceEngine extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
private applyRuntimeEnvironment(stage: 'init' | 'movement'): void {
|
||||
const prepared = prepareRuntimeEnvironment(this.cwd, this.config.runtime);
|
||||
if (!prepared) return;
|
||||
log.info('Runtime environment prepared', {
|
||||
stage,
|
||||
runtimeRoot: prepared.runtimeRoot,
|
||||
envFile: prepared.envFile,
|
||||
prepare: prepared.prepare,
|
||||
tmpdir: prepared.injectedEnv.TMPDIR,
|
||||
gradleUserHome: prepared.injectedEnv.GRADLE_USER_HOME,
|
||||
npmCache: prepared.injectedEnv.npm_config_cache,
|
||||
});
|
||||
}
|
||||
|
||||
/** Validate piece configuration at construction time */
|
||||
private validateConfig(): void {
|
||||
const initialMovement = this.config.movements.find((s) => s.name === this.config.initialMovement);
|
||||
@ -538,6 +554,7 @@ export class PieceEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
const movement = this.getMovement(this.state.currentMovement);
|
||||
this.applyRuntimeEnvironment('movement');
|
||||
const loopCheck = this.loopDetector.check(movement.name);
|
||||
|
||||
if (loopCheck.shouldWarn) {
|
||||
@ -676,6 +693,7 @@ export class PieceEngine extends EventEmitter {
|
||||
loopDetected?: boolean;
|
||||
}> {
|
||||
const movement = this.getMovement(this.state.currentMovement);
|
||||
this.applyRuntimeEnvironment('movement');
|
||||
const loopCheck = this.loopDetector.check(movement.name);
|
||||
|
||||
if (loopCheck.shouldAbort) {
|
||||
|
||||
11
src/core/runtime/presets/prepare-gradle.sh
Executable file
11
src/core/runtime/presets/prepare-gradle.sh
Executable file
@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
runtime_root="${TAKT_RUNTIME_ROOT:?TAKT_RUNTIME_ROOT is required}"
|
||||
runtime_tmp="${TAKT_RUNTIME_TMP:-$runtime_root/tmp}"
|
||||
gradle_home="$runtime_root/gradle"
|
||||
|
||||
mkdir -p "$runtime_tmp" "$gradle_home"
|
||||
|
||||
echo "GRADLE_USER_HOME=$gradle_home"
|
||||
echo "TMPDIR=$runtime_tmp"
|
||||
11
src/core/runtime/presets/prepare-node.sh
Executable file
11
src/core/runtime/presets/prepare-node.sh
Executable file
@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
runtime_root="${TAKT_RUNTIME_ROOT:?TAKT_RUNTIME_ROOT is required}"
|
||||
runtime_tmp="${TAKT_RUNTIME_TMP:-$runtime_root/tmp}"
|
||||
npm_cache="$runtime_root/npm"
|
||||
|
||||
mkdir -p "$runtime_tmp" "$npm_cache"
|
||||
|
||||
echo "npm_config_cache=$npm_cache"
|
||||
echo "TMPDIR=$runtime_tmp"
|
||||
208
src/core/runtime/runtime-environment.ts
Normal file
208
src/core/runtime/runtime-environment.ts
Normal file
@ -0,0 +1,208 @@
|
||||
import { mkdirSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { join, resolve, isAbsolute, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import type { PieceRuntimeConfig, RuntimePrepareEntry, RuntimePreparePreset } from '../models/piece-types.js';
|
||||
|
||||
export interface RuntimeEnvironmentResult {
|
||||
runtimeRoot: string;
|
||||
envFile: string;
|
||||
prepare: RuntimePrepareEntry[];
|
||||
injectedEnv: Record<string, string>;
|
||||
}
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const PRESET_SCRIPT_DIR = join(__dirname, 'presets');
|
||||
const PRESET_SCRIPT_MAP: Record<RuntimePreparePreset, string> = {
|
||||
gradle: join(PRESET_SCRIPT_DIR, 'prepare-gradle.sh'),
|
||||
node: join(PRESET_SCRIPT_DIR, 'prepare-node.sh'),
|
||||
};
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function createBaseEnvironment(runtimeRoot: string): Record<string, string> {
|
||||
return {
|
||||
TMPDIR: join(runtimeRoot, 'tmp'),
|
||||
XDG_CACHE_HOME: join(runtimeRoot, 'cache'),
|
||||
XDG_CONFIG_HOME: join(runtimeRoot, 'config'),
|
||||
XDG_STATE_HOME: join(runtimeRoot, 'state'),
|
||||
CI: 'true',
|
||||
};
|
||||
}
|
||||
|
||||
function appendJavaTmpdirOption(base: string | undefined, tmpDir: string): string {
|
||||
const option = `-Djava.io.tmpdir=${tmpDir}`;
|
||||
if (!base || base.trim().length === 0) return option;
|
||||
if (base.includes(option)) return base;
|
||||
return `${base} ${option}`.trim();
|
||||
}
|
||||
|
||||
function parseScriptOutput(stdout: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
const lines = stdout.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const normalized = trimmed.startsWith('export ')
|
||||
? trimmed.slice('export '.length).trim()
|
||||
: trimmed;
|
||||
const eq = normalized.indexOf('=');
|
||||
if (eq <= 0) continue;
|
||||
const key = normalized.slice(0, eq).trim();
|
||||
const value = normalized.slice(eq + 1).trim();
|
||||
if (!key) continue;
|
||||
env[key] = value.replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function resolvePrepareScript(cwd: string, entry: RuntimePrepareEntry): string {
|
||||
if (entry === 'gradle' || entry === 'node') {
|
||||
return PRESET_SCRIPT_MAP[entry];
|
||||
}
|
||||
return isAbsolute(entry) ? entry : resolve(cwd, entry);
|
||||
}
|
||||
|
||||
function runPrepareScript(
|
||||
cwd: string,
|
||||
scriptPath: string,
|
||||
runtimeRoot: string,
|
||||
env: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
if (!existsSync(scriptPath)) {
|
||||
throw new Error(`Runtime prepare script not found: ${scriptPath}`);
|
||||
}
|
||||
|
||||
const result = spawnSync('bash', [scriptPath], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
TAKT_RUNTIME_ROOT: runtimeRoot,
|
||||
TAKT_RUNTIME_TMP: join(runtimeRoot, 'tmp'),
|
||||
TAKT_RUNTIME_CACHE: join(runtimeRoot, 'cache'),
|
||||
TAKT_RUNTIME_CONFIG: join(runtimeRoot, 'config'),
|
||||
TAKT_RUNTIME_STATE: join(runtimeRoot, 'state'),
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
const stderr = (result.stderr ?? '').trim();
|
||||
throw new Error(`Runtime prepare script failed: ${scriptPath}${stderr ? ` (${stderr})` : ''}`);
|
||||
}
|
||||
|
||||
return parseScriptOutput(result.stdout ?? '');
|
||||
}
|
||||
|
||||
function buildInjectedEnvironment(
|
||||
cwd: string,
|
||||
runtimeRoot: string,
|
||||
prepareEntries: RuntimePrepareEntry[],
|
||||
): Record<string, string> {
|
||||
const env: Record<string, string> = {
|
||||
...createBaseEnvironment(runtimeRoot),
|
||||
};
|
||||
|
||||
for (const entry of prepareEntries) {
|
||||
const scriptPath = resolvePrepareScript(cwd, entry);
|
||||
const scriptEnv = runPrepareScript(cwd, scriptPath, runtimeRoot, env);
|
||||
Object.assign(env, scriptEnv);
|
||||
}
|
||||
|
||||
if (prepareEntries.includes('gradle')) {
|
||||
const tmpDir = env.TMPDIR ?? join(runtimeRoot, 'tmp');
|
||||
env.JAVA_TOOL_OPTIONS = appendJavaTmpdirOption(process.env['JAVA_TOOL_OPTIONS'], tmpDir);
|
||||
}
|
||||
if (prepareEntries.includes('gradle') && !env.GRADLE_USER_HOME) {
|
||||
env.GRADLE_USER_HOME = join(runtimeRoot, 'gradle');
|
||||
}
|
||||
if (prepareEntries.includes('node') && !env.npm_config_cache) {
|
||||
env.npm_config_cache = join(runtimeRoot, 'npm');
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
function ensureRuntimeDirectories(runtimeRoot: string, env: Record<string, string>): void {
|
||||
const dirs = new Set<string>([
|
||||
runtimeRoot,
|
||||
join(runtimeRoot, 'tmp'),
|
||||
join(runtimeRoot, 'cache'),
|
||||
join(runtimeRoot, 'config'),
|
||||
join(runtimeRoot, 'state'),
|
||||
]);
|
||||
|
||||
for (const value of Object.values(env)) {
|
||||
if (!value || value === 'true') continue;
|
||||
if (value.startsWith(runtimeRoot)) {
|
||||
dirs.add(value);
|
||||
}
|
||||
}
|
||||
|
||||
for (const dir of dirs) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function writeRuntimeEnvFile(envFile: string, env: Record<string, string>): void {
|
||||
const lines = [
|
||||
'#!/usr/bin/env bash',
|
||||
'set -euo pipefail',
|
||||
'',
|
||||
];
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
lines.push(`export ${key}=${shellQuote(value)}`);
|
||||
}
|
||||
lines.push('');
|
||||
writeFileSync(envFile, lines.join('\n'), 'utf-8');
|
||||
}
|
||||
|
||||
function dedupePrepare(entries: RuntimePrepareEntry[]): RuntimePrepareEntry[] {
|
||||
return [...new Set(entries)];
|
||||
}
|
||||
|
||||
export function resolveRuntimeConfig(
|
||||
globalRuntime: PieceRuntimeConfig | undefined,
|
||||
pieceRuntime: PieceRuntimeConfig | undefined,
|
||||
): PieceRuntimeConfig | undefined {
|
||||
const prepare = pieceRuntime?.prepare?.length
|
||||
? pieceRuntime.prepare
|
||||
: globalRuntime?.prepare;
|
||||
if (!prepare || prepare.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return { prepare: dedupePrepare(prepare) };
|
||||
}
|
||||
|
||||
export function prepareRuntimeEnvironment(
|
||||
cwd: string,
|
||||
runtime: PieceRuntimeConfig | undefined,
|
||||
): RuntimeEnvironmentResult | undefined {
|
||||
const prepareEntries = runtime?.prepare;
|
||||
if (!prepareEntries || prepareEntries.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const deduped = dedupePrepare(prepareEntries);
|
||||
const runtimeRoot = join(cwd, '.runtime');
|
||||
const envFile = join(runtimeRoot, 'env.sh');
|
||||
const injectedEnv = buildInjectedEnvironment(cwd, runtimeRoot, deduped);
|
||||
|
||||
ensureRuntimeDirectories(runtimeRoot, injectedEnv);
|
||||
writeRuntimeEnvFile(envFile, injectedEnv);
|
||||
|
||||
for (const [key, value] of Object.entries(injectedEnv)) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
|
||||
return {
|
||||
runtimeRoot,
|
||||
envFile,
|
||||
prepare: deduped,
|
||||
injectedEnv,
|
||||
};
|
||||
}
|
||||
@ -70,6 +70,7 @@ import { EXIT_SIGINT } from '../../../shared/exitCodes.js';
|
||||
import { ShutdownManager } from './shutdownManager.js';
|
||||
import { buildRunPaths } from '../../../core/piece/run/run-paths.js';
|
||||
import { resolveMovementProviderModel } from '../../../core/piece/provider-resolution.js';
|
||||
import { resolveRuntimeConfig } from '../../../core/runtime/runtime-environment.js';
|
||||
import { writeFileAtomic, ensureDir } from '../../../infra/config/index.js';
|
||||
|
||||
const log = createLogger('piece');
|
||||
@ -319,6 +320,10 @@ export async function executePiece(
|
||||
const shouldNotifyPieceComplete = shouldNotify && notificationSoundEvents?.pieceComplete !== false;
|
||||
const shouldNotifyPieceAbort = shouldNotify && notificationSoundEvents?.pieceAbort !== false;
|
||||
const currentProvider = globalConfig.provider ?? 'claude';
|
||||
const effectivePieceConfig: PieceConfig = {
|
||||
...pieceConfig,
|
||||
runtime: resolveRuntimeConfig(globalConfig.runtime, pieceConfig.runtime),
|
||||
};
|
||||
const providerEventLogger = createProviderEventLogger({
|
||||
logsDir: runPaths.logsAbs,
|
||||
sessionId: pieceSessionId,
|
||||
@ -424,7 +429,7 @@ export async function executePiece(
|
||||
const runAbortController = new AbortController();
|
||||
|
||||
try {
|
||||
engine = new PieceEngine(pieceConfig, cwd, task, {
|
||||
engine = new PieceEngine(effectivePieceConfig, cwd, task, {
|
||||
abortSignal: runAbortController.signal,
|
||||
onStream: providerEventLogger.wrapCallback(streamHandler),
|
||||
onUserInput,
|
||||
|
||||
@ -126,6 +126,9 @@ export class GlobalConfigManager {
|
||||
pieceCategoriesFile: parsed.piece_categories_file,
|
||||
personaProviders: parsed.persona_providers,
|
||||
providerOptions: normalizeProviderOptions(parsed.provider_options),
|
||||
runtime: parsed.runtime?.prepare && parsed.runtime.prepare.length > 0
|
||||
? { prepare: [...new Set(parsed.runtime.prepare)] }
|
||||
: undefined,
|
||||
branchNameStrategy: parsed.branch_name_strategy,
|
||||
preventSleep: parsed.prevent_sleep,
|
||||
notificationSound: parsed.notification_sound,
|
||||
@ -210,6 +213,11 @@ export class GlobalConfigManager {
|
||||
if (config.personaProviders && Object.keys(config.personaProviders).length > 0) {
|
||||
raw.persona_providers = config.personaProviders;
|
||||
}
|
||||
if (config.runtime?.prepare && config.runtime.prepare.length > 0) {
|
||||
raw.runtime = {
|
||||
prepare: [...new Set(config.runtime.prepare)],
|
||||
};
|
||||
}
|
||||
if (config.branchNameStrategy) {
|
||||
raw.branch_name_strategy = config.branchNameStrategy;
|
||||
}
|
||||
|
||||
@ -23,8 +23,10 @@ import {
|
||||
} from './resource-resolver.js';
|
||||
|
||||
type RawStep = z.output<typeof PieceMovementRawSchema>;
|
||||
type RawPiece = z.output<typeof PieceConfigRawSchema>;
|
||||
|
||||
import type { MovementProviderOptions } from '../../../core/models/piece-types.js';
|
||||
import type { PieceRuntimeConfig } from '../../../core/models/piece-types.js';
|
||||
|
||||
/** Convert raw YAML provider_options (snake_case) to internal format (camelCase). */
|
||||
export function normalizeProviderOptions(
|
||||
@ -81,6 +83,16 @@ export function mergeProviderOptions(
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
function normalizeRuntimeConfig(raw: RawPiece['piece_config']): PieceRuntimeConfig | undefined {
|
||||
const prepare = raw?.runtime?.prepare;
|
||||
if (!prepare || prepare.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
prepare: [...new Set(prepare)],
|
||||
};
|
||||
}
|
||||
|
||||
/** Check if a raw output contract item is the object form (has 'name' property). */
|
||||
function isOutputContractItem(raw: unknown): raw is { name: string; order?: string; format?: string } {
|
||||
return typeof raw === 'object' && raw !== null && !Array.isArray(raw) && 'name' in raw;
|
||||
@ -389,6 +401,7 @@ export function normalizePieceConfig(
|
||||
};
|
||||
|
||||
const pieceProviderOptions = normalizeProviderOptions(parsed.piece_config?.provider_options as RawStep['provider_options']);
|
||||
const pieceRuntime = normalizeRuntimeConfig(parsed.piece_config);
|
||||
|
||||
const movements: PieceMovement[] = parsed.movements.map((step) =>
|
||||
normalizeStepFromRaw(step, pieceDir, sections, pieceProviderOptions, context),
|
||||
@ -401,6 +414,7 @@ export function normalizePieceConfig(
|
||||
name: parsed.name,
|
||||
description: parsed.description,
|
||||
providerOptions: pieceProviderOptions,
|
||||
runtime: pieceRuntime,
|
||||
personas: parsed.personas,
|
||||
policies: resolvedPolicies,
|
||||
knowledge: resolvedKnowledge,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user