From 6e14cd3c38b5285bad1063c5fda304dbb6500a90 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Sun, 15 Feb 2026 05:28:39 +0900 Subject: [PATCH] feat(runtime): add configurable prepare presets and provider e2e --- builtins/en/config.yaml | 6 + builtins/ja/config.yaml | 6 + docs/testing/e2e.md | 8 + e2e/specs/runtime-config-provider.e2e.ts | 168 ++++++++++++++ package.json | 2 +- src/__tests__/globalConfig-defaults.test.ts | 35 +++ src/__tests__/models.test.ts | 24 ++ .../provider-options-piece-parser.test.ts | 23 ++ src/__tests__/runtime-environment.test.ts | 113 ++++++++++ src/core/models/global-config.ts | 4 +- src/core/models/index.ts | 3 + src/core/models/piece-types.ts | 13 ++ src/core/models/schemas.ts | 16 ++ src/core/models/types.ts | 3 + src/core/piece/engine/PieceEngine.ts | 18 ++ src/core/runtime/presets/prepare-gradle.sh | 11 + src/core/runtime/presets/prepare-node.sh | 11 + src/core/runtime/runtime-environment.ts | 208 ++++++++++++++++++ src/features/tasks/execute/pieceExecution.ts | 7 +- src/infra/config/global/globalConfig.ts | 8 + src/infra/config/loaders/pieceParser.ts | 14 ++ 21 files changed, 698 insertions(+), 3 deletions(-) create mode 100644 e2e/specs/runtime-config-provider.e2e.ts create mode 100644 src/__tests__/runtime-environment.test.ts create mode 100755 src/core/runtime/presets/prepare-gradle.sh create mode 100755 src/core/runtime/presets/prepare-node.sh create mode 100644 src/core/runtime/runtime-environment.ts diff --git a/builtins/en/config.yaml b/builtins/en/config.yaml index b23915d..c0a7b33 100644 --- a/builtins/en/config.yaml +++ b/builtins/en/config.yaml @@ -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) diff --git a/builtins/ja/config.yaml b/builtins/ja/config.yaml index 2cbf381..b864599 100644 --- a/builtins/ja/config.yaml +++ b/builtins/ja/config.yaml @@ -45,6 +45,12 @@ provider: claude # macOS のアイドルスリープを防止(デフォルト: false) # prevent_sleep: false +# 実行時ランタイム環境のデフォルト(piece_config.runtime があればそちらを優先) +# runtime: +# prepare: +# - gradle +# - node + # ── 並列実行 (takt run) ── # タスクの同時実行数(1 = 逐次実行、最大: 10) diff --git a/docs/testing/e2e.md b/docs/testing/e2e.md index b536ab0..da7eaa0 100644 --- a/docs/testing/e2e.md +++ b/docs/testing/e2e.md @@ -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 '' --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不使用の操作のみ) diff --git a/e2e/specs/runtime-config-provider.e2e.ts b/e2e/specs/runtime-config-provider.e2e.ts new file mode 100644 index 0000000..63b68f6 --- /dev/null +++ b/e2e/specs/runtime-config-provider.e2e.ts @@ -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); +}); diff --git a/package.json b/package.json index 9de0789..be822c6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/__tests__/globalConfig-defaults.test.ts b/src/__tests__/globalConfig-defaults.test.ts index d8a5ccc..d02f0ff 100644 --- a/src/__tests__/globalConfig-defaults.test.ts +++ b/src/__tests__/globalConfig-defaults.test.ts @@ -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'); diff --git a/src/__tests__/models.test.ts b/src/__tests__/models.test.ts index 1d8b423..913e90b 100644 --- a/src/__tests__/models.test.ts +++ b/src/__tests__/models.test.ts @@ -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', diff --git a/src/__tests__/provider-options-piece-parser.test.ts b/src/__tests__/provider-options-piece-parser.test.ts index 79e36b1..5972750 100644 --- a/src/__tests__/provider-options-piece-parser.test.ts +++ b/src/__tests__/provider-options-piece-parser.test.ts @@ -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', () => { diff --git a/src/__tests__/runtime-environment.test.ts b/src/__tests__/runtime-environment.test.ts new file mode 100644 index 0000000..31d6290 --- /dev/null +++ b/src/__tests__/runtime-environment.test.ts @@ -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'] }); + }); +}); diff --git a/src/core/models/global-config.ts b/src/core/models/global-config.ts index 8e90589..126e0d5 100644 --- a/src/core/models/global-config.ts +++ b/src/core/models/global-config.ts @@ -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; /** 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) */ diff --git a/src/core/models/index.ts b/src/core/models/index.ts index 9cd5116..63036c7 100644 --- a/src/core/models/index.ts +++ b/src/core/models/index.ts @@ -8,6 +8,9 @@ export type { OutputContractItem, OutputContractEntry, McpServerConfig, + RuntimePreparePreset, + RuntimePrepareEntry, + PieceRuntimeConfig, AgentResponse, SessionState, PartDefinition, diff --git a/src/core/models/piece-types.ts b/src/core/models/piece-types.ts index 8449032..5b063aa 100644 --- a/src/core/models/piece-types.ts +++ b/src/core/models/piece-types.ts @@ -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; /** Resolved policy definitions — map of name to file content (resolved at parse time) */ diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index b23ff3b..2ecd18f 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -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) */ diff --git a/src/core/models/types.ts b/src/core/models/types.ts index ce59af8..ec9b9f5 100644 --- a/src/core/models/types.ts +++ b/src/core/models/types.ts @@ -37,6 +37,9 @@ export type { OutputContractItem, OutputContractEntry, McpServerConfig, + RuntimePreparePreset, + RuntimePrepareEntry, + PieceRuntimeConfig, MovementProviderOptions, PieceMovement, ArpeggioMovementConfig, diff --git a/src/core/piece/engine/PieceEngine.ts b/src/core/piece/engine/PieceEngine.ts index b65a9de..364fcf3 100644 --- a/src/core/piece/engine/PieceEngine.ts +++ b/src/core/piece/engine/PieceEngine.ts @@ -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) { diff --git a/src/core/runtime/presets/prepare-gradle.sh b/src/core/runtime/presets/prepare-gradle.sh new file mode 100755 index 0000000..d0e2089 --- /dev/null +++ b/src/core/runtime/presets/prepare-gradle.sh @@ -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" diff --git a/src/core/runtime/presets/prepare-node.sh b/src/core/runtime/presets/prepare-node.sh new file mode 100755 index 0000000..d315153 --- /dev/null +++ b/src/core/runtime/presets/prepare-node.sh @@ -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" diff --git a/src/core/runtime/runtime-environment.ts b/src/core/runtime/runtime-environment.ts new file mode 100644 index 0000000..b00bf93 --- /dev/null +++ b/src/core/runtime/runtime-environment.ts @@ -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; +} + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const PRESET_SCRIPT_DIR = join(__dirname, 'presets'); +const PRESET_SCRIPT_MAP: Record = { + 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 { + 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 { + const env: Record = {}; + 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, +): Record { + 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 { + const env: Record = { + ...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): void { + const dirs = new Set([ + 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): 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, + }; +} diff --git a/src/features/tasks/execute/pieceExecution.ts b/src/features/tasks/execute/pieceExecution.ts index 215ad50..d8987f2 100644 --- a/src/features/tasks/execute/pieceExecution.ts +++ b/src/features/tasks/execute/pieceExecution.ts @@ -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, diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index 4d86a5c..048a4d7 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -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; } diff --git a/src/infra/config/loaders/pieceParser.ts b/src/infra/config/loaders/pieceParser.ts index 525fc04..2095ebb 100644 --- a/src/infra/config/loaders/pieceParser.ts +++ b/src/infra/config/loaders/pieceParser.ts @@ -23,8 +23,10 @@ import { } from './resource-resolver.js'; type RawStep = z.output; +type RawPiece = z.output; 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,