.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:
parent
f733a7ebb1
commit
289a0fb912
60
src/__tests__/normalizeRuntime.test.ts
Normal file
60
src/__tests__/normalizeRuntime.test.ts
Normal 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'] });
|
||||
});
|
||||
});
|
||||
@ -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'] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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) */
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 */
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user