.takt/config.yaml(プロジェクト設定)に runtime.prepare を記述するとエラーになる (#464)

* fix: add missing runtime field to ProjectConfigSchema and unify normalizeRuntime

ProjectConfigSchema.strict() rejected the runtime key in .takt/config.yaml
because the field was never added to the project-level Zod schema. The global
schema and piece-level schema already had it, so only project-level
runtime.prepare was broken ("Unrecognized key: runtime").

- Add runtime to ProjectConfigSchema and ProjectLocalConfig type
- Handle runtime in loadProjectConfig/saveProjectConfig
- Extract normalizeRuntime() into configNormalizers.ts as shared function
- Replace duplicated normalization in globalConfigCore.ts and pieceParser.ts
- Add 9 round-trip tests for project-level runtime.prepare

* fix: remove unnecessary comments from configNormalizers.ts

* fix: replace removed piece field with verbose in runtime test

The piece field was removed from ProjectConfigSchema in PR #465.
Update the test to use verbose instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jake 2026-03-04 20:13:44 +09:00 committed by GitHub
parent f733a7ebb1
commit 289a0fb912
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 173 additions and 29 deletions

View File

@ -0,0 +1,60 @@
/**
* Unit tests for normalizeRuntime() shared normalizer function.
*
* Tests dedup, empty-to-undefined normalization, and passthrough behavior.
*/
import { describe, it, expect } from 'vitest';
import { normalizeRuntime } from '../infra/config/configNormalizers.js';
describe('normalizeRuntime', () => {
it('should return undefined when input is undefined', () => {
const result = normalizeRuntime(undefined);
expect(result).toBeUndefined();
});
it('should return undefined when prepare is not specified', () => {
const result = normalizeRuntime({});
expect(result).toBeUndefined();
});
it('should return undefined when prepare is undefined', () => {
const result = normalizeRuntime({ prepare: undefined });
expect(result).toBeUndefined();
});
it('should return undefined when prepare is empty array', () => {
const result = normalizeRuntime({ prepare: [] });
expect(result).toBeUndefined();
});
it('should pass through single preset entry', () => {
const result = normalizeRuntime({ prepare: ['node'] });
expect(result).toEqual({ prepare: ['node'] });
});
it('should pass through multiple preset entries', () => {
const result = normalizeRuntime({ prepare: ['node', 'gradle'] });
expect(result).toEqual({ prepare: ['node', 'gradle'] });
});
it('should pass through custom script paths', () => {
const result = normalizeRuntime({ prepare: ['./setup.sh'] });
expect(result).toEqual({ prepare: ['./setup.sh'] });
});
it('should deduplicate repeated entries', () => {
const result = normalizeRuntime({ prepare: ['node', 'node'] });
expect(result).toEqual({ prepare: ['node'] });
});
it('should deduplicate while preserving first-occurrence order', () => {
const result = normalizeRuntime({ prepare: ['gradle', 'node', 'gradle'] });
expect(result).toEqual({ prepare: ['gradle', 'node'] });
});
it('should handle mixed presets and custom scripts', () => {
const result = normalizeRuntime({ prepare: ['node', 'gradle', './custom-setup.sh'] });
expect(result).toEqual({ prepare: ['node', 'gradle', './custom-setup.sh'] });
});
});

View File

@ -352,4 +352,85 @@ piece_overrides:
expect(() => loadProjectConfig(testDir)).not.toThrow(); expect(() => loadProjectConfig(testDir)).not.toThrow();
}); });
}); });
describe('runtime.prepare round-trip', () => {
it('should load single preset entry', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(configPath, 'runtime:\n prepare:\n - node\n', 'utf-8');
const loaded = loadProjectConfig(testDir);
expect(loaded.runtime).toEqual({ prepare: ['node'] });
});
it('should load multiple preset entries', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(configPath, 'runtime:\n prepare:\n - node\n - gradle\n', 'utf-8');
const loaded = loadProjectConfig(testDir);
expect(loaded.runtime).toEqual({ prepare: ['node', 'gradle'] });
});
it('should load custom script paths', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(configPath, 'runtime:\n prepare:\n - ./setup.sh\n', 'utf-8');
const loaded = loadProjectConfig(testDir);
expect(loaded.runtime).toEqual({ prepare: ['./setup.sh'] });
});
it('should round-trip save and load', () => {
const config: ProjectLocalConfig = {
runtime: { prepare: ['node'] },
};
saveProjectConfig(testDir, config);
const reloaded = loadProjectConfig(testDir);
expect(reloaded.runtime).toEqual({ prepare: ['node'] });
});
it('should deduplicate entries on load', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(configPath, 'runtime:\n prepare:\n - node\n - node\n', 'utf-8');
const loaded = loadProjectConfig(testDir);
expect(loaded.runtime).toEqual({ prepare: ['node'] });
});
it('should return undefined when runtime is not specified', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(configPath, '{}\n', 'utf-8');
const loaded = loadProjectConfig(testDir);
expect(loaded.runtime).toBeUndefined();
});
it('should return undefined when prepare is empty array', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
writeFileSync(configPath, 'runtime:\n prepare: []\n', 'utf-8');
const loaded = loadProjectConfig(testDir);
expect(loaded.runtime).toBeUndefined();
});
it('should not serialize runtime when config has no runtime', () => {
const config: ProjectLocalConfig = {};
saveProjectConfig(testDir, config);
const raw = readFileSync(join(testDir, '.takt', 'config.yaml'), 'utf-8');
expect(raw).not.toContain('runtime');
});
it('should round-trip mixed presets and custom scripts', () => {
const config: ProjectLocalConfig = {
runtime: { prepare: ['node', 'gradle', './custom-setup.sh'] },
};
saveProjectConfig(testDir, config);
const reloaded = loadProjectConfig(testDir);
expect(reloaded.runtime).toEqual({ prepare: ['node', 'gradle', './custom-setup.sh'] });
});
});
}); });

