.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();
|
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(),
|
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) */
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 */
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user