maestro/src/config-normalize.test.ts
oss-sync ee062050e0
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (3385c80)
2026-06-08 05:00:15 +00:00

575 lines
20 KiB
TypeScript

/**
* Tests for the v1 → v2 config normalizer (Step 1 of the 2026-05-21
* Settings UI / Config Restructure design).
*
* Coverage matrix:
* - explicit v2 input → pass through (no warning, no migration)
* - explicit `config_version: 99` → fatal
* - v1 provider.workers[].proxy = true → connection_type aao_gateway
* - v1 provider.workers[].proxy = false/missing → connection_type direct
* - provider.model fills missing worker.model
* - both empty → empty model + warning (not fatal)
* - storage.* mirrors of worktree_dir / custom_pieces_dir / user_folder_root /
* tools.task_upload_max_size_mb / tools.trash_retention_days
* - `${VAR}` references preserved verbatim (not coerced into literals)
* - fixture-driven snapshots for the four documented production shapes
*/
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'fs';
import { join } from 'path';
import { parse as parseYaml } from 'yaml';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import {
normalizeConfig,
UnsupportedConfigVersionError,
} from './config-normalize.js';
import { toSnakeKeys } from './config.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const FIXTURES_DIR = join(__dirname, '__fixtures__', 'config-migration');
/**
* Tiny helper: parse a YAML string and pre-camelCase keys the way loadConfig
* does. The normalizer expects camelCased keys (post-transformKeys).
*/
function loadYaml(yamlText: string): unknown {
return transformToCamel(parseYaml(yamlText));
}
function loadFixture(name: string): unknown {
return loadYaml(readFileSync(join(FIXTURES_DIR, name), 'utf-8'));
}
/** snake_case → camelCase recursive (mirror of config.ts transformKeys). */
function transformToCamel(obj: unknown): unknown {
if (Array.isArray(obj)) return obj.map(transformToCamel);
if (obj !== null && typeof obj === 'object') {
return Object.fromEntries(
Object.entries(obj as Record<string, unknown>).map(([k, v]) => [
k.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()),
transformToCamel(v),
]),
);
}
return obj;
}
describe('normalizeConfig — version handling', () => {
it('v2 input passes through with config_version=2', () => {
const out = normalizeConfig({
configVersion: 2,
llm: {
workers: [
{
id: 'w1',
connectionType: 'direct',
endpoint: 'http://localhost:11434/v1',
model: 'qwen3:32b',
roles: ['auto'],
maxConcurrency: 1,
enabled: true,
},
],
},
});
expect(out.configVersion).toBe(2);
expect(out.llm?.workers).toHaveLength(1);
expect(out.llm?.workers[0]).toMatchObject({
id: 'w1',
connectionType: 'direct',
model: 'qwen3:32b',
});
});
it('missing config_version treated as v1', () => {
const out = normalizeConfig({
provider: {
baseUrl: 'http://localhost:11434/v1',
model: 'qwen3:32b',
workers: [],
},
});
expect(out.configVersion).toBe(2);
// v1 → defaulted single worker from baseUrl
expect(out.llm?.workers).toHaveLength(1);
expect(out.llm?.workers[0]?.endpoint).toBe('http://localhost:11434/v1');
});
it('config_version: 99 throws UnsupportedConfigVersionError', () => {
expect(() => normalizeConfig({ configVersion: 99 })).toThrowError(UnsupportedConfigVersionError);
});
it('config_version: "2" (string) throws (typo guard)', () => {
expect(() => normalizeConfig({ configVersion: '2' })).toThrowError(UnsupportedConfigVersionError);
});
it('config_version: 0 throws', () => {
expect(() => normalizeConfig({ configVersion: 0 })).toThrowError(UnsupportedConfigVersionError);
});
it('null / non-object input is treated as empty config', () => {
const out = normalizeConfig(null);
expect(out.configVersion).toBe(2);
expect(out.llm?.workers).toEqual([]);
});
});
describe('normalizeConfig — v1 provider → v2 llm', () => {
it('proxy: true → connection_type: aao_gateway', () => {
const out = normalizeConfig({
provider: {
model: 'qwen3:8b',
workers: [
{
id: 'team-pool',
endpoint: 'http://litellm:4000/v1',
proxy: true,
apiKey: 'tok-abc',
},
],
},
});
expect(out.llm?.workers[0]).toMatchObject({
id: 'team-pool',
connectionType: 'aao_gateway',
endpoint: 'http://litellm:4000/v1',
apiKey: 'tok-abc',
});
});
it('proxy: false → connection_type: direct', () => {
const out = normalizeConfig({
provider: {
model: 'qwen3:8b',
workers: [
{ id: 'gpu1', endpoint: 'http://gpu1/v1', proxy: false },
],
},
});
expect(out.llm?.workers[0]?.connectionType).toBe('direct');
});
it('proxy omitted → connection_type: direct', () => {
const out = normalizeConfig({
provider: {
model: 'qwen3:8b',
workers: [{ id: 'gpu1', endpoint: 'http://gpu1/v1' }],
},
});
expect(out.llm?.workers[0]?.connectionType).toBe('direct');
});
it('worker.model empty + provider.model set → worker.model inherits', () => {
const out = normalizeConfig({
provider: {
model: 'qwen3:32b',
workers: [{ id: 'gpu1', endpoint: 'http://gpu1/v1' }],
},
});
expect(out.llm?.workers[0]?.model).toBe('qwen3:32b');
});
it('worker.model explicit overrides provider.model', () => {
const out = normalizeConfig({
provider: {
model: 'qwen3:32b',
workers: [
{ id: 'gpu1', endpoint: 'http://gpu1/v1', model: 'qwen3:14b' },
],
},
});
expect(out.llm?.workers[0]?.model).toBe('qwen3:14b');
});
it('worker.model empty + provider.model empty → empty string, no throw', () => {
expect(() =>
normalizeConfig({
provider: { workers: [{ id: 'gpu1', endpoint: 'http://gpu1/v1' }] },
}),
).not.toThrow();
const out = normalizeConfig({
provider: { workers: [{ id: 'gpu1', endpoint: 'http://gpu1/v1' }] },
});
expect(out.llm?.workers[0]?.model).toBe('');
});
it('base_url with no workers → single default worker', () => {
const out = normalizeConfig({
provider: {
baseUrl: 'http://localhost:11434/v1',
model: 'qwen3:32b',
workers: [],
},
});
expect(out.llm?.workers).toHaveLength(1);
expect(out.llm?.workers[0]).toMatchObject({
id: 'default',
endpoint: 'http://localhost:11434/v1',
model: 'qwen3:32b',
connectionType: 'direct',
});
});
it('provider.timeout_minutes / retry / metrics → llm.*', () => {
const out = normalizeConfig({
provider: {
model: 'qwen3:8b',
baseUrl: 'http://localhost:11434/v1',
timeoutMinutes: 20,
retry: {
maxAttempts: 5,
backoffMs: [1000, 2000],
retryableStatus: [429, 503],
},
metrics: { enabled: true, prefix: 'aao_worker' },
},
});
expect(out.llm?.timeoutMinutes).toBe(20);
expect(out.llm?.retry).toEqual({
maxAttempts: 5,
backoffMs: [1000, 2000],
retryableStatus: [429, 503],
});
expect(out.llm?.metrics).toEqual({ enabled: true, prefix: 'aao_worker' });
});
it('profiles: [...] → roles: [...] on v2 worker', () => {
const out = normalizeConfig({
provider: {
model: 'm',
workers: [
// Use camelCased keys (post-transformKeys)
{ id: 'g', endpoint: 'http://g/v1', profiles: ['fast'] },
],
},
});
expect(out.llm?.workers[0]?.roles).toEqual(['fast']);
});
it('roles defaults to [auto, fast, quality] when neither roles nor profiles set', () => {
const out = normalizeConfig({
provider: {
model: 'm',
workers: [{ id: 'g', endpoint: 'http://g/v1' }],
},
});
expect(out.llm?.workers[0]?.roles).toEqual(['auto', 'fast', 'quality']);
});
});
describe('normalizeConfig — storage migration', () => {
it('top-level worktree_dir → storage.worktreeDir', () => {
const out = normalizeConfig({
worktreeDir: '/data/workspaces',
provider: { workers: [{ id: 'g', endpoint: 'http://g/v1', model: 'm' }] },
});
expect(out.storage?.worktreeDir).toBe('/data/workspaces');
});
it('tools.task_upload_max_size_mb → storage.taskUploadMaxSizeMb', () => {
const out = normalizeConfig({
tools: { taskUploadMaxSizeMb: 100 },
provider: { workers: [{ id: 'g', endpoint: 'http://g/v1', model: 'm' }] },
});
expect(out.storage?.taskUploadMaxSizeMb).toBe(100);
});
it('tools.trash_retention_days → storage.trashRetentionDays', () => {
const out = normalizeConfig({
tools: { trashRetentionDays: 7 },
provider: { workers: [{ id: 'g', endpoint: 'http://g/v1', model: 'm' }] },
});
expect(out.storage?.trashRetentionDays).toBe(7);
});
it('all storage keys round-trip together', () => {
const out = normalizeConfig({
worktreeDir: '/w',
customPiecesDir: './cp',
userFolderRoot: './users',
tools: { taskUploadMaxSizeMb: 50, trashRetentionDays: 30 },
provider: { workers: [{ id: 'g', endpoint: 'http://g/v1', model: 'm' }] },
});
expect(out.storage).toEqual({
worktreeDir: '/w',
customPiecesDir: './cp',
userFolderRoot: './users',
taskUploadMaxSizeMb: 50,
trashRetentionDays: 30,
});
});
it('existing storage.* wins over legacy flat keys', () => {
const out = normalizeConfig({
worktreeDir: '/old',
storage: { worktreeDir: '/new' },
provider: { workers: [{ id: 'g', endpoint: 'http://g/v1', model: 'm' }] },
});
expect(out.storage?.worktreeDir).toBe('/new');
});
});
describe('normalizeConfig — env reference preservation', () => {
it('${VAR} in worker api_key is preserved verbatim', () => {
const out = normalizeConfig({
provider: {
model: 'm',
workers: [
{
id: 'team',
endpoint: 'http://t/v1',
proxy: true,
apiKey: '${TEAM_KEY}',
},
],
},
});
expect(out.llm?.workers[0]?.apiKey).toBe('${TEAM_KEY}');
});
it('${VAR} in provider.metrics.bearer_token is preserved', () => {
const out = normalizeConfig({
provider: {
model: 'm',
baseUrl: 'http://x/v1',
metrics: { bearerToken: '${BEARER}' },
},
});
expect(out.llm?.metrics?.bearerToken).toBe('${BEARER}');
});
it('env: prefix is preserved (legacy syntax)', () => {
const out = normalizeConfig({
provider: {
model: 'm',
baseUrl: 'http://x/v1',
metrics: { bearerToken: 'env:LEGACY_BEARER' },
},
});
expect(out.llm?.metrics?.bearerToken).toBe('env:LEGACY_BEARER');
});
});
describe('normalizeConfig — fixtures', () => {
it('v1-single-ollama.yaml normalizes to expected v2 shape', () => {
const raw = loadFixture('v1-single-ollama.yaml');
const out = normalizeConfig(raw);
expect(out.configVersion).toBe(2);
expect(out.llm?.workers).toHaveLength(1);
expect(out.llm?.workers[0]).toMatchObject({
id: 'default',
connectionType: 'direct',
endpoint: 'http://localhost:11434/v1',
model: 'qwen3:32b',
enabled: true,
maxConcurrency: 1,
});
expect(out.llm?.timeoutMinutes).toBe(10);
expect(out.llm?.retry).toEqual({
maxAttempts: 3,
backoffMs: [2000, 5000, 15000],
retryableStatus: [429, 500, 502, 503, 504],
});
expect(out.storage).toEqual({
worktreeDir: '/var/lib/agent-orchestrator/workspaces',
customPiecesDir: './custom-pieces',
userFolderRoot: './data/users',
taskUploadMaxSizeMb: 50,
trashRetentionDays: 30,
});
});
it('v1-multi-worker-with-proxy.yaml maps proxy:true to aao_gateway + keeps ${} refs', () => {
const raw = loadFixture('v1-multi-worker-with-proxy.yaml');
const out = normalizeConfig(raw);
expect(out.configVersion).toBe(2);
expect(out.llm?.timeoutMinutes).toBe(15);
expect(out.llm?.workers).toHaveLength(4);
const byId = new Map(out.llm!.workers.map(w => [w.id, w]));
expect(byId.get('gpu1')).toMatchObject({
connectionType: 'direct',
model: 'qwen3:32b', // inherits from provider.model
roles: ['auto', 'fast'],
maxConcurrency: 2,
});
expect(byId.get('gpu2')).toMatchObject({
connectionType: 'direct',
model: 'qwen3:14b', // worker override
vlm: true,
});
expect(byId.get('team-pool')).toMatchObject({
connectionType: 'aao_gateway',
apiKey: '${TEAM_A_LITELLM_KEY}', // env ref preserved literally
model: 'qwen3:8b',
maxConcurrency: 4,
roles: ['quality'],
});
expect(byId.get('gpu-reflection')).toMatchObject({
connectionType: 'direct',
roles: ['reflection'],
});
});
it('v1-gateway-server-with-keys.yaml preserves gateway.* untouched + builds llm from provider', () => {
const raw = loadFixture('v1-gateway-server-with-keys.yaml');
const out = normalizeConfig(raw);
expect(out.configVersion).toBe(2);
// provider → llm path
expect(out.llm?.workers).toHaveLength(1);
expect(out.llm?.workers[0]).toMatchObject({
id: 'default',
endpoint: 'http://localhost:11434/v1',
model: 'qwen3:8b',
connectionType: 'direct',
});
expect(out.llm?.metrics?.bearerToken).toBe('${AAO_WORKER_METRICS_BEARER_TOKEN}');
// gateway.* should pass through untouched.
const gateway = (out as unknown as { gateway?: Record<string, unknown> }).gateway;
expect(gateway).toBeDefined();
expect(gateway).toMatchObject({
enabled: true,
listenPort: 4000,
});
expect(Array.isArray(gateway?.backends)).toBe(true);
expect((gateway?.backends as unknown[])).toHaveLength(2);
const vkeys = gateway?.virtualKeys as Array<Record<string, unknown>>;
expect(vkeys[0]?.key).toBe('${TEAM_A_GATEWAY_KEY}'); // env ref kept
});
it('v1-mcp-and-ssh.yaml mirrors user_folder_root into storage + preserves mcp/ssh', () => {
const raw = loadFixture('v1-mcp-and-ssh.yaml');
const out = normalizeConfig(raw);
expect(out.configVersion).toBe(2);
expect(out.storage?.userFolderRoot).toBe('/opt/aao/data/users');
expect(out.storage?.taskUploadMaxSizeMb).toBe(100);
// mcp + ssh blocks should pass through unchanged
expect(out.mcp).toMatchObject({ enabled: true, callTimeoutSeconds: 30 });
expect(out.ssh).toMatchObject({
enabled: true,
masterKeyPath: './data/secrets/ssh-master.key',
});
// ${VAR} inside tools.knowledge_namespaces stays literal
const tools = (out as unknown as { tools?: { knowledgeNamespaces?: Record<string, { apiKey: string }> } }).tools;
expect(tools?.knowledgeNamespaces?.eng?.apiKey).toBe('${DKS_ENG_KEY}');
expect(tools?.knowledgeNamespaces?.ops?.apiKey).toBe('${DKS_OPS_KEY}');
});
});
describe('normalizeConfig — backwards compat with loadConfig', () => {
it('toSnakeKeys round-trip on normalizer output stays consumable by YAML stringifier', () => {
// Sanity guard: the v2 blocks survive snake_case conversion (used by
// /api/config write path in later steps).
const out = normalizeConfig({
provider: {
model: 'qwen3:8b',
workers: [
{ id: 'gpu1', endpoint: 'http://gpu1/v1', proxy: true, apiKey: '${K}' },
],
},
});
const snake = toSnakeKeys({
config_version: out.configVersion,
llm: out.llm,
storage: out.storage,
}) as Record<string, unknown>;
expect((snake.llm as Record<string, unknown>).workers).toBeDefined();
const workers = (snake.llm as Record<string, unknown>).workers as Array<Record<string, unknown>>;
expect(workers[0]).toMatchObject({
id: 'gpu1',
connection_type: 'aao_gateway',
api_key: '${K}',
});
});
it('mirrors storage.* back into top-level flat keys for legacy readers (2026-05-21 hotfix)', () => {
// Production aao broke when v2-only config.yaml dropped top-level
// `worktree_dir`. worker-bootstrap.ts:153/172 reads
// `config.worktreeDir` and got the default `/var/lib/...` path,
// which isn't writable on the typical deploy → EACCES on mkdir.
// The normalizer now mirrors storage.* into the legacy top-level
// keys so the compat-window readers keep working.
const out = normalizeConfig({
configVersion: 2,
llm: { workers: [{ id: 'w1', connectionType: 'direct', endpoint: 'http://x/v1', model: 'm' }] },
storage: {
worktreeDir: '/home/user/data/agent-workspaces',
customPiecesDir: '/home/user/data/pieces',
userFolderRoot: '/home/user/data/users',
taskUploadMaxSizeMb: 100,
trashRetentionDays: 45,
},
});
expect(out.worktreeDir).toBe('/home/user/data/agent-workspaces');
expect(out.customPiecesDir).toBe('/home/user/data/pieces');
expect(out.userFolderRoot).toBe('/home/user/data/users');
expect(out.tools?.taskUploadMaxSizeMb).toBe(100);
expect(out.tools?.trashRetentionDays).toBe(45);
});
it('storage.* explicit value wins over top-level value (#369 precedence fix)', () => {
// After hotfix #369: when the source v2 input has an explicit
// `storage.worktreeDir`, that value is authoritative and overrides
// any pre-existing top-level value — because the top-level is
// almost always the legacy default (`/var/lib/...`) merged in by
// loadConfig before the normalizer runs. The env override (#369)
// is re-applied AFTER normalizeConfig in loadConfig so a runtime
// WORKTREE_DIR still wins; that contract isn't tested here.
const out = normalizeConfig({
configVersion: 2,
llm: { workers: [{ id: 'w1', connectionType: 'direct', endpoint: 'http://x/v1', model: 'm' }] },
worktreeDir: '/var/lib/maestro/workspaces', // simulates merged-in default
storage: { worktreeDir: '/home/user/data/agent-workspaces' },
});
expect(out.worktreeDir).toBe('/home/user/data/agent-workspaces');
});
it('v1 top-level worktreeDir survives when no storage block was authored', () => {
// Legacy path: a v1 file with `worktree_dir` but no `storage` block
// synthesizes storage.worktreeDir from the top-level. The mirror is
// a no-op (top-level was already set) and the user's value is
// preserved on both sides.
const out = normalizeConfig({
// configVersion omitted → v1 path
provider: { workers: [{ id: 'w1', endpoint: 'http://x/v1', model: 'm' }] },
worktreeDir: '/home/op/explicit-v1-value',
});
expect(out.worktreeDir).toBe('/home/op/explicit-v1-value');
expect(out.storage?.worktreeDir).toBe('/home/op/explicit-v1-value');
});
});
describe('browser.display_mode migration (captcha_solve rename)', () => {
it('legacy captchaSolve=novnc → displayMode=novnc, captchaSolve removed', () => {
const out = normalizeConfig({ browser: { captchaSolve: 'novnc' } });
expect(out.browser?.displayMode).toBe('novnc');
expect((out.browser as Record<string, unknown>)?.captchaSolve).toBeUndefined();
});
it('legacy captchaSolve=skip → displayMode=headless, captchaSolve removed', () => {
const out = normalizeConfig({ browser: { captchaSolve: 'skip' } });
expect(out.browser?.displayMode).toBe('headless');
expect((out.browser as Record<string, unknown>)?.captchaSolve).toBeUndefined();
});
it('displayMode wins over captchaSolve when both present; captchaSolve removed', () => {
const out = normalizeConfig({ browser: { displayMode: 'novnc', captchaSolve: 'skip' } });
expect(out.browser?.displayMode).toBe('novnc');
expect((out.browser as Record<string, unknown>)?.captchaSolve).toBeUndefined();
});
it('neither key → browser block unchanged (displayMode stays undefined)', () => {
const out = normalizeConfig({ browser: { maxSessions: 3 } });
expect(out.browser?.displayMode).toBeUndefined();
expect((out.browser as Record<string, unknown>)?.captchaSolve).toBeUndefined();
expect(out.browser?.maxSessions).toBe(3);
});
});