View File

@ -585,6 +585,8 @@ export const ProjectConfigSchema = z.object({
minimal_output: z.boolean().optional(), minimal_output: z.boolean().optional(),
provider_options: MovementProviderOptionsSchema, provider_options: MovementProviderOptionsSchema,
provider_profiles: ProviderPermissionProfilesSchema, provider_profiles: ProviderPermissionProfilesSchema,
/** Project-level runtime environment configuration */
runtime: RuntimeConfigSchema,
/** Number of tasks to run concurrently in takt run (default from global: 1, max: 10) */ /** Number of tasks to run concurrently in takt run (default from global: 1, max: 10) */
concurrency: z.number().int().min(1).max(10).optional(), concurrency: z.number().int().min(1).max(10).optional(),
/** Polling interval in ms for picking up new tasks during takt run (default: 500, range: 100-5000) */ /** Polling interval in ms for picking up new tasks during takt run (default: 500, range: 100-5000) */

View File

@ -1,14 +1,17 @@
/** import type { MovementProviderOptions, PieceRuntimeConfig } from '../../core/models/piece-types.js';
* Shared normalizer/denormalizer functions for config snake_case <-> camelCase conversion.
*
* Used by both globalConfig.ts and projectConfig.ts.
*/
import type { MovementProviderOptions } from '../../core/models/piece-types.js';
import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js'; import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js';
import type { PieceOverrides, PersonaProviderEntry, PipelineConfig } from '../../core/models/persisted-global-config.js'; import type { PieceOverrides, PersonaProviderEntry, PipelineConfig } from '../../core/models/persisted-global-config.js';
import { validateProviderModelCompatibility } from './providerModelCompatibility.js'; import { validateProviderModelCompatibility } from './providerModelCompatibility.js';
export function normalizeRuntime(
runtime: { prepare?: string[] } | undefined,
): PieceRuntimeConfig | undefined {
if (!runtime?.prepare || runtime.prepare.length === 0) {
return undefined;
}
return { prepare: [...new Set(runtime.prepare)] };
}
export function normalizeProviderProfiles( export function normalizeProviderProfiles(
raw: Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined, raw: Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined,
): ProviderPermissionProfiles | undefined { ): ProviderPermissionProfiles | undefined {

View File

@ -12,6 +12,7 @@ import {
normalizePieceOverrides, normalizePieceOverrides,
denormalizePieceOverrides, denormalizePieceOverrides,
denormalizeProviderOptions, denormalizeProviderOptions,
normalizeRuntime,
} from '../configNormalizers.js'; } from '../configNormalizers.js';
import { getGlobalConfigPath } from '../paths.js'; import { getGlobalConfigPath } from '../paths.js';
import { applyGlobalConfigEnvOverrides } from '../env/config-env-overrides.js'; import { applyGlobalConfigEnvOverrides } from '../env/config-env-overrides.js';
@ -109,9 +110,7 @@ export class GlobalConfigManager {
pieceCategoriesFile: parsed.piece_categories_file, pieceCategoriesFile: parsed.piece_categories_file,
providerOptions: normalizedProvider.providerOptions, providerOptions: normalizedProvider.providerOptions,
providerProfiles: normalizeProviderProfiles(parsed.provider_profiles as Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined), providerProfiles: normalizeProviderProfiles(parsed.provider_profiles as Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined),
runtime: parsed.runtime?.prepare && parsed.runtime.prepare.length > 0 runtime: normalizeRuntime(parsed.runtime),
? { prepare: [...new Set(parsed.runtime.prepare)] }
: undefined,
preventSleep: parsed.prevent_sleep, preventSleep: parsed.prevent_sleep,
notificationSound: parsed.notification_sound, notificationSound: parsed.notification_sound,
notificationSoundEvents: parsed.notification_sound_events ? { notificationSoundEvents: parsed.notification_sound_events ? {
@ -230,10 +229,9 @@ export class GlobalConfigManager {
if (rawProviderProfiles && Object.keys(rawProviderProfiles).length > 0) { if (rawProviderProfiles && Object.keys(rawProviderProfiles).length > 0) {
raw.provider_profiles = rawProviderProfiles; raw.provider_profiles = rawProviderProfiles;
} }
if (config.runtime?.prepare && config.runtime.prepare.length > 0) { const normalizedRuntime = normalizeRuntime(config.runtime);
raw.runtime = { if (normalizedRuntime) {
prepare: [...new Set(config.runtime.prepare)], raw.runtime = normalizedRuntime;
};
} }
if (config.preventSleep !== undefined) { if (config.preventSleep !== undefined) {
raw.prevent_sleep = config.preventSleep; raw.prevent_sleep = config.preventSleep;

View File

@ -24,10 +24,8 @@ 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'; import { normalizeRuntime } from '../configNormalizers.js';
import type { PieceOverrides } from '../../../core/models/persisted-global-config.js'; import type { PieceOverrides } from '../../../core/models/persisted-global-config.js';
import { applyQualityGateOverrides } from './qualityGateOverrides.js'; import { applyQualityGateOverrides } from './qualityGateOverrides.js';
import { loadProjectConfig } from '../project/projectConfig.js'; import { loadProjectConfig } from '../project/projectConfig.js';
@ -54,16 +52,6 @@ function normalizeProviderReference(
); );
} }
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)],
};
}
/** /**
* Normalize the raw output_contracts field from YAML into internal format. * Normalize the raw output_contracts field from YAML into internal format.
* *
@ -399,7 +387,7 @@ export function normalizePieceConfig(
const pieceProvider = normalizedPieceProvider.provider; const pieceProvider = normalizedPieceProvider.provider;
const pieceModel = normalizedPieceProvider.model; const pieceModel = normalizedPieceProvider.model;
const pieceProviderOptions = normalizedPieceProvider.providerOptions; const pieceProviderOptions = normalizedPieceProvider.providerOptions;
const pieceRuntime = normalizeRuntimeConfig(parsed.piece_config); const pieceRuntime = normalizeRuntime(parsed.piece_config?.runtime);
const movements: PieceMovement[] = parsed.movements.map((step) => const movements: PieceMovement[] = parsed.movements.map((step) =>
normalizeStepFromRaw(step, pieceDir, sections, pieceProvider, pieceModel, pieceProviderOptions, context, projectOverrides, globalOverrides), normalizeStepFromRaw(step, pieceDir, sections, pieceProvider, pieceModel, pieceProviderOptions, context, projectOverrides, globalOverrides),

View File

@ -16,6 +16,7 @@ import {
normalizePersonaProviders, normalizePersonaProviders,
normalizePieceOverrides, normalizePieceOverrides,
denormalizePieceOverrides, denormalizePieceOverrides,
normalizeRuntime,
} from '../configNormalizers.js'; } from '../configNormalizers.js';
import { invalidateResolvedConfigCache } from '../resolutionCache.js'; import { invalidateResolvedConfigCache } from '../resolutionCache.js';
import { MIGRATED_PROJECT_LOCAL_DEFAULTS } from '../migratedProjectLocalDefaults.js'; import { MIGRATED_PROJECT_LOCAL_DEFAULTS } from '../migratedProjectLocalDefaults.js';
@ -101,6 +102,7 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
task_poll_interval_ms, task_poll_interval_ms,
interactive_preview_movements, interactive_preview_movements,
piece_overrides, piece_overrides,
runtime,
...rest ...rest
} = parsedConfig; } = parsedConfig;
const normalizedProvider = normalizeConfigProviderReference( const normalizedProvider = normalizeConfigProviderReference(
@ -141,6 +143,7 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
providerOptions: normalizedProvider.providerOptions, providerOptions: normalizedProvider.providerOptions,
providerProfiles: normalizeProviderProfiles(provider_profiles as Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined), providerProfiles: normalizeProviderProfiles(provider_profiles as Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined),
pieceOverrides: normalizePieceOverrides(piece_overrides as { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record<string, { quality_gates?: string[] }> } | undefined), pieceOverrides: normalizePieceOverrides(piece_overrides as { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record<string, { quality_gates?: string[] }> } | undefined),
runtime: normalizeRuntime(runtime),
}; };
} }
@ -269,6 +272,13 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig
} }
delete savePayload.pieceOverrides; delete savePayload.pieceOverrides;
const normalizedRuntime = normalizeRuntime(config.runtime);
if (normalizedRuntime) {
savePayload.runtime = normalizedRuntime;
} else {
delete savePayload.runtime;
}
const content = stringify(savePayload, { indent: 2 }); const content = stringify(savePayload, { indent: 2 });
writeFileSync(configPath, content, 'utf-8'); writeFileSync(configPath, content, 'utf-8');
invalidateResolvedConfigCache(projectDir); invalidateResolvedConfigCache(projectDir);

View File

@ -2,7 +2,7 @@
* Config module type definitions * Config module type definitions
*/ */
import type { MovementProviderOptions } from '../../core/models/piece-types.js'; import type { MovementProviderOptions, PieceRuntimeConfig } from '../../core/models/piece-types.js';
import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js'; import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js';
import type { import type {
AnalyticsConfig, AnalyticsConfig,
@ -54,6 +54,8 @@ export interface ProjectLocalConfig {
providerProfiles?: ProviderPermissionProfiles; providerProfiles?: ProviderPermissionProfiles;
/** Piece-level overrides (quality_gates, etc.) */ /** Piece-level overrides (quality_gates, etc.) */
pieceOverrides?: PieceOverrides; pieceOverrides?: PieceOverrides;
/** Runtime environment configuration (project-level override) */
runtime?: PieceRuntimeConfig;
} }
/** Persona session data for persistence */ /** Persona session data for persistence */