Compare commits

..

1 Commits

Author SHA1 Message Date
Jarvis
4bf12d9990 feat(#462): add federation query source routing
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 16:03:47 -05:00
20 changed files with 678 additions and 357 deletions

View File

@@ -1,11 +1,9 @@
import { Controller, Get, Inject, Optional, UseGuards } from '@nestjs/common';
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { sql, type Db } from '@mosaicstack/db';
import { createQueue } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import { DB } from '../database/database.module.js';
import { AgentService } from '../agent/agent.service.js';
import { ProviderService } from '../agent/provider.service.js';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import { AdminGuard } from './admin.guard.js';
import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js';
@@ -16,9 +14,6 @@ export class AdminHealthController {
@Inject(DB) private readonly db: Db,
@Inject(AgentService) private readonly agentService: AgentService,
@Inject(ProviderService) private readonly providerService: ProviderService,
@Optional()
@Inject(MOSAIC_CONFIG)
private readonly mosaicConfig: MosaicConfig | null,
) {}
@Get()
@@ -60,14 +55,6 @@ export class AdminHealthController {
}
private async checkCache(): Promise<ServiceStatusDto> {
// On Local tier there is no Redis. The cache is intentionally absent, which
// is a healthy state for this tier — report 'ok' rather than opening a new
// ioredis connection on every admin health check (which would spam
// ECONNREFUSED and create/destroy a connection per request). latencyMs 0
// signals "no cache backend to measure" for this tier.
if (this.mosaicConfig?.queue?.type === 'local') {
return { status: 'ok', latencyMs: 0 };
}
const start = Date.now();
const handle = createQueue();
try {

View File

@@ -21,10 +21,7 @@ export class CommandExecutorService {
@Inject(AgentService) private readonly agentService: AgentService,
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
// On Local tier COMMANDS_REDIS is null — provider login caching is skipped.
@Optional()
@Inject(COMMANDS_REDIS)
private readonly redis: QueueHandle['redis'] | null,
@Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'],
@Inject(BRAIN) private readonly brain: Brain,
@Optional()
@Inject(forwardRef(() => ReloadService))
@@ -406,16 +403,14 @@ export class CommandExecutorService {
};
}
const pollToken = crypto.randomUUID();
const pollKey = `mosaic:auth:poll:${pollToken}`;
if (this.redis) {
// Store pending state in Valkey (TTL 5 minutes)
await this.redis.set(
pollKey,
JSON.stringify({ status: 'pending', provider: providerName, userId }),
'EX',
300,
);
}
const key = `mosaic:auth:poll:${pollToken}`;
// Store pending state in Valkey (TTL 5 minutes)
await this.redis.set(
key,
JSON.stringify({ status: 'pending', provider: providerName, userId }),
'EX',
300,
);
// In production this would construct an OAuth URL
const loginUrl = `${process.env['MOSAIC_BASE_URL'] ?? 'http://localhost:3000'}/auth/provider/${providerName}?token=${pollToken}`;
return {

View File

@@ -1,7 +1,5 @@
import { forwardRef, Inject, Module, Optional, type OnApplicationShutdown } from '@nestjs/common';
import { forwardRef, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import { ChatModule } from '../chat/chat.module.js';
import { GCModule } from '../gc/gc.module.js';
import { ReloadModule } from '../reload/reload.module.js';
@@ -16,17 +14,13 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
providers: [
{
provide: COMMANDS_QUEUE_HANDLE,
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
// On Local tier there is no Redis — skip the ioredis connection.
// CommandExecutorService falls back to no-cache for /provider login on local.
if (config?.queue?.type === 'local') return null;
useFactory: (): QueueHandle => {
return createQueue();
},
inject: [MOSAIC_CONFIG],
},
{
provide: COMMANDS_REDIS,
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
useFactory: (handle: QueueHandle) => handle.redis,
inject: [COMMANDS_QUEUE_HANDLE],
},
CommandRegistryService,
@@ -35,13 +29,9 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
exports: [CommandRegistryService, CommandExecutorService],
})
export class CommandsModule implements OnApplicationShutdown {
constructor(
@Optional()
@Inject(COMMANDS_QUEUE_HANDLE)
private readonly handle: QueueHandle | null,
) {}
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
async onApplicationShutdown(): Promise<void> {
await this.handle?.close().catch(() => {});
await this.handle.close().catch(() => {});
}
}

View File

@@ -0,0 +1,255 @@
import 'reflect-metadata';
import { describe, expect, it, vi } from 'vitest';
import type { Db } from '@mosaicstack/db';
import type { FederationListResponse } from '@mosaicstack/types';
import {
FederationClientError,
type FederationClientService,
} from '../federation-client.service.js';
import { type QuerySourceError, QuerySourceService } from '../query-source.service.js';
interface TestRow {
id: string;
title: string;
}
interface PeerRow {
id: string;
commonName: string;
endpointUrl: string | null;
clientKeyPem: string | null;
state: 'active' | 'pending' | 'suspended' | 'revoked';
}
const LOCAL_ROWS: TestRow[] = [
{ id: 'local-1', title: 'Local One' },
{ id: 'local-2', title: 'Local Two' },
];
const PEER_A: PeerRow = {
id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
commonName: 'peer-a',
endpointUrl: 'https://peer-a.example.com',
clientKeyPem: 'sealed-key-a',
state: 'active',
};
const PEER_B: PeerRow = {
id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
commonName: 'peer-b',
endpointUrl: 'https://peer-b.example.com',
clientKeyPem: 'sealed-key-b',
state: 'active',
};
const PEER_LOCALHOST: PeerRow = {
id: 'cccccccc-cccc-cccc-cccc-cccccccccccc',
commonName: 'peer-localhost',
endpointUrl: 'https://localhost:3001',
clientKeyPem: 'sealed-key-c',
state: 'active',
};
function makeDb(activePeers: PeerRow[]): Db {
const orderBy = vi.fn().mockResolvedValue(activePeers);
const where = vi.fn().mockReturnValue({ orderBy });
const from = vi.fn().mockReturnValue({ where });
const select = vi.fn().mockReturnValue({ from });
return {
select,
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
transaction: vi.fn(),
} as unknown as Db;
}
function makeFederationClient(
list: (
peerId: string,
resource: string,
request: Record<string, unknown>,
) => Promise<FederationListResponse<TestRow>>,
): FederationClientService {
return {
list: list as unknown as FederationClientService['list'],
} as FederationClientService;
}
function makeLocalResponse(rows: TestRow[] = LOCAL_ROWS): Promise<FederationListResponse<TestRow>> {
return Promise.resolve({ items: rows });
}
describe('QuerySourceService', () => {
it('routes source="local" to the local executor and tags rows as local', async () => {
const list = vi.fn(async (): Promise<FederationListResponse<TestRow>> => ({ items: [] }));
const service = new QuerySourceService(makeDb([PEER_A]), makeFederationClient(list));
const result = await service.list<TestRow>({
source: 'local',
resource: 'tasks',
request: { cursor: 'ignored-for-local-test' },
local: () => makeLocalResponse(),
});
expect(result).toEqual({
items: [
{ id: 'local-1', title: 'Local One', _source: 'local' },
{ id: 'local-2', title: 'Local Two', _source: 'local' },
],
});
expect(list).not.toHaveBeenCalled();
});
it('routes source="federated:<host>" to the matching active peer and tags rows with peer commonName', async () => {
const list = vi.fn(
async (): Promise<FederationListResponse<TestRow>> => ({
items: [{ id: 'remote-1', title: 'Remote One' }],
}),
);
const service = new QuerySourceService(makeDb([PEER_A, PEER_B]), makeFederationClient(list));
const result = await service.list<TestRow>({
source: 'federated:peer-b.example.com',
resource: 'tasks',
request: { status: 'open' },
local: () => makeLocalResponse(),
});
expect(result).toEqual({
items: [{ id: 'remote-1', title: 'Remote One', _source: 'peer-b' }],
});
expect(list).toHaveBeenCalledWith(PEER_B.id, 'tasks', { status: 'open' });
});
it('matches federated hosts by endpoint host including non-default port', async () => {
const list = vi.fn(
async (): Promise<FederationListResponse<TestRow>> => ({
items: [{ id: 'remote-port', title: 'Remote Port' }],
}),
);
const service = new QuerySourceService(makeDb([PEER_LOCALHOST]), makeFederationClient(list));
const result = await service.list<TestRow>({
source: 'federated:localhost:3001',
resource: 'tasks',
request: {},
local: () => makeLocalResponse(),
});
expect(result).toEqual({
items: [{ id: 'remote-port', title: 'Remote Port', _source: 'peer-localhost' }],
});
expect(list).toHaveBeenCalledWith(PEER_LOCALHOST.id, 'tasks', {});
});
it('fans out source="all" to local plus every active outbound peer in parallel and merges tagged rows', async () => {
const callOrder: string[] = [];
const list = vi.fn(async (peerId: string): Promise<FederationListResponse<TestRow>> => {
callOrder.push(`remote-start:${peerId}`);
await Promise.resolve();
return {
items: [{ id: `remote-${peerId.slice(0, 1)}`, title: `Remote ${peerId.slice(0, 1)}` }],
};
});
const service = new QuerySourceService(makeDb([PEER_A, PEER_B]), makeFederationClient(list));
const result = await service.list<TestRow>({
source: 'all',
resource: 'tasks',
request: { limit: 25 },
local: async () => {
callOrder.push('local-start');
await Promise.resolve();
return { items: [{ id: 'local-1', title: 'Local One' }] };
},
});
expect(result).toEqual({
items: [
{ id: 'local-1', title: 'Local One', _source: 'local' },
{ id: 'remote-a', title: 'Remote a', _source: 'peer-a' },
{ id: 'remote-b', title: 'Remote b', _source: 'peer-b' },
],
});
expect(list).toHaveBeenCalledTimes(2);
expect(callOrder).toEqual([
'local-start',
`remote-start:${PEER_A.id}`,
`remote-start:${PEER_B.id}`,
]);
});
it('marks source="all" as partial and truncated when any subquery returns a cursor', async () => {
const list = vi.fn(
async (): Promise<FederationListResponse<TestRow>> => ({
items: [{ id: 'remote-a', title: 'Remote A' }],
nextCursor: 'remote-next',
}),
);
const service = new QuerySourceService(makeDb([PEER_A]), makeFederationClient(list));
const result = await service.list<TestRow>({
source: 'all',
resource: 'tasks',
request: {},
local: () => makeLocalResponse([{ id: 'local-1', title: 'Local One' }]),
});
expect(result).toEqual({
items: [
{ id: 'local-1', title: 'Local One', _source: 'local' },
{ id: 'remote-a', title: 'Remote A', _source: 'peer-a' },
],
_partial: true,
_truncated: true,
});
});
it('returns _partial=true for source="all" when one peer fails without dropping successful sources', async () => {
const list = vi.fn(async (peerId: string): Promise<FederationListResponse<TestRow>> => {
if (peerId === PEER_B.id) {
throw new FederationClientError({
code: 'NETWORK',
message: 'peer unavailable',
peerId,
});
}
return { items: [{ id: 'remote-a', title: 'Remote A' }] };
});
const service = new QuerySourceService(makeDb([PEER_A, PEER_B]), makeFederationClient(list));
const result = await service.list<TestRow>({
source: 'all',
resource: 'tasks',
request: {},
local: () => makeLocalResponse([{ id: 'local-1', title: 'Local One' }]),
});
expect(result).toEqual({
items: [
{ id: 'local-1', title: 'Local One', _source: 'local' },
{ id: 'remote-a', title: 'Remote A', _source: 'peer-a' },
],
_partial: true,
});
});
it('throws QuerySourceError when a federated host does not match an active outbound peer', async () => {
const list = vi.fn(async (): Promise<FederationListResponse<TestRow>> => ({ items: [] }));
const service = new QuerySourceService(makeDb([PEER_A]), makeFederationClient(list));
await expect(
service.list<TestRow>({
source: 'federated:missing.example.com',
resource: 'tasks',
request: {},
local: () => makeLocalResponse(),
}),
).rejects.toMatchObject({
name: 'QuerySourceError',
code: 'PEER_NOT_FOUND',
} satisfies Partial<QuerySourceError>);
});
});

View File

@@ -11,3 +11,13 @@ export {
type FederationClientErrorCode,
type FederationClientErrorOptions,
} from './federation-client.service.js';
export {
QuerySourceService,
QuerySourceError,
type QuerySource,
type QuerySourceErrorCode,
type QuerySourceErrorOptions,
type QuerySourceListOptions,
type QuerySourceListResponse,
type LocalListExecutor,
} from './query-source.service.js';

View File

@@ -0,0 +1,261 @@
/**
* QuerySourceService — gateway query source router (FED-M3-09).
*
* Accepts the federation query-layer `source` selector and routes list-style
* reads to local storage, one federated peer, or all active outbound peers.
*
* `source: "all"` is intentionally tolerant of per-peer failures: local data
* and successful peer responses are returned, and the envelope is marked
* `_partial: true`. Local failures still reject because there is no safe local
* fallback and the gateway's own storage is expected to be authoritative.
*/
import { Inject, Injectable, Logger } from '@nestjs/common';
import { and, eq, federationPeers, isNotNull, type Db } from '@mosaicstack/db';
import {
SOURCE_LOCAL,
tagWithSource,
type FederationListResponse,
type SourceTag,
} from '@mosaicstack/types';
import { DB } from '../../database/database.module.js';
import { FederationClientService } from './federation-client.service.js';
export type QuerySource = 'local' | 'all' | `federated:${string}`;
export type QuerySourceErrorCode = 'INVALID_SOURCE' | 'PEER_NOT_FOUND';
export interface QuerySourceErrorOptions {
code: QuerySourceErrorCode;
message: string;
source: string;
}
export class QuerySourceError extends Error {
readonly code: QuerySourceErrorCode;
readonly source: string;
constructor(opts: QuerySourceErrorOptions) {
super(opts.message);
this.name = 'QuerySourceError';
this.code = opts.code;
this.source = opts.source;
}
}
export type LocalListExecutor<T extends object> = () => Promise<FederationListResponse<T> | T[]>;
export interface QuerySourceListOptions<T extends object> {
source: QuerySource;
resource: string;
request?: Record<string, unknown>;
local: LocalListExecutor<T>;
}
export type QuerySourceListResponse<T extends object> = FederationListResponse<T & SourceTag>;
interface OutboundPeer {
id: string;
commonName: string;
endpointUrl: string;
}
interface TaggedList<T extends object> {
items: Array<T & SourceTag>;
partial: boolean;
truncated: boolean;
nextCursor?: string;
}
@Injectable()
export class QuerySourceService {
private readonly logger = new Logger(QuerySourceService.name);
constructor(
@Inject(DB) private readonly db: Db,
@Inject(FederationClientService) private readonly federationClient: FederationClientService,
) {}
async list<T extends object>(
options: QuerySourceListOptions<T>,
): Promise<QuerySourceListResponse<T>> {
const request = options.request ?? {};
if (options.source === 'local') {
const local = await this.runLocal(options.local);
return this.toResponse(this.tagList(local, SOURCE_LOCAL));
}
if (options.source === 'all') {
return this.listAll(options.resource, request, options.local);
}
if (options.source.startsWith('federated:')) {
const host = options.source.slice('federated:'.length).trim();
if (!host) {
throw new QuerySourceError({
code: 'INVALID_SOURCE',
message: 'Federated source must include a host after federated:',
source: options.source,
});
}
const peer = await this.findPeerByHost(host, options.source);
const remote = await this.federationClient.list<T>(peer.id, options.resource, request);
return this.toResponse(this.tagList(remote, peer.commonName));
}
throw new QuerySourceError({
code: 'INVALID_SOURCE',
message: `Unsupported query source: ${options.source}`,
source: options.source,
});
}
private async listAll<T extends object>(
resource: string,
request: Record<string, unknown>,
local: LocalListExecutor<T>,
): Promise<QuerySourceListResponse<T>> {
const peers = await this.listActiveOutboundPeers();
const localPromise = this.runLocal(local).then((response) =>
this.tagList(response, SOURCE_LOCAL),
);
const remotePromises = peers.map(async (peer: OutboundPeer): Promise<TaggedList<T> | null> => {
try {
const response = await this.federationClient.list<T>(peer.id, resource, request);
return this.tagList(response, peer.commonName);
} catch (error: unknown) {
this.logger.warn(
`Federated query to peer ${peer.commonName} (${peer.id}) failed; returning partial all-source response: ${
error instanceof Error ? error.message : String(error)
}`,
);
return null;
}
});
const [localResult, ...remoteResults] = await Promise.all([localPromise, ...remotePromises]);
const successfulRemoteResults = remoteResults.filter(
(result: TaggedList<T> | null): result is TaggedList<T> => result !== null,
);
const allResults = [localResult, ...successfulRemoteResults];
const peerFailure = successfulRemoteResults.length !== peers.length;
return this.mergeTaggedLists(allResults, peerFailure);
}
private async runLocal<T extends object>(
local: LocalListExecutor<T>,
): Promise<FederationListResponse<T>> {
const response = await local();
if (Array.isArray(response)) {
return { items: response };
}
return response;
}
private tagList<T extends object>(
response: FederationListResponse<T>,
source: string,
): TaggedList<T> {
return {
items: tagWithSource(response.items, source),
partial: response._partial === true,
truncated: response._truncated === true || response.nextCursor !== undefined,
nextCursor: response.nextCursor,
};
}
private mergeTaggedLists<T extends object>(
lists: Array<TaggedList<T>>,
peerFailure: boolean,
): QuerySourceListResponse<T> {
const items = lists.flatMap((list: TaggedList<T>) => list.items);
const partial =
peerFailure ||
lists.some((list: TaggedList<T>) => list.partial || list.nextCursor !== undefined);
const truncated = lists.some((list: TaggedList<T>) => list.truncated);
const response: QuerySourceListResponse<T> = { items };
if (partial) {
response._partial = true;
}
if (truncated) {
response._truncated = true;
}
return response;
}
private toResponse<T extends object>(tagged: TaggedList<T>): QuerySourceListResponse<T> {
const response: QuerySourceListResponse<T> = {
items: tagged.items,
};
if (tagged.nextCursor !== undefined) {
response.nextCursor = tagged.nextCursor;
}
if (tagged.partial) {
response._partial = true;
}
if (tagged.truncated) {
response._truncated = true;
}
return response;
}
private async findPeerByHost(sourceHost: string, source: string): Promise<OutboundPeer> {
const host = normalizeHost(sourceHost);
const peers = await this.listActiveOutboundPeers();
const peer = peers.find((candidate: OutboundPeer) => {
const commonName = normalizeHost(candidate.commonName);
const endpointHosts = endpointHostKeys(candidate.endpointUrl).map((endpointHost: string) =>
normalizeHost(endpointHost),
);
return commonName === host || endpointHosts.includes(host);
});
if (!peer) {
throw new QuerySourceError({
code: 'PEER_NOT_FOUND',
message: `No active outbound federation peer matches source ${source}`,
source,
});
}
return peer;
}
private async listActiveOutboundPeers(): Promise<OutboundPeer[]> {
const rows = await this.db
.select({
id: federationPeers.id,
commonName: federationPeers.commonName,
endpointUrl: federationPeers.endpointUrl,
})
.from(federationPeers)
.where(
and(
eq(federationPeers.state, 'active'),
isNotNull(federationPeers.endpointUrl),
isNotNull(federationPeers.clientKeyPem),
),
)
.orderBy(federationPeers.commonName);
return rows.filter((row): row is OutboundPeer => typeof row.endpointUrl === 'string');
}
}
function normalizeHost(host: string): string {
return host.trim().toLowerCase();
}
function endpointHostKeys(endpointUrl: string): string[] {
try {
const url = new URL(endpointUrl);
return Array.from(new Set([url.host, url.hostname].filter((host: string) => host.length > 0)));
} catch {
return [];
}
}

View File

@@ -5,7 +5,7 @@ import { EnrollmentController } from './enrollment.controller.js';
import { EnrollmentService } from './enrollment.service.js';
import { FederationController } from './federation.controller.js';
import { GrantsService } from './grants.service.js';
import { FederationClientService } from './client/index.js';
import { FederationClientService, QuerySourceService } from './client/index.js';
import { FederationAuthGuard } from './server/index.js';
@Module({
@@ -16,6 +16,7 @@ import { FederationAuthGuard } from './server/index.js';
EnrollmentService,
GrantsService,
FederationClientService,
QuerySourceService,
FederationAuthGuard,
],
exports: [
@@ -23,6 +24,7 @@ import { FederationAuthGuard } from './server/index.js';
EnrollmentService,
GrantsService,
FederationClientService,
QuerySourceService,
FederationAuthGuard,
],
})

View File

@@ -1,7 +1,5 @@
import { Module, type OnApplicationShutdown, Inject, Optional } from '@nestjs/common';
import { Module, type OnApplicationShutdown, Inject } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import { SessionGCService } from './session-gc.service.js';
import { REDIS } from './gc.tokens.js';
@@ -11,17 +9,13 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
providers: [
{
provide: GC_QUEUE_HANDLE,
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
// On Local tier there is no Redis — skip the ioredis connection entirely.
// The Valkey GC sweep is a no-op on Local (no session keys stored there).
if (config?.queue?.type === 'local') return null;
useFactory: (): QueueHandle => {
return createQueue();
},
inject: [MOSAIC_CONFIG],
},
{
provide: REDIS,
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
useFactory: (handle: QueueHandle) => handle.redis,
inject: [GC_QUEUE_HANDLE],
},
SessionGCService,
@@ -29,13 +23,9 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
exports: [SessionGCService],
})
export class GCModule implements OnApplicationShutdown {
constructor(
@Optional()
@Inject(GC_QUEUE_HANDLE)
private readonly handle: QueueHandle | null,
) {}
constructor(@Inject(GC_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
async onApplicationShutdown(): Promise<void> {
await this.handle?.close().catch(() => {});
await this.handle.close().catch(() => {});
}
}

View File

@@ -1,4 +1,4 @@
import { Inject, Injectable, Logger, Optional, type OnModuleInit } from '@nestjs/common';
import { Inject, Injectable, Logger, type OnModuleInit } from '@nestjs/common';
import type { QueueHandle } from '@mosaicstack/queue';
import type { LogService } from '@mosaicstack/log';
import { LOG_SERVICE } from '../log/log.tokens.js';
@@ -32,21 +32,11 @@ export class SessionGCService implements OnModuleInit {
private readonly logger = new Logger(SessionGCService.name);
constructor(
// On Local tier there is no Redis — the GC module provides null for this token.
// NOTE: if a future feature stores Redis-backed state on Local tier, this guard
// would silently skip GC for those keys. Revisit when that happens.
@Optional()
@Inject(REDIS)
private readonly redis: QueueHandle['redis'] | null,
@Inject(REDIS) private readonly redis: QueueHandle['redis'],
@Inject(LOG_SERVICE) private readonly logService: LogService,
) {}
onModuleInit(): void {
if (!this.redis) {
// Local tier: no Valkey — skip cold-start GC entirely (correct no-op).
this.logger.log('SessionGCService: Valkey GC skipped on local tier (no Redis configured)');
return;
}
// Fire-and-forget: run full GC asynchronously so it does not block the
// NestJS bootstrap chain. Cold-start GC typically takes 100500 ms
// depending on Valkey key count; deferring it removes that latency from
@@ -70,10 +60,8 @@ export class SessionGCService implements OnModuleInit {
* Scan Valkey for all keys matching a pattern using SCAN (non-blocking).
* KEYS is avoided because it blocks the Valkey event loop for the full scan
* duration, which can cause latency spikes under production key volumes.
* Returns empty array when Redis is not available (Local tier).
*/
private async scanKeys(pattern: string): Promise<string[]> {
if (!this.redis) return [];
const collected: string[] = [];
let cursor = '0';
do {
@@ -90,14 +78,12 @@ export class SessionGCService implements OnModuleInit {
async collect(sessionId: string): Promise<GCResult> {
const result: GCResult = { sessionId, cleaned: {} };
// 1. Valkey: delete all session-scoped keys (skipped on Local tier)
if (this.redis) {
const pattern = `mosaic:session:${sessionId}:*`;
const valkeyKeys = await this.scanKeys(pattern);
if (valkeyKeys.length > 0) {
await this.redis.del(...valkeyKeys);
result.cleaned.valkeyKeys = valkeyKeys.length;
}
// 1. Valkey: delete all session-scoped keys
const pattern = `mosaic:session:${sessionId}:*`;
const valkeyKeys = await this.scanKeys(pattern);
if (valkeyKeys.length > 0) {
await this.redis.del(...valkeyKeys);
result.cleaned.valkeyKeys = valkeyKeys.length;
}
// 2. PG: demote hot-tier agent_logs for this session to warm
@@ -120,7 +106,6 @@ export class SessionGCService implements OnModuleInit {
const cleaned: GCResult[] = [];
// 1. Find all session-scoped Valkey keys (non-blocking SCAN)
// Returns empty on Local tier — no Valkey session keys exist there.
const allSessionKeys = await this.scanKeys('mosaic:session:*');
// Extract unique session IDs from keys
@@ -151,15 +136,11 @@ export class SessionGCService implements OnModuleInit {
*/
async fullCollect(): Promise<FullGCResult> {
const start = Date.now();
let valkeyKeysCount = 0;
if (this.redis) {
// 1. Valkey: delete ALL session-scoped keys (non-blocking SCAN)
const sessionKeys = await this.scanKeys('mosaic:session:*');
if (sessionKeys.length > 0) {
await this.redis.del(...sessionKeys);
}
valkeyKeysCount = sessionKeys.length;
// 1. Valkey: delete ALL session-scoped keys (non-blocking SCAN)
const sessionKeys = await this.scanKeys('mosaic:session:*');
if (sessionKeys.length > 0) {
await this.redis.del(...sessionKeys);
}
// 2. NOTE: channel keys are NOT collected on cold start
@@ -173,7 +154,7 @@ export class SessionGCService implements OnModuleInit {
const jobsPurged = 0;
return {
valkeyKeys: valkeyKeysCount,
valkeyKeys: sessionKeys.length,
logsDemoted,
jobsPurged,
tempFilesRemoved: 0,

View File

@@ -19,7 +19,7 @@ import type { MosaicJobData } from '../queue/queue.service.js';
@Injectable()
export class CronService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(CronService.name);
private readonly registeredWorkers: Array<Worker<MosaicJobData>> = [];
private readonly registeredWorkers: Worker<MosaicJobData>[] = [];
constructor(
@Inject(SummarizationService) private readonly summarization: SummarizationService,
@@ -28,16 +28,6 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
) {}
async onModuleInit(): Promise<void> {
// On Local tier BullMQ is disabled — skip all job scheduling.
// NOTE: this means summarization, tier management, and Valkey GC jobs do not
// run on Local installs. For a single-user local install this is acceptable.
// If periodic background work is needed on Local in the future, add a
// setInterval-based scheduler here.
if (!this.queueService.isEnabled()) {
this.logger.log('CronService: BullMQ disabled on local tier — no jobs will be scheduled');
return;
}
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
const gcSchedule = process.env['SESSION_GC_CRON'] ?? '0 4 * * *'; // daily at 4am
@@ -52,7 +42,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
const summarizationWorker = this.queueService.registerWorker(QUEUE_SUMMARIZATION, async () => {
await this.summarization.runSummarization();
});
if (summarizationWorker) this.registeredWorkers.push(summarizationWorker);
this.registeredWorkers.push(summarizationWorker);
// M6-005: Tier management repeatable job
await this.queueService.addRepeatableJob(
@@ -64,14 +54,14 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => {
await this.summarization.runTierManagement();
});
if (tierWorker) this.registeredWorkers.push(tierWorker);
this.registeredWorkers.push(tierWorker);
// M6-004: GC repeatable job
await this.queueService.addRepeatableJob(QUEUE_GC, 'session-gc', {}, gcSchedule);
const gcWorker = this.queueService.registerWorker(QUEUE_GC, async () => {
await this.sessionGC.sweepOrphans();
});
if (gcWorker) this.registeredWorkers.push(gcWorker);
this.registeredWorkers.push(gcWorker);
this.logger.log(
`BullMQ jobs scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}", gc="${gcSchedule}"`,

View File

@@ -1,7 +1,5 @@
import { Inject, Injectable, Logger, Optional, type OnApplicationShutdown } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import { MOSAIC_CONFIG } from '../config/config.module.js';
const SESSION_SYSTEM_KEY = (sessionId: string) => `mosaic:session:${sessionId}:system`;
const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string) =>
@@ -13,54 +11,16 @@ interface OverrideFragment {
addedAt: number;
}
interface LocalOverrideEntry {
condensed: string;
fragments: OverrideFragment[];
}
@Injectable()
export class SystemOverrideService implements OnApplicationShutdown {
export class SystemOverrideService {
private readonly logger = new Logger(SystemOverrideService.name);
private readonly handle: QueueHandle | null;
/**
* In-memory fallback used on Local tier (no Redis).
* NOTE: state is ephemeral — lost on restart. For Local single-user installs
* this is acceptable; system overrides are re-applied at the next session.
* This is a deliberate behavior change from the Redis-backed 7-day TTL.
*/
private readonly localStore = new Map<string, LocalOverrideEntry>();
private readonly handle: QueueHandle;
constructor(
@Optional()
@Inject(MOSAIC_CONFIG)
private readonly mosaicConfig: MosaicConfig | null,
) {
if (this.mosaicConfig?.queue?.type === 'local') {
this.handle = null;
} else {
this.handle = createQueue();
}
}
async onApplicationShutdown(): Promise<void> {
// On non-local tiers the constructor opens an ioredis connection; close it
// on graceful shutdown to avoid leaking the handle (local tier is null).
await this.handle?.close().catch(() => {});
constructor() {
this.handle = createQueue();
}
async set(sessionId: string, override: string): Promise<void> {
if (!this.handle) {
// Local tier: in-memory path
const entry = this.localStore.get(sessionId) ?? { condensed: '', fragments: [] };
entry.fragments.push({ text: override, addedAt: Date.now() });
entry.condensed = await this.condenseOverrides(entry.fragments.map((f) => f.text));
this.localStore.set(sessionId, entry);
this.logger.debug(
`Set system override for session ${sessionId} (local, ${entry.fragments.length} fragment(s))`,
);
return;
}
// Load existing fragments
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId));
const fragments: OverrideFragment[] = existing
@@ -90,17 +50,10 @@ export class SystemOverrideService implements OnApplicationShutdown {
}
async get(sessionId: string): Promise<string | null> {
if (!this.handle) {
return this.localStore.get(sessionId)?.condensed ?? null;
}
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId));
}
async renew(sessionId: string): Promise<void> {
if (!this.handle) {
// Local tier: no TTL to renew; entry persists until restart
return;
}
const pipeline = this.handle.redis.pipeline();
pipeline.expire(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS);
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS);
@@ -108,11 +61,6 @@ export class SystemOverrideService implements OnApplicationShutdown {
}
async clear(sessionId: string): Promise<void> {
if (!this.handle) {
this.localStore.delete(sessionId);
this.logger.debug(`Cleared system override for session ${sessionId} (local)`);
return;
}
await this.handle.redis.del(
SESSION_SYSTEM_KEY(sessionId),
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId),

View File

@@ -8,9 +8,7 @@ import {
} from '@nestjs/common';
import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq';
import type { LogService } from '@mosaicstack/log';
import type { MosaicConfig } from '@mosaicstack/config';
import { LOG_SERVICE } from '../log/log.tokens.js';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import type { JobDto, JobStatus } from './queue-admin.dto.js';
// ---------------------------------------------------------------------------
@@ -110,42 +108,21 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
private readonly connection: ConnectionOptions;
private readonly queues = new Map<string, Queue<MosaicJobData>>();
private readonly workers = new Map<string, Worker<MosaicJobData>>();
/** False on Local tier — BullMQ/Redis operations become no-ops. */
private readonly enabled: boolean;
constructor(
@Optional()
@Inject(LOG_SERVICE)
private readonly logService: LogService | null,
@Optional()
@Inject(MOSAIC_CONFIG)
private readonly mosaicConfig: MosaicConfig | null,
) {
this.enabled = this.mosaicConfig?.queue?.type !== 'local';
this.connection = this.enabled
? getConnection()
: ({ host: '127.0.0.1', port: 6380 } as ConnectionOptions);
}
/** Returns true when BullMQ/Redis is active (Standalone and Federated tiers). */
isEnabled(): boolean {
return this.enabled;
this.connection = getConnection();
}
onModuleInit(): void {
if (this.enabled) {
this.logger.log('QueueService initialised (BullMQ)');
} else {
this.logger.log(
'QueueService: BullMQ disabled for local tier — no Redis connections will be opened',
);
}
this.logger.log('QueueService initialised (BullMQ)');
}
async onModuleDestroy(): Promise<void> {
if (this.enabled) {
await this.closeAll();
}
await this.closeAll();
}
// -------------------------------------------------------------------------
@@ -154,10 +131,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/**
* Get or create a BullMQ Queue for the given queue name.
* Returns null on Local tier where BullMQ is disabled.
*/
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> | null {
if (!this.enabled) return null;
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> {
let queue = this.queues.get(name) as Queue<T> | undefined;
if (!queue) {
queue = new Queue<T>(name, { connection: this.connection });
@@ -169,7 +144,6 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/**
* Add a BullMQ repeatable job (cron-style).
* Uses `jobId` as a deterministic key so duplicate registrations are idempotent.
* No-op on Local tier.
*/
async addRepeatableJob<T extends MosaicJobData>(
queueName: string,
@@ -177,13 +151,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
data: T,
cronExpression: string,
): Promise<void> {
if (!this.enabled) {
this.logger.debug(
`Skipping repeatable job "${jobName}" on "${queueName}" (local tier — BullMQ disabled)`,
);
return;
}
const queue = this.getQueue<T>(queueName)!;
const queue = this.getQueue<T>(queueName);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (queue as Queue<any>).add(jobName, data, {
repeat: { pattern: cronExpression },
@@ -197,18 +165,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/**
* Register a Worker for the given queue name with error handling and
* exponential backoff.
* Returns null on Local tier where BullMQ is disabled.
*/
registerWorker<T extends MosaicJobData>(
queueName: string,
handler: JobHandler<T>,
): Worker<T> | null {
if (!this.enabled) {
this.logger.debug(
`Skipping worker registration for "${queueName}" (local tier — BullMQ disabled)`,
);
return null;
}
registerWorker<T extends MosaicJobData>(queueName: string, handler: JobHandler<T>): Worker<T> {
const worker = new Worker<T>(
queueName,
async (job) => {
@@ -265,12 +223,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/**
* Return queue health statistics for all managed queues.
* Returns an empty healthy result on Local tier.
*/
async getHealthStatus(): Promise<QueueHealthStatus> {
if (!this.enabled) {
return { queues: {}, healthy: true };
}
const queues: QueueHealthStatus['queues'] = {};
let healthy = true;
@@ -301,10 +255,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/**
* List jobs across all managed queues, optionally filtered by status.
* BullMQ jobs are fetched by state type from each queue.
* Returns empty array on Local tier.
*/
async listJobs(status?: JobStatus): Promise<JobDto[]> {
if (!this.enabled) return [];
const jobs: JobDto[] = [];
const states: JobStatus[] = status
? [status]
@@ -331,10 +283,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* Retry a specific failed job by its BullMQ job ID (format: "queueName:id").
* The caller passes "<queueName>__<jobId>" as the composite ID because BullMQ
* job IDs are not globally unique — they are scoped to their queue.
* Returns an error on Local tier.
*/
async retryJob(compositeId: string): Promise<{ ok: boolean; message: string }> {
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
const sep = compositeId.lastIndexOf('__');
if (sep === -1) {
return { ok: false, message: 'Invalid job id format. Expected "<queue>__<jobId>".' };
@@ -366,7 +316,6 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* Pause a queue by name.
*/
async pauseQueue(name: string): Promise<{ ok: boolean; message: string }> {
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
const queue = this.queues.get(name);
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
await queue.pause();
@@ -378,7 +327,6 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* Resume a paused queue by name.
*/
async resumeQueue(name: string): Promise<{ ok: boolean; message: string }> {
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
const queue = this.queues.get(name);
if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
await queue.resume();

View File

@@ -0,0 +1,67 @@
# FED-M3-09 — Query Source Service Scratchpad
## Objective
Implement `apps/gateway/src/federation/client/query-source.service.ts` for `source: "local" | "federated:<host>" | "all"` routing.
## Scope
- Add QuerySourceService in gateway federation client layer.
- Unit-test local-only, single federated peer, all-source fan-out/merge, and per-peer partial failures.
- Keep `docs/federation/TASKS.md` read-only per project agent guidance.
## Constraints / assumptions
- Issue: #462.
- Branch: `feat/federation-m3-query-source` from `origin/main` (`e0e7be70`).
- ASSUMPTION: `federated:<host>` should match active outbound peers by `commonName` first and by `endpointUrl` host/hostname as compatibility fallback; source tags use `peer.commonName` per `@mosaicstack/types` source-tag docs.
- ASSUMPTION: QuerySourceService provides list/fan-out behavior; get/source routing can be layered later because card acceptance says merge rows.
- ASSUMPTION: `source: "all"` cannot safely return a single continuation cursor for multiple sub-sources; any subquery cursor marks the merged response `_partial: true` + `_truncated: true` while omitting `nextCursor`.
- Budget: no explicit hard cap from orchestrator; working cap ~8K-12K tokens for card 1 implementation + tests + PR cycle.
- OpenBrain unavailable: credential loader failed with missing `/home/jarvis/.config/mosaic/credentials.json`; not blocking code delivery.
## Plan
1. Review federation client/types/db patterns.
2. Write unit tests for source behavior.
3. Implement QuerySourceService and export/register it in FederationModule.
4. Run scoped tests, typecheck, lint, format.
5. Run codex uncommitted review and remediate.
6. Commit, queue guard, push, PR via wrapper.
## Progress
- 2026-06-24: Intake complete; using isolated worktree to avoid dirty orchestrator files in original checkout.
- 2026-06-24: Added QuerySourceService, module export, barrel export, and 7 unit tests.
- 2026-06-24: First Codex review found pagination and port-host matching issues; both remediated with tests.
## Tests run
- `pnpm --filter @mosaicstack/gateway test -- query-source.service.spec.ts` — PASS (7 tests).
- `pnpm --filter @mosaicstack/gateway typecheck` — PASS.
- `pnpm --filter @mosaicstack/gateway lint` — PASS.
- `pnpm format:check` — PASS.
- `pnpm typecheck` — PASS (41/41 turbo tasks).
- `pnpm lint` — PASS (23/23 turbo tasks).
- `pnpm test` — FAIL in pre-existing/live-DB integration suite: `apps/gateway/src/__tests__/cross-user-isolation.test.ts` cleanup hit `relation "messages" does not exist` against local PostgreSQL. Changed QuerySource unit tests passed; failure is outside FED-M3-09 surface and appears tied to local DB schema state.
## Review evidence
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — first pass request-changes, 2 should-fix findings (all-source cursor handling; endpoint port host matching).
- Remediation: `_partial` + `_truncated` when any all-source subquery has `nextCursor`; endpoint match accepts URL `host` and `hostname`; added tests for both.
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — PASS/approve, no findings.
- `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — PASS, risk level none, no findings.
## Risks / blockers
- Federation query layer is not yet wired; service API needs to be stable and easy to compose.
- Must avoid hard-failing `source: all` on remote peer failures.
## Acceptance evidence mapping
| Acceptance criterion | Evidence |
| ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| local source returns local rows tagged `_source: local` | `query-source.service.spec.ts` local test; scoped test PASS |
| `federated:<host>` queries selected peer and tags rows with peer source | `query-source.service.spec.ts` commonName/endpoint-host tests; scoped test PASS |
| `all` fans out local + active outbound peers in parallel and merges tagged rows | `query-source.service.spec.ts` all-source call-order/merge test; scoped test PASS |
| per-peer failure on `all` returns `_partial: true`, not throw | `query-source.service.spec.ts` peer failure test; scoped test PASS |

View File

@@ -69,8 +69,6 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
const prompter = new HeadlessPrompter({
'Installation mode': 'quick',
'Select your LLM provider': 'anthropic',
'Anthropic API key': 'sk-ant-api03-test',
'What name should agents use?': 'TestBot',
'Communication style': 'direct',
'Your name': 'Tester',
@@ -105,8 +103,6 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
const prompter = new HeadlessPrompter({
'Installation mode': 'quick',
'Select your LLM provider': 'anthropic',
'Anthropic API key': 'sk-ant-api03-test',
'What name should agents use?': 'TestBot',
'Communication style': 'direct',
'Your name': 'Tester',
@@ -129,8 +125,6 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
it('respects skipGateway: true', async () => {
const prompter = new HeadlessPrompter({
'Installation mode': 'quick',
'Select your LLM provider': 'anthropic',
'Anthropic API key': 'sk-ant-api03-test',
'What name should agents use?': 'TestBot',
'Communication style': 'direct',
'Your name': 'Tester',

View File

@@ -1,7 +1,7 @@
export class WizardCancelledError extends Error {
override name = 'WizardCancelledError';
constructor(message = 'Wizard cancelled by user') {
super(message);
constructor() {
super('Wizard cancelled by user');
}
}

View File

@@ -207,16 +207,9 @@ export async function finalizeStage(
: 'none selected'
: `install failed — ${skillsResult.failureReason ?? 'unknown error'}`;
const providerConfigured =
state.providerType && state.providerType !== 'none' && state.providerKey;
const providerSummary = providerConfigured
? `Provider: ${state.providerType} (configured)`
: 'Provider: NONE — agent has no brain';
const summary: string[] = [
`Agent: ${state.soul.agentName ?? 'Assistant'}`,
`Style: ${state.soul.communicationStyle ?? 'direct'}`,
providerSummary,
`Runtimes: ${state.runtimes.detected.join(', ') || 'none detected'}`,
`Skills: ${skillsSummary}`,
`Config: ${state.mosaicHome}`,
@@ -246,12 +239,5 @@ export async function finalizeStage(
p.note(nextSteps.map((s, i) => `${(i + 1).toString()}. ${s}`).join('\n'), 'Next Steps');
if (!providerConfigured) {
p.warn(
'Installation complete, but no LLM provider is configured. ' +
'Run `mosaic wizard` or `mosaic gateway install` to add an API key before using the agent.',
);
} else {
p.outro('Mosaic is ready.');
}
p.outro('Mosaic is ready.');
}

View File

@@ -294,12 +294,7 @@ export async function gatewayConfigStage(
}
// Install the gateway npm package on first install or after failure.
// MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1 forces a skip even without opts.skipInstall:
// used by dev/offline installs where @mosaicstack/gateway is already present
// globally (e.g. a build-from-source `install.sh --dev`) and must not be
// overwritten by the registry @latest build.
const skipNpmInstall = opts.skipInstall || process.env['MOSAIC_GATEWAY_SKIP_NPM_INSTALL'] === '1';
if (!skipNpmInstall && !daemonRunning) {
if (!opts.skipInstall && !daemonRunning) {
installGatewayPackage();
}

View File

@@ -78,7 +78,7 @@ describe('providerSetupStage', () => {
expect(state.providerType).toBe('none');
});
it('prompts for provider then key in interactive mode', async () => {
it('prompts for key in interactive mode', async () => {
delete process.env['MOSAIC_ASSUME_YES'];
// Simulate a TTY
const origIsTTY = process.stdin.isTTY;
@@ -86,13 +86,11 @@ describe('providerSetupStage', () => {
const state = makeState();
const p = buildPrompter({
select: vi.fn().mockResolvedValue('anthropic'),
text: vi.fn().mockResolvedValue('sk-ant-api03-interactive'),
});
await providerSetupStage(p, state);
expect(p.select).toHaveBeenCalled();
expect(p.text).toHaveBeenCalled();
expect(state.providerKey).toBe('sk-ant-api03-interactive');
expect(state.providerType).toBe('anthropic');
@@ -100,57 +98,20 @@ describe('providerSetupStage', () => {
Object.defineProperty(process.stdin, 'isTTY', { value: origIsTTY, configurable: true });
});
it('rejects empty and mismatched keys via the validate callback (Anthropic)', async () => {
it('handles empty key in interactive mode', async () => {
delete process.env['MOSAIC_ASSUME_YES'];
const origIsTTY = process.stdin.isTTY;
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
let capturedValidate: ((v: string) => string | void) | undefined;
const state = makeState();
const p = buildPrompter({
select: vi.fn().mockResolvedValue('anthropic'),
text: vi
.fn()
.mockImplementation(async (opts: { validate?: (v: string) => string | void }) => {
capturedValidate = opts.validate;
return 'sk-ant-api03-ok';
}),
text: vi.fn().mockResolvedValue(''),
});
await providerSetupStage(p, state);
expect(capturedValidate).toBeDefined();
expect(capturedValidate?.('')).toBe('API key is required');
expect(capturedValidate?.(' ')).toBe('API key is required');
expect(capturedValidate?.('not-a-key')).toBe('Anthropic keys start with sk-ant-');
expect(capturedValidate?.('sk-ant-valid')).toBeUndefined();
expect(state.providerType).toBe('anthropic');
Object.defineProperty(process.stdin, 'isTTY', { value: origIsTTY, configurable: true });
});
it('rejects an Anthropic key when OpenAI is selected', async () => {
delete process.env['MOSAIC_ASSUME_YES'];
const origIsTTY = process.stdin.isTTY;
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
let capturedValidate: ((v: string) => string | void) | undefined;
const state = makeState();
const p = buildPrompter({
select: vi.fn().mockResolvedValue('openai'),
text: vi
.fn()
.mockImplementation(async (opts: { validate?: (v: string) => string | void }) => {
capturedValidate = opts.validate;
return 'sk-proj-ok';
}),
});
await providerSetupStage(p, state);
expect(capturedValidate?.('sk-ant-api03-xyz')).toBe('OpenAI keys start with sk- (not sk-ant-)');
expect(capturedValidate?.('sk-proj-xyz')).toBeUndefined();
expect(state.providerType).toBe('openai');
expect(state.providerType).toBe('none');
expect(state.providerKey).toBeUndefined();
Object.defineProperty(process.stdin, 'isTTY', { value: origIsTTY, configurable: true });
});

View File

@@ -1,13 +1,12 @@
import type { WizardPrompter } from '../prompter/interface.js';
import type { WizardState } from '../types.js';
import type { ProviderType } from '../types.js';
import { detectProviderType } from '../constants.js';
/**
* Provider setup stage — collects the user's LLM API key and validates the
* Provider setup stage — collects the user's LLM API key and detects the
* provider type from the key prefix.
*
* In headless mode, reads from `MOSAIC_ANTHROPIC_API_KEY` or `MOSAIC_OPENAI_API_KEY`.
* Interactive mode requires the user to select a provider and enter a valid key.
*/
export async function providerSetupStage(p: WizardPrompter, state: WizardState): Promise<void> {
const isHeadless = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
@@ -17,57 +16,39 @@ export async function providerSetupStage(p: WizardPrompter, state: WizardState):
const openaiKey = process.env['MOSAIC_OPENAI_API_KEY'] ?? '';
const key = anthropicKey || openaiKey;
state.providerKey = key || undefined;
if (anthropicKey) {
state.providerType = 'anthropic';
} else if (openaiKey) {
state.providerType = 'openai';
} else {
state.providerType = 'none';
p.warn(
'No API key found (MOSAIC_ANTHROPIC_API_KEY / MOSAIC_OPENAI_API_KEY). ' +
'Run `mosaic gateway install` to configure a key before using the agent.',
);
}
state.providerType = detectProviderType(key);
return;
}
p.separator();
p.note(
'Configure your LLM provider so the agent has a brain.\n' +
'Anthropic (Claude) and OpenAI are supported. You will need an API key to continue.',
'Anthropic (Claude) and OpenAI are supported.\n' +
'You can skip this and add a key later via `mosaic configure`.',
'LLM Provider',
);
const providerType = await p.select<ProviderType>({
message: 'Select your LLM provider',
options: [
{ value: 'anthropic', label: 'Anthropic (Claude)', hint: 'Keys start with sk-ant-' },
{ value: 'openai', label: 'OpenAI', hint: 'Keys start with sk-' },
],
initialValue: 'anthropic',
});
const key = await p.text({
message: providerType === 'anthropic' ? 'Anthropic API key' : 'OpenAI API key',
placeholder: providerType === 'anthropic' ? 'sk-ant-api03-...' : 'sk-...',
validate: (value: string): string | void => {
if (!value || value.trim().length === 0) {
return 'API key is required';
}
const trimmed = value.trim();
if (providerType === 'anthropic' && !trimmed.startsWith('sk-ant-')) {
return 'Anthropic keys start with sk-ant-';
}
if (
providerType === 'openai' &&
(!trimmed.startsWith('sk-') || trimmed.startsWith('sk-ant-'))
) {
return 'OpenAI keys start with sk- (not sk-ant-)';
}
},
message: 'API key (paste your Anthropic or OpenAI key, or press Enter to skip)',
defaultValue: '',
placeholder: 'sk-ant-api03-... or sk-...',
});
state.providerKey = key.trim();
state.providerType = providerType;
p.log(`Provider configured: ${providerType === 'anthropic' ? 'Anthropic (Claude)' : 'OpenAI'}`);
if (key) {
const provider = detectProviderType(key);
state.providerKey = key;
state.providerType = provider;
if (provider === 'anthropic') {
p.log('Detected provider: Anthropic (Claude)');
} else if (provider === 'openai') {
p.log('Detected provider: OpenAI');
} else {
p.log('Provider auto-detection failed. Key will be stored as ANTHROPIC_API_KEY.');
state.providerType = 'anthropic';
}
} else {
state.providerType = 'none';
p.log('No API key provided. You can add one later with `mosaic configure`.');
}
}

View File

@@ -2,7 +2,6 @@ import type { WizardPrompter } from '../prompter/interface.js';
import type { ConfigService } from '../config/config-service.js';
import type { WizardState } from '../types.js';
import { DEFAULTS } from '../constants.js';
import { WizardCancelledError } from '../errors.js';
import { providerSetupStage } from './provider-setup.js';
import { runtimeSetupStage } from './runtime-setup.js';
import { hooksPreviewStage } from './hooks-preview.js';
@@ -39,25 +38,6 @@ export async function quickStartPath(
// 1. Provider setup (first question)
await providerSetupStage(prompter, state);
// Belt-and-suspenders guard: ensure a provider key was set before proceeding.
// The interactive path in providerSetupStage always requires a key, so this
// guard is effectively unreachable interactively. The headless path may
// produce providerType='none' when no env var is present: there we warn (the
// operator can configure a key later via `mosaic gateway install`) and let
// the scripted install continue — finalize.ts will NOT print "Mosaic is
// ready" without a configured provider, so no false-green is possible.
if (state.providerType === 'none' || !state.providerKey) {
const headlessRun = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
if (!headlessRun) {
prompter.warn(
'A provider API key is required to continue. ' +
'Set MOSAIC_ANTHROPIC_API_KEY or MOSAIC_OPENAI_API_KEY and run the wizard again, ' +
'or run `mosaic gateway install` to configure one after installation.',
);
throw new WizardCancelledError('No LLM provider configured');
}
}
// Apply sensible defaults for everything else
state.soul.agentName ??= 'Mosaic';
state.soul.roleDescription ??= DEFAULTS.roleDescription;