feat(config): add federated tier and rename team→standalone (FED-M1-01)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful

Aligns mosaic.config tier vocabulary with the federation PRD:
- Adds `federated` to the tier enum (Postgres + Valkey + pgvector)
- Renames `team` → `standalone` in StorageTier / GatewayStorageTier
- Keeps `team` as a deprecated alias that warns to stderr and maps to standalone
- Adds DEFAULT_FEDERATED_CONFIG (port 5433, pgvector memory)
- Corrects DEFAULT_STANDALONE_CONFIG.memory to keyword (PRD line 247)
- Wizard interactive promptTier offers local/standalone/federated
- Wizard headless env path now hard-rejects unknown MOSAIC_STORAGE_TIER
  values with a message naming all three valid tiers
- Interactive DATABASE_URL pre-fill is tier-aware: standalone→5432, federated→5433
- 10 new unit tests for the validator + 3 new wizard headless tests

No new behavior beyond the enum and defaults — pgvector enforcement,
tier-detector, and docker-compose overlays land in FED-M1-02..04.

Refs #460

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Jarvis
2026-04-19 18:06:37 -05:00
parent 8aabb8c5b2
commit 86edb16947
9 changed files with 288 additions and 30 deletions

View File

@@ -216,8 +216,8 @@ describe('gatewayConfigStage', () => {
expect(daemonState.startCalled).toBe(0);
});
it('honors MOSAIC_STORAGE_TIER=team in headless path', async () => {
process.env['MOSAIC_STORAGE_TIER'] = 'team';
it('honors MOSAIC_STORAGE_TIER=standalone in headless path', async () => {
process.env['MOSAIC_STORAGE_TIER'] = 'standalone';
process.env['MOSAIC_DATABASE_URL'] = 'postgresql://test/db';
process.env['MOSAIC_VALKEY_URL'] = 'redis://test:6379';
@@ -231,12 +231,75 @@ describe('gatewayConfigStage', () => {
});
expect(result.ready).toBe(true);
expect(state.gateway?.tier).toBe('team');
expect(state.gateway?.tier).toBe('standalone');
const envContents = readFileSync(daemonState.envFile, 'utf-8');
expect(envContents).toContain('DATABASE_URL=postgresql://test/db');
expect(envContents).toContain('VALKEY_URL=redis://test:6379');
const mosaicConfig = JSON.parse(readFileSync(daemonState.mosaicConfigFile, 'utf-8'));
expect(mosaicConfig.tier).toBe('team');
expect(mosaicConfig.tier).toBe('standalone');
});
it('accepts deprecated MOSAIC_STORAGE_TIER=team as alias for standalone', async () => {
process.env['MOSAIC_STORAGE_TIER'] = 'team';
process.env['MOSAIC_DATABASE_URL'] = 'postgresql://test/db';
process.env['MOSAIC_VALKEY_URL'] = 'redis://test:6379';
const p = buildPrompter();
const state = makeState('/home/user/.config/mosaic');
const result = await gatewayConfigStage(p, state, {
host: 'localhost',
defaultPort: 14242,
skipInstall: true,
});
// Deprecated alias 'team' maps to 'standalone'
expect(result.ready).toBe(true);
expect(state.gateway?.tier).toBe('standalone');
const mosaicConfig = JSON.parse(readFileSync(daemonState.mosaicConfigFile, 'utf-8'));
expect(mosaicConfig.tier).toBe('standalone');
});
it('honors MOSAIC_STORAGE_TIER=federated in headless path', async () => {
process.env['MOSAIC_STORAGE_TIER'] = 'federated';
process.env['MOSAIC_DATABASE_URL'] = 'postgresql://test/feddb';
process.env['MOSAIC_VALKEY_URL'] = 'redis://test:6379';
const p = buildPrompter();
const state = makeState('/home/user/.config/mosaic');
const result = await gatewayConfigStage(p, state, {
host: 'localhost',
defaultPort: 14242,
skipInstall: true,
});
expect(result.ready).toBe(true);
expect(state.gateway?.tier).toBe('federated');
const envContents = readFileSync(daemonState.envFile, 'utf-8');
expect(envContents).toContain('DATABASE_URL=postgresql://test/feddb');
const mosaicConfig = JSON.parse(readFileSync(daemonState.mosaicConfigFile, 'utf-8'));
expect(mosaicConfig.tier).toBe('federated');
expect(mosaicConfig.memory.type).toBe('pgvector');
});
it('rejects an unknown MOSAIC_STORAGE_TIER value in headless mode with a descriptive warning', async () => {
process.env['MOSAIC_STORAGE_TIER'] = 'federatd'; // deliberate typo
const warnFn = vi.fn();
const p = buildPrompter({ warn: warnFn });
const state = makeState('/home/user/.config/mosaic');
const result = await gatewayConfigStage(p, state, {
host: 'localhost',
defaultPort: 14242,
skipInstall: true,
});
// The stage surfaces validation errors as ready:false (warning is shown to the user).
expect(result.ready).toBe(false);
// The warning message must name all three valid values.
expect(warnFn).toHaveBeenCalledWith(expect.stringMatching(/local.*standalone.*federated/i));
});
it('regenerates config when portOverride differs from saved GATEWAY_PORT', async () => {

View File

@@ -84,10 +84,15 @@ async function promptTier(p: WizardPrompter): Promise<GatewayStorageTier> {
hint: 'embedded database, no dependencies',
},
{
value: 'team',
label: 'Team',
value: 'standalone',
label: 'Standalone',
hint: 'PostgreSQL + Valkey required',
},
{
value: 'federated',
label: 'Federated',
hint: 'PostgreSQL + Valkey + pgvector, federation server+client',
},
],
});
return tier;
@@ -437,7 +442,21 @@ async function collectAndWriteConfig(
p.log('Headless mode detected — reading configuration from environment variables.');
const storageTierEnv = process.env['MOSAIC_STORAGE_TIER'] ?? 'local';
tier = storageTierEnv === 'team' ? 'team' : 'local';
if (storageTierEnv === 'team') {
// Deprecated alias — warn and treat as standalone
process.stderr.write(
'[mosaic] DEPRECATED: MOSAIC_STORAGE_TIER=team is deprecated — use "standalone" instead.\n',
);
tier = 'standalone';
} else if (storageTierEnv === 'standalone' || storageTierEnv === 'federated') {
tier = storageTierEnv;
} else if (storageTierEnv !== '' && storageTierEnv !== 'local') {
throw new GatewayConfigValidationError(
`Invalid MOSAIC_STORAGE_TIER="${storageTierEnv}" — expected "local", "standalone", or "federated" (deprecated alias "team" also accepted)`,
);
} else {
tier = 'local';
}
const portEnv = process.env['MOSAIC_GATEWAY_PORT'];
port = portEnv ? parseInt(portEnv, 10) : opts.defaultPort;
@@ -453,13 +472,13 @@ async function collectAndWriteConfig(
hostname = hostnameEnv;
corsOrigin = corsOverride ?? deriveCorsOrigin(hostnameEnv, 3000);
if (tier === 'team') {
if (tier === 'standalone' || tier === 'federated') {
const missing: string[] = [];
if (!databaseUrl) missing.push('MOSAIC_DATABASE_URL');
if (!valkeyUrl) missing.push('MOSAIC_VALKEY_URL');
if (missing.length > 0) {
throw new GatewayConfigValidationError(
'Headless install with tier=team requires env vars: ' + missing.join(', '),
`Headless install with tier=${tier} requires env vars: ` + missing.join(', '),
);
}
}
@@ -467,11 +486,15 @@ async function collectAndWriteConfig(
tier = await promptTier(p);
port = await promptPort(p, opts.defaultPort);
if (tier === 'team') {
if (tier === 'standalone' || tier === 'federated') {
const defaultDbUrl =
tier === 'federated'
? 'postgresql://mosaic:mosaic@localhost:5433/mosaic'
: 'postgresql://mosaic:mosaic@localhost:5432/mosaic';
databaseUrl = await p.text({
message: 'DATABASE_URL',
initialValue: 'postgresql://mosaic:mosaic@localhost:5433/mosaic',
defaultValue: 'postgresql://mosaic:mosaic@localhost:5433/mosaic',
initialValue: defaultDbUrl,
defaultValue: defaultDbUrl,
});
valkeyUrl = await p.text({
message: 'VALKEY_URL',
@@ -521,7 +544,7 @@ async function collectAndWriteConfig(
`OTEL_SERVICE_NAME=mosaic-gateway`,
];
if (tier === 'team' && databaseUrl && valkeyUrl) {
if ((tier === 'standalone' || tier === 'federated') && databaseUrl && valkeyUrl) {
envLines.push(`DATABASE_URL=${databaseUrl}`);
envLines.push(`VALKEY_URL=${valkeyUrl}`);
}
@@ -545,12 +568,19 @@ async function collectAndWriteConfig(
queue: { type: 'local', dataDir: join(opts.gatewayHome, 'queue') },
memory: { type: 'keyword' },
}
: {
tier: 'team',
storage: { type: 'postgres', url: databaseUrl },
queue: { type: 'bullmq', url: valkeyUrl },
memory: { type: 'pgvector' },
};
: tier === 'federated'
? {
tier: 'federated',
storage: { type: 'postgres', url: databaseUrl },
queue: { type: 'bullmq', url: valkeyUrl },
memory: { type: 'pgvector' },
}
: {
tier: 'standalone',
storage: { type: 'postgres', url: databaseUrl },
queue: { type: 'bullmq', url: valkeyUrl },
memory: { type: 'keyword' },
};
writeFileSync(opts.mosaicConfigFile, JSON.stringify(mosaicConfig, null, 2) + '\n', {
mode: 0o600,

View File

@@ -58,7 +58,7 @@ export interface HooksState {
acceptedAt?: string;
}
export type GatewayStorageTier = 'local' | 'team';
export type GatewayStorageTier = 'local' | 'standalone' | 'federated';
export interface GatewayAdminState {
name: string;