feat(runtime): add configurable prepare presets and provider e2e

This commit is contained in:
nrslib 2026-02-15 05:28:39 +09:00
parent 18bad35489
commit 6e14cd3c38
21 changed files with 698 additions and 3 deletions

View File

@ -45,6 +45,12 @@ provider: claude
# Prevent macOS idle sleep during execution using caffeinate (default: false) # Prevent macOS idle sleep during execution using caffeinate (default: false)
# prevent_sleep: false # prevent_sleep: false
# Runtime environment defaults (applies to all pieces unless piece_config.runtime overrides)
# runtime:
# prepare:
# - gradle
# - node
# ── Parallel Execution (takt run) ── # ── Parallel Execution (takt run) ──
# Number of tasks to run concurrently (1 = sequential, max: 10) # Number of tasks to run concurrently (1 = sequential, max: 10)

View File

@ -45,6 +45,12 @@ provider: claude
# macOS のアイドルスリープを防止(デフォルト: false # macOS のアイドルスリープを防止(デフォルト: false
# prevent_sleep: false # prevent_sleep: false
# 実行時ランタイム環境のデフォルトpiece_config.runtime があればそちらを優先)
# runtime:
# prepare:
# - gradle
# - node
# ── 並列実行 (takt run) ── # ── 並列実行 (takt run) ──
# タスクの同時実行数1 = 逐次実行、最大: 10 # タスクの同時実行数1 = 逐次実行、最大: 10

View File

@ -113,6 +113,14 @@ E2Eテストを追加・変更した場合は、このドキュメントも更
- `takt run --provider mock` を起動し、`=== Running Piece:` が出たら `Ctrl+C` を送る。 - `takt run --provider mock` を起動し、`=== Running Piece:` が出たら `Ctrl+C` を送る。
- 3件目タスク`sigint-c`)が開始されないことを確認する。 - 3件目タスク`sigint-c`)が開始されないことを確認する。
- `=== Tasks Summary ===` 以降に新規タスク開始やクローン作成ログが出ないことを確認する。 - `=== 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` - List tasks non-interactive`e2e/specs/list-non-interactive.e2e.ts`
- 目的: `takt list` の非対話モードでブランチ操作ができることを確認。 - 目的: `takt list` の非対話モードでブランチ操作ができることを確認。
- LLM: 呼び出さないLLM不使用の操作のみ - LLM: 呼び出さないLLM不使用の操作のみ

View 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);
});

View File

