.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();
});
});
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(),
provider_options: MovementProviderOptionsSchema,
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) */
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) */

View File

@ -1,14 +1,17 @@
/**
* 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 { MovementProviderOptions, PieceRuntimeConfig } from '../../core/models/piece-types.js';
import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js';
import type { PieceOverrides, PersonaProviderEntry, PipelineConfig } from '../../core/models/persisted-global-config.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(
raw: Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined,
): ProviderPermissionProfiles | undefined {

View File

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

View File

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

View File

@ -16,6 +16,7 @@ import {
normalizePersonaProviders,
normalizePieceOverrides,
denormalizePieceOverrides,
normalizeRuntime,
} from '../configNormalizers.js';
import { invalidateResolvedConfigCache } from '../resolutionCache.js';
import { MIGRATED_PROJECT_LOCAL_DEFAULTS } from '../migratedProjectLocalDefaults.js';
@ -101,6 +102,7 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
task_poll_interval_ms,
interactive_preview_movements,
piece_overrides,
runtime,
...rest
} = parsedConfig;
const normalizedProvider = normalizeConfigProviderReference(
@ -141,6 +143,7 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
providerOptions: normalizedProvider.providerOptions,
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),
runtime: normalizeRuntime(runtime),
};
}
@ -269,6 +272,13 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig
}
delete savePayload.pieceOverrides;
const normalizedRuntime = normalizeRuntime(config.runtime);
if (normalizedRuntime) {
savePayload.runtime = normalizedRuntime;
} else {
delete savePayload.runtime;
}
const content = stringify(savePayload, { indent: 2 });
writeFileSync(configPath, content, 'utf-8');
invalidateResolvedConfigCache(projectDir);

View File

@ -2,7 +2,7 @@
* 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 {
AnalyticsConfig,
@ -54,6 +54,8 @@ export interface ProjectLocalConfig {
providerProfiles?: ProviderPermissionProfiles;
/** Piece-level overrides (quality_gates, etc.) */
pieceOverrides?: PieceOverrides;
/** Runtime environment configuration (project-level override) */
runtime?: PieceRuntimeConfig;
}
/** Persona session data for persistence */