@ -10,7 +10,7 @@
"takt-cli": "./dist/app/cli/index.js" "takt-cli": "./dist/app/cli/index.js"
}, },
"scripts": { "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", "watch": "tsc --watch",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",

View File

@ -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', () => { describe('provider/model compatibility validation', () => {
it('should throw when provider is codex but model is a Claude alias (opus)', () => { it('should throw when provider is codex but model is a Claude alias (opus)', () => {
const taktDir = join(testHomeDir, '.takt'); const taktDir = join(testHomeDir, '.takt');

View File

@ -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', () => { it('should allow omitting permission_mode', () => {
const config = { const config = {
name: 'test-piece', name: 'test-piece',

View File

@ -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', () => { describe('mergeProviderOptions', () => {

View 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'] });
});
});

View File

@ -2,7 +2,7 @@
* Configuration types (global and project) * Configuration types (global and project)
*/ */
import type { MovementProviderOptions } from './piece-types.js'; import type { MovementProviderOptions, PieceRuntimeConfig } from './piece-types.js';
/** Custom agent configuration */ /** Custom agent configuration */
export interface CustomAgentConfig { export interface CustomAgentConfig {
@ -90,6 +90,8 @@ export interface GlobalConfig {
personaProviders?: Record<string, 'claude' | 'codex' | 'opencode' | 'mock'>; personaProviders?: Record<string, 'claude' | 'codex' | 'opencode' | 'mock'>;
/** Global provider-specific options (lowest priority) */ /** Global provider-specific options (lowest priority) */
providerOptions?: MovementProviderOptions; providerOptions?: MovementProviderOptions;
/** Global runtime environment defaults (can be overridden by piece runtime) */
runtime?: PieceRuntimeConfig;
/** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */ /** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */
branchNameStrategy?: 'romaji' | 'ai'; branchNameStrategy?: 'romaji' | 'ai';
/** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */ /** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */

View File

@ -8,6 +8,9 @@ export type {
OutputContractItem, OutputContractItem,
OutputContractEntry, OutputContractEntry,
McpServerConfig, McpServerConfig,
RuntimePreparePreset,
RuntimePrepareEntry,
PieceRuntimeConfig,
AgentResponse, AgentResponse,
SessionState, SessionState,
PartDefinition, PartDefinition,

View File

@ -92,6 +92,17 @@ export interface OpenCodeProviderOptions {
networkAccess?: boolean; 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) */ /** Claude sandbox settings (maps to SDK SandboxSettings) */
export interface ClaudeSandboxSettings { export interface ClaudeSandboxSettings {
/** Allow all Bash commands to run outside the sandbox */ /** Allow all Bash commands to run outside the sandbox */
@ -237,6 +248,8 @@ export interface PieceConfig {
description?: string; description?: string;
/** Piece-level default provider options (used as movement defaults) */ /** Piece-level default provider options (used as movement defaults) */
providerOptions?: MovementProviderOptions; 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) */ /** Persona definitions — map of name to file path or inline content (raw, not content-resolved) */
personas?: Record<string, string>; personas?: Record<string, string>;
/** Resolved policy definitions — map of name to file content (resolved at parse time) */ /** Resolved policy definitions — map of name to file content (resolved at parse time) */

View File

@ -78,9 +78,23 @@ export const MovementProviderOptionsSchema = z.object({
}).optional(), }).optional(),
}).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 */ /** Piece-level provider options schema */
export const PieceProviderOptionsSchema = z.object({ export const PieceProviderOptionsSchema = z.object({
provider_options: MovementProviderOptionsSchema, provider_options: MovementProviderOptionsSchema,
runtime: RuntimeConfigSchema,
}).optional(); }).optional();
/** /**
@ -425,6 +439,8 @@ export const GlobalConfigSchema = z.object({
persona_providers: z.record(z.string(), z.enum(['claude', 'codex', 'opencode', 'mock'])).optional(), persona_providers: z.record(z.string(), z.enum(['claude', 'codex', 'opencode', 'mock'])).optional(),
/** Global provider-specific options (lowest priority) */ /** Global provider-specific options (lowest priority) */
provider_options: MovementProviderOptionsSchema, provider_options: MovementProviderOptionsSchema,
/** Global runtime defaults (piece runtime overrides this) */
runtime: RuntimeConfigSchema,
/** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */ /** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */
branch_name_strategy: z.enum(['romaji', 'ai']).optional(), branch_name_strategy: z.enum(['romaji', 'ai']).optional(),
/** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */ /** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */

View File

@ -37,6 +37,9 @@ export type {
OutputContractItem, OutputContractItem,
OutputContractEntry, OutputContractEntry,
McpServerConfig, McpServerConfig,
RuntimePreparePreset,
RuntimePrepareEntry,
PieceRuntimeConfig,
MovementProviderOptions, MovementProviderOptions,
PieceMovement, PieceMovement,
ArpeggioMovementConfig, ArpeggioMovementConfig,

View File

@ -33,6 +33,7 @@ import { ParallelRunner } from './ParallelRunner.js';
import { ArpeggioRunner } from './ArpeggioRunner.js'; import { ArpeggioRunner } from './ArpeggioRunner.js';
import { TeamLeaderRunner } from './TeamLeaderRunner.js'; import { TeamLeaderRunner } from './TeamLeaderRunner.js';
import { buildRunPaths, type RunPaths } from '../run/run-paths.js'; import { buildRunPaths, type RunPaths } from '../run/run-paths.js';
import { prepareRuntimeEnvironment } from '../../runtime/runtime-environment.js';
const log = createLogger('engine'); const log = createLogger('engine');
@ -89,6 +90,7 @@ export class PieceEngine extends EventEmitter {
this.runPaths = buildRunPaths(this.cwd, reportDirName); this.runPaths = buildRunPaths(this.cwd, reportDirName);
this.reportDir = this.runPaths.reportsRel; this.reportDir = this.runPaths.reportsRel;
this.ensureRunDirsExist(); this.ensureRunDirsExist();
this.applyRuntimeEnvironment('init');
this.validateConfig(); this.validateConfig();
this.state = createInitialState(config, options); this.state = createInitialState(config, options);
this.detectRuleIndex = options.detectRuleIndex ?? (() => { 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 */ /** Validate piece configuration at construction time */
private validateConfig(): void { private validateConfig(): void {
const initialMovement = this.config.movements.find((s) => s.name === this.config.initialMovement); 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); const movement = this.getMovement(this.state.currentMovement);
this.applyRuntimeEnvironment('movement');
const loopCheck = this.loopDetector.check(movement.name); const loopCheck = this.loopDetector.check(movement.name);
if (loopCheck.shouldWarn) { if (loopCheck.shouldWarn) {
@ -676,6 +693,7 @@ export class PieceEngine extends EventEmitter {
loopDetected?: boolean; loopDetected?: boolean;
}> { }> {
const movement = this.getMovement(this.state.currentMovement); const movement = this.getMovement(this.state.currentMovement);
this.applyRuntimeEnvironment('movement');
const loopCheck = this.loopDetector.check(movement.name); const loopCheck = this.loopDetector.check(movement.name);
if (loopCheck.shouldAbort) { if (loopCheck.shouldAbort) {

View 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"

View 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"

View 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,
};
}

View File

@ -70,6 +70,7 @@ import { EXIT_SIGINT } from '../../../shared/exitCodes.js';
import { ShutdownManager } from './shutdownManager.js'; import { ShutdownManager } from './shutdownManager.js';
import { buildRunPaths } from '../../../core/piece/run/run-paths.js'; import { buildRunPaths } from '../../../core/piece/run/run-paths.js';
import { resolveMovementProviderModel } from '../../../core/piece/provider-resolution.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'; import { writeFileAtomic, ensureDir } from '../../../infra/config/index.js';
const log = createLogger('piece'); const log = createLogger('piece');
@ -319,6 +320,10 @@ export async function executePiece(
const shouldNotifyPieceComplete = shouldNotify && notificationSoundEvents?.pieceComplete !== false; const shouldNotifyPieceComplete = shouldNotify && notificationSoundEvents?.pieceComplete !== false;
const shouldNotifyPieceAbort = shouldNotify && notificationSoundEvents?.pieceAbort !== false; const shouldNotifyPieceAbort = shouldNotify && notificationSoundEvents?.pieceAbort !== false;
const currentProvider = globalConfig.provider ?? 'claude'; const currentProvider = globalConfig.provider ?? 'claude';
const effectivePieceConfig: PieceConfig = {
...pieceConfig,
runtime: resolveRuntimeConfig(globalConfig.runtime, pieceConfig.runtime),
};
const providerEventLogger = createProviderEventLogger({ const providerEventLogger = createProviderEventLogger({
logsDir: runPaths.logsAbs, logsDir: runPaths.logsAbs,
sessionId: pieceSessionId, sessionId: pieceSessionId,
@ -424,7 +429,7 @@ export async function executePiece(
const runAbortController = new AbortController(); const runAbortController = new AbortController();
try { try {
engine = new PieceEngine(pieceConfig, cwd, task, { engine = new PieceEngine(effectivePieceConfig, cwd, task, {
abortSignal: runAbortController.signal, abortSignal: runAbortController.signal,
onStream: providerEventLogger.wrapCallback(streamHandler), onStream: providerEventLogger.wrapCallback(streamHandler),
onUserInput, onUserInput,

View File

@ -126,6 +126,9 @@ export class GlobalConfigManager {
pieceCategoriesFile: parsed.piece_categories_file, pieceCategoriesFile: parsed.piece_categories_file,
personaProviders: parsed.persona_providers, personaProviders: parsed.persona_providers,
providerOptions: normalizeProviderOptions(parsed.provider_options), 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, branchNameStrategy: parsed.branch_name_strategy,
preventSleep: parsed.prevent_sleep, preventSleep: parsed.prevent_sleep,
notificationSound: parsed.notification_sound, notificationSound: parsed.notification_sound,
@ -210,6 +213,11 @@ export class GlobalConfigManager {
if (config.personaProviders && Object.keys(config.personaProviders).length > 0) { if (config.personaProviders && Object.keys(config.personaProviders).length > 0) {
raw.persona_providers = config.personaProviders; 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) { if (config.branchNameStrategy) {
raw.branch_name_strategy = config.branchNameStrategy; raw.branch_name_strategy = config.branchNameStrategy;
} }

View File

@ -23,8 +23,10 @@ import {
} from './resource-resolver.js'; } from './resource-resolver.js';
type RawStep = z.output<typeof PieceMovementRawSchema>; type RawStep = z.output<typeof PieceMovementRawSchema>;
type RawPiece = z.output<typeof PieceConfigRawSchema>;
import type { MovementProviderOptions } from '../../../core/models/piece-types.js'; 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). */ /** Convert raw YAML provider_options (snake_case) to internal format (camelCase). */
export function normalizeProviderOptions( export function normalizeProviderOptions(
@ -81,6 +83,16 @@ export function mergeProviderOptions(
return Object.keys(result).length > 0 ? result : undefined; 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). */ /** 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 } { function isOutputContractItem(raw: unknown): raw is { name: string; order?: string; format?: string } {
return typeof raw === 'object' && raw !== null && !Array.isArray(raw) && 'name' in raw; 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 pieceProviderOptions = normalizeProviderOptions(parsed.piece_config?.provider_options as RawStep['provider_options']);
const pieceRuntime = normalizeRuntimeConfig(parsed.piece_config);
const movements: PieceMovement[] = parsed.movements.map((step) => const movements: PieceMovement[] = parsed.movements.map((step) =>
normalizeStepFromRaw(step, pieceDir, sections, pieceProviderOptions, context), normalizeStepFromRaw(step, pieceDir, sections, pieceProviderOptions, context),
@ -401,6 +414,7 @@ export function normalizePieceConfig(
name: parsed.name, name: parsed.name,
description: parsed.description, description: parsed.description,
providerOptions: pieceProviderOptions, providerOptions: pieceProviderOptions,
runtime: pieceRuntime,
personas: parsed.personas, personas: parsed.personas,
policies: resolvedPolicies, policies: resolvedPolicies,
knowledge: resolvedKnowledge, knowledge: resolvedKnowledge,