Compare commits

..

1 Commits

Author SHA1 Message Date
Jarvis
0095a452cd docs(federation): sync M3 backlog to origin/main reality
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Reconcile docs/federation/TASKS.md against main (0.0.48): mark FED-M3-01
(#506), -02 (#505), -03 (#509), -08 (#508) done; mark dependency-clear
FED-M3-04/07/09 in-progress (dispatched to coder lane). Adds a dated
backlog-sync note recording merges, dispatch, and the remaining DAG.

Backlog was stale (all M3 marked not-started) despite four merged PRs,
which masked three ready cards while the coder lane sat idle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:51:38 -05:00
16 changed files with 91 additions and 363 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 { sql, type Db } from '@mosaicstack/db';
import { createQueue } from '@mosaicstack/queue'; import { createQueue } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import { DB } from '../database/database.module.js'; import { DB } from '../database/database.module.js';
import { AgentService } from '../agent/agent.service.js'; import { AgentService } from '../agent/agent.service.js';
import { ProviderService } from '../agent/provider.service.js'; import { ProviderService } from '../agent/provider.service.js';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import { AdminGuard } from './admin.guard.js'; import { AdminGuard } from './admin.guard.js';
import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js'; import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js';
@@ -16,9 +14,6 @@ export class AdminHealthController {
@Inject(DB) private readonly db: Db, @Inject(DB) private readonly db: Db,
@Inject(AgentService) private readonly agentService: AgentService, @Inject(AgentService) private readonly agentService: AgentService,
@Inject(ProviderService) private readonly providerService: ProviderService, @Inject(ProviderService) private readonly providerService: ProviderService,
@Optional()
@Inject(MOSAIC_CONFIG)
private readonly mosaicConfig: MosaicConfig | null,
) {} ) {}
@Get() @Get()
@@ -60,14 +55,6 @@ export class AdminHealthController {
} }
private async checkCache(): Promise<ServiceStatusDto> { 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 start = Date.now();
const handle = createQueue(); const handle = createQueue();
try { try {

View File

@@ -21,10 +21,7 @@ export class CommandExecutorService {
@Inject(AgentService) private readonly agentService: AgentService, @Inject(AgentService) private readonly agentService: AgentService,
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService, @Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
@Inject(SessionGCService) private readonly sessionGC: SessionGCService, @Inject(SessionGCService) private readonly sessionGC: SessionGCService,
// On Local tier COMMANDS_REDIS is null — provider login caching is skipped. @Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'],
@Optional()
@Inject(COMMANDS_REDIS)
private readonly redis: QueueHandle['redis'] | null,
@Inject(BRAIN) private readonly brain: Brain, @Inject(BRAIN) private readonly brain: Brain,
@Optional() @Optional()
@Inject(forwardRef(() => ReloadService)) @Inject(forwardRef(() => ReloadService))
@@ -406,16 +403,14 @@ export class CommandExecutorService {
}; };
} }
const pollToken = crypto.randomUUID(); const pollToken = crypto.randomUUID();
const pollKey = `mosaic:auth:poll:${pollToken}`; const key = `mosaic:auth:poll:${pollToken}`;
if (this.redis) {
// Store pending state in Valkey (TTL 5 minutes) // Store pending state in Valkey (TTL 5 minutes)
await this.redis.set( await this.redis.set(
pollKey, key,
JSON.stringify({ status: 'pending', provider: providerName, userId }), JSON.stringify({ status: 'pending', provider: providerName, userId }),
'EX', 'EX',
300, 300,
); );
}
// In production this would construct an OAuth URL // In production this would construct an OAuth URL
const loginUrl = `${process.env['MOSAIC_BASE_URL'] ?? 'http://localhost:3000'}/auth/provider/${providerName}?token=${pollToken}`; const loginUrl = `${process.env['MOSAIC_BASE_URL'] ?? 'http://localhost:3000'}/auth/provider/${providerName}?token=${pollToken}`;
return { 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 { 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 { ChatModule } from '../chat/chat.module.js';
import { GCModule } from '../gc/gc.module.js'; import { GCModule } from '../gc/gc.module.js';
import { ReloadModule } from '../reload/reload.module.js'; import { ReloadModule } from '../reload/reload.module.js';
@@ -16,17 +14,13 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
providers: [ providers: [
{ {
provide: COMMANDS_QUEUE_HANDLE, provide: COMMANDS_QUEUE_HANDLE,
useFactory: (config: MosaicConfig | null): QueueHandle | null => { useFactory: (): QueueHandle => {
// 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;
return createQueue(); return createQueue();
}, },
inject: [MOSAIC_CONFIG],
}, },
{ {
provide: COMMANDS_REDIS, provide: COMMANDS_REDIS,
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null, useFactory: (handle: QueueHandle) => handle.redis,
inject: [COMMANDS_QUEUE_HANDLE], inject: [COMMANDS_QUEUE_HANDLE],
}, },
CommandRegistryService, CommandRegistryService,
@@ -35,13 +29,9 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
exports: [CommandRegistryService, CommandExecutorService], exports: [CommandRegistryService, CommandExecutorService],
}) })
export class CommandsModule implements OnApplicationShutdown { export class CommandsModule implements OnApplicationShutdown {
constructor( constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
@Optional()
@Inject(COMMANDS_QUEUE_HANDLE)
private readonly handle: QueueHandle | null,
) {}
async onApplicationShutdown(): Promise<void> { async onApplicationShutdown(): Promise<void> {
await this.handle?.close().catch(() => {}); await this.handle.close().catch(() => {});
} }
} }

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 { 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 { SessionGCService } from './session-gc.service.js';
import { REDIS } from './gc.tokens.js'; import { REDIS } from './gc.tokens.js';
@@ -11,17 +9,13 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
providers: [ providers: [
{ {
provide: GC_QUEUE_HANDLE, provide: GC_QUEUE_HANDLE,
useFactory: (config: MosaicConfig | null): QueueHandle | null => { useFactory: (): QueueHandle => {
// 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;
return createQueue(); return createQueue();
}, },
inject: [MOSAIC_CONFIG],
}, },
{ {
provide: REDIS, provide: REDIS,
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null, useFactory: (handle: QueueHandle) => handle.redis,
inject: [GC_QUEUE_HANDLE], inject: [GC_QUEUE_HANDLE],
}, },
SessionGCService, SessionGCService,
@@ -29,13 +23,9 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
exports: [SessionGCService], exports: [SessionGCService],
}) })
export class GCModule implements OnApplicationShutdown { export class GCModule implements OnApplicationShutdown {
constructor( constructor(@Inject(GC_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
@Optional()
@Inject(GC_QUEUE_HANDLE)
private readonly handle: QueueHandle | null,
) {}
async onApplicationShutdown(): Promise<void> { 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 { QueueHandle } from '@mosaicstack/queue';
import type { LogService } from '@mosaicstack/log'; import type { LogService } from '@mosaicstack/log';
import { LOG_SERVICE } from '../log/log.tokens.js'; import { LOG_SERVICE } from '../log/log.tokens.js';
@@ -32,21 +32,11 @@ export class SessionGCService implements OnModuleInit {
private readonly logger = new Logger(SessionGCService.name); private readonly logger = new Logger(SessionGCService.name);
constructor( constructor(
// On Local tier there is no Redis — the GC module provides null for this token. @Inject(REDIS) private readonly redis: QueueHandle['redis'],
// 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(LOG_SERVICE) private readonly logService: LogService, @Inject(LOG_SERVICE) private readonly logService: LogService,
) {} ) {}
onModuleInit(): void { 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 // Fire-and-forget: run full GC asynchronously so it does not block the
// NestJS bootstrap chain. Cold-start GC typically takes 100500 ms // NestJS bootstrap chain. Cold-start GC typically takes 100500 ms
// depending on Valkey key count; deferring it removes that latency from // 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). * 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 * KEYS is avoided because it blocks the Valkey event loop for the full scan
* duration, which can cause latency spikes under production key volumes. * 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[]> { private async scanKeys(pattern: string): Promise<string[]> {
if (!this.redis) return [];
const collected: string[] = []; const collected: string[] = [];
let cursor = '0'; let cursor = '0';
do { do {
@@ -90,15 +78,13 @@ export class SessionGCService implements OnModuleInit {
async collect(sessionId: string): Promise<GCResult> { async collect(sessionId: string): Promise<GCResult> {
const result: GCResult = { sessionId, cleaned: {} }; const result: GCResult = { sessionId, cleaned: {} };
// 1. Valkey: delete all session-scoped keys (skipped on Local tier) // 1. Valkey: delete all session-scoped keys
if (this.redis) {
const pattern = `mosaic:session:${sessionId}:*`; const pattern = `mosaic:session:${sessionId}:*`;
const valkeyKeys = await this.scanKeys(pattern); const valkeyKeys = await this.scanKeys(pattern);
if (valkeyKeys.length > 0) { if (valkeyKeys.length > 0) {
await this.redis.del(...valkeyKeys); await this.redis.del(...valkeyKeys);
result.cleaned.valkeyKeys = valkeyKeys.length; result.cleaned.valkeyKeys = valkeyKeys.length;
} }
}
// 2. PG: demote hot-tier agent_logs for this session to warm // 2. PG: demote hot-tier agent_logs for this session to warm
const cutoff = new Date(); // demote all hot logs for this session const cutoff = new Date(); // demote all hot logs for this session
@@ -120,7 +106,6 @@ export class SessionGCService implements OnModuleInit {
const cleaned: GCResult[] = []; const cleaned: GCResult[] = [];
// 1. Find all session-scoped Valkey keys (non-blocking SCAN) // 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:*'); const allSessionKeys = await this.scanKeys('mosaic:session:*');
// Extract unique session IDs from keys // Extract unique session IDs from keys
@@ -151,16 +136,12 @@ export class SessionGCService implements OnModuleInit {
*/ */
async fullCollect(): Promise<FullGCResult> { async fullCollect(): Promise<FullGCResult> {
const start = Date.now(); const start = Date.now();
let valkeyKeysCount = 0;
if (this.redis) {
// 1. Valkey: delete ALL session-scoped keys (non-blocking SCAN) // 1. Valkey: delete ALL session-scoped keys (non-blocking SCAN)
const sessionKeys = await this.scanKeys('mosaic:session:*'); const sessionKeys = await this.scanKeys('mosaic:session:*');
if (sessionKeys.length > 0) { if (sessionKeys.length > 0) {
await this.redis.del(...sessionKeys); await this.redis.del(...sessionKeys);
} }
valkeyKeysCount = sessionKeys.length;
}
// 2. NOTE: channel keys are NOT collected on cold start // 2. NOTE: channel keys are NOT collected on cold start
// (discord/telegram plugins may reconnect and resume) // (discord/telegram plugins may reconnect and resume)
@@ -173,7 +154,7 @@ export class SessionGCService implements OnModuleInit {
const jobsPurged = 0; const jobsPurged = 0;
return { return {
valkeyKeys: valkeyKeysCount, valkeyKeys: sessionKeys.length,
logsDemoted, logsDemoted,
jobsPurged, jobsPurged,
tempFilesRemoved: 0, tempFilesRemoved: 0,

View File

@@ -19,7 +19,7 @@ import type { MosaicJobData } from '../queue/queue.service.js';
@Injectable() @Injectable()
export class CronService implements OnModuleInit, OnModuleDestroy { export class CronService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(CronService.name); private readonly logger = new Logger(CronService.name);
private readonly registeredWorkers: Array<Worker<MosaicJobData>> = []; private readonly registeredWorkers: Worker<MosaicJobData>[] = [];
constructor( constructor(
@Inject(SummarizationService) private readonly summarization: SummarizationService, @Inject(SummarizationService) private readonly summarization: SummarizationService,
@@ -28,16 +28,6 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
) {} ) {}
async onModuleInit(): Promise<void> { 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 summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
const gcSchedule = process.env['SESSION_GC_CRON'] ?? '0 4 * * *'; // daily at 4am 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 () => { const summarizationWorker = this.queueService.registerWorker(QUEUE_SUMMARIZATION, async () => {
await this.summarization.runSummarization(); await this.summarization.runSummarization();
}); });
if (summarizationWorker) this.registeredWorkers.push(summarizationWorker); this.registeredWorkers.push(summarizationWorker);
// M6-005: Tier management repeatable job // M6-005: Tier management repeatable job
await this.queueService.addRepeatableJob( await this.queueService.addRepeatableJob(
@@ -64,14 +54,14 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => { const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => {
await this.summarization.runTierManagement(); await this.summarization.runTierManagement();
}); });
if (tierWorker) this.registeredWorkers.push(tierWorker); this.registeredWorkers.push(tierWorker);
// M6-004: GC repeatable job // M6-004: GC repeatable job
await this.queueService.addRepeatableJob(QUEUE_GC, 'session-gc', {}, gcSchedule); await this.queueService.addRepeatableJob(QUEUE_GC, 'session-gc', {}, gcSchedule);
const gcWorker = this.queueService.registerWorker(QUEUE_GC, async () => { const gcWorker = this.queueService.registerWorker(QUEUE_GC, async () => {
await this.sessionGC.sweepOrphans(); await this.sessionGC.sweepOrphans();
}); });
if (gcWorker) this.registeredWorkers.push(gcWorker); this.registeredWorkers.push(gcWorker);
this.logger.log( this.logger.log(
`BullMQ jobs scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}", gc="${gcSchedule}"`, `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 { 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_KEY = (sessionId: string) => `mosaic:session:${sessionId}:system`;
const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string) => const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string) =>
@@ -13,54 +11,16 @@ interface OverrideFragment {
addedAt: number; addedAt: number;
} }
interface LocalOverrideEntry {
condensed: string;
fragments: OverrideFragment[];
}
@Injectable() @Injectable()
export class SystemOverrideService implements OnApplicationShutdown { export class SystemOverrideService {
private readonly logger = new Logger(SystemOverrideService.name); private readonly logger = new Logger(SystemOverrideService.name);
private readonly handle: QueueHandle | null; private readonly handle: QueueHandle;
/**
* 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>();
constructor( constructor() {
@Optional()
@Inject(MOSAIC_CONFIG)
private readonly mosaicConfig: MosaicConfig | null,
) {
if (this.mosaicConfig?.queue?.type === 'local') {
this.handle = null;
} else {
this.handle = createQueue(); 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(() => {});
}
async set(sessionId: string, override: string): Promise<void> { 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 // Load existing fragments
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId)); const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId));
const fragments: OverrideFragment[] = existing const fragments: OverrideFragment[] = existing
@@ -90,17 +50,10 @@ export class SystemOverrideService implements OnApplicationShutdown {
} }
async get(sessionId: string): Promise<string | null> { 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)); return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId));
} }
async renew(sessionId: string): Promise<void> { 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(); const pipeline = this.handle.redis.pipeline();
pipeline.expire(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS); pipeline.expire(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS);
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_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> { 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( await this.handle.redis.del(
SESSION_SYSTEM_KEY(sessionId), SESSION_SYSTEM_KEY(sessionId),
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId), SESSION_SYSTEM_FRAGMENTS_KEY(sessionId),

View File

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

View File

@@ -93,15 +93,15 @@ Goal: Two federated gateways exchange real data over mTLS. Inbound requests pass
| id | status | description | issue | agent | branch | depends_on | estimate | notes | | id | status | description | issue | agent | branch | depends_on | estimate | notes |
| --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ------------------------------------ | ---------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ------------------------------------ | ---------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| FED-M3-01 | not-started | `packages/types/src/federation/` — request/response DTOs for `list`, `get`, `capabilities` verbs. Wire-format zod schemas + inferred TS types. Includes `FederationRequest`, `FederationListResponse<T>`, `FederationGetResponse<T>`, `FederationCapabilitiesResponse`, error envelope, `_source` tag. | #462 | sonnet | feat/federation-m3-types | — | 4K | Reusable from gateway server + client + harness. Pure types — no I/O, no NestJS. | | FED-M3-01 | done | `packages/types/src/federation/` — request/response DTOs for `list`, `get`, `capabilities` verbs. Wire-format zod schemas + inferred TS types. Includes `FederationRequest`, `FederationListResponse<T>`, `FederationGetResponse<T>`, `FederationCapabilitiesResponse`, error envelope, `_source` tag. | #462 | sonnet | feat/federation-m3-types | — | 4K | Reusable from gateway server + client + harness. Pure types — no I/O, no NestJS. |
| FED-M3-02 | not-started | `tools/federation-harness/` scaffold: `docker-compose.two-gateways.yml` (Server A + Server B + step-CA), `seed.ts` (provisions grants, peers, sample tasks/notes/credentials per scope variant), `harness.ts` helper (boots stack, returns typed clients). README documents harness use. | #462 | sonnet | feat/federation-m3-harness | DEPLOY-04 (soft) | 8K | Falls back to local docker-compose if `mos-test-1/-2` not yet redeployed (DEPLOY chain blocked on IMG-FIX). Permanent test infra used by M3+. | | FED-M3-02 | done | `tools/federation-harness/` scaffold: `docker-compose.two-gateways.yml` (Server A + Server B + step-CA), `seed.ts` (provisions grants, peers, sample tasks/notes/credentials per scope variant), `harness.ts` helper (boots stack, returns typed clients). README documents harness use. | #462 | sonnet | feat/federation-m3-harness | DEPLOY-04 (soft) | 8K | Falls back to local docker-compose if `mos-test-1/-2` not yet redeployed (DEPLOY chain blocked on IMG-FIX). Permanent test infra used by M3+. |
| FED-M3-03 | not-started | `apps/gateway/src/federation/server/federation-auth.guard.ts` (NestJS guard). Validates inbound client cert from Fastify TLS context, extracts `grantId` + `subjectUserId` from custom OIDs, loads grant from DB, asserts `status='active'`, attaches `FederationContext` to request. | #462 | sonnet | feat/federation-m3-auth-guard | M3-01 | 8K | Reuses OID parsing logic mirrored from `ca.service.ts` post-issuance verification. 401 on malformed/missing OIDs; 403 on revoked/expired/missing grant. | | FED-M3-03 | done | `apps/gateway/src/federation/server/federation-auth.guard.ts` (NestJS guard). Validates inbound client cert from Fastify TLS context, extracts `grantId` + `subjectUserId` from custom OIDs, loads grant from DB, asserts `status='active'`, attaches `FederationContext` to request. | #462 | sonnet | feat/federation-m3-auth-guard | M3-01 | 8K | Reuses OID parsing logic mirrored from `ca.service.ts` post-issuance verification. 401 on malformed/missing OIDs; 403 on revoked/expired/missing grant. |
| FED-M3-04 | not-started | `apps/gateway/src/federation/server/scope.service.ts`. Pipeline: (1) resource allowlist + excluded check, (2) native RBAC eval as `subjectUserId`, (3) scope filter intersection (`include_teams`, `include_personal`), (4) `max_rows_per_query` cap. Pure service — DB calls injected. | #462 | sonnet | feat/federation-m3-scope-service | M3-01 | 10K | Hardest correctness target in M3. Reuses `parseFederationScope` (M2-03). Returns either `{ allowed: true, filter }` or structured deny reason for audit. | | FED-M3-04 | in-progress | `apps/gateway/src/federation/server/scope.service.ts`. Pipeline: (1) resource allowlist + excluded check, (2) native RBAC eval as `subjectUserId`, (3) scope filter intersection (`include_teams`, `include_personal`), (4) `max_rows_per_query` cap. Pure service — DB calls injected. | #462 | sonnet | feat/federation-m3-scope-service | M3-01 | 10K | Hardest correctness target in M3. Reuses `parseFederationScope` (M2-03). Returns either `{ allowed: true, filter }` or structured deny reason for audit. |
| FED-M3-05 | not-started | `apps/gateway/src/federation/server/verbs/list.controller.ts`. Wires AuthGuard → ScopeService → tasks/notes/memory query layer; applies row cap; tags rows with `_source`. Resource selector via path param. | #462 | sonnet | feat/federation-m3-verb-list | M3-03, M3-04 | 6K | Routes: `POST /api/federation/v1/list/:resource`. No body persistence. Audit write deferred to M4. | | FED-M3-05 | not-started | `apps/gateway/src/federation/server/verbs/list.controller.ts`. Wires AuthGuard → ScopeService → tasks/notes/memory query layer; applies row cap; tags rows with `_source`. Resource selector via path param. | #462 | sonnet | feat/federation-m3-verb-list | M3-03, M3-04 | 6K | Routes: `POST /api/federation/v1/list/:resource`. No body persistence. Audit write deferred to M4. |
| FED-M3-06 | not-started | `apps/gateway/src/federation/server/verbs/get.controller.ts`. Single-resource fetch by id; same pipeline as list. 404 on not-found, 403 on RBAC/scope deny — both audited the same way. | #462 | sonnet | feat/federation-m3-verb-get | M3-03, M3-04 | 6K | `POST /api/federation/v1/get/:resource/:id`. Mirrors list controller patterns. | | FED-M3-06 | not-started | `apps/gateway/src/federation/server/verbs/get.controller.ts`. Single-resource fetch by id; same pipeline as list. 404 on not-found, 403 on RBAC/scope deny — both audited the same way. | #462 | sonnet | feat/federation-m3-verb-get | M3-03, M3-04 | 6K | `POST /api/federation/v1/get/:resource/:id`. Mirrors list controller patterns. |
| FED-M3-07 | not-started | `apps/gateway/src/federation/server/verbs/capabilities.controller.ts`. Read-only enumeration: returns `{ resources, excluded_resources, max_rows_per_query, supported_verbs }` derived from grant scope. Always allowed for an active grant — no RBAC eval. | #462 | sonnet | feat/federation-m3-verb-capabilities | M3-03 | 4K | `GET /api/federation/v1/capabilities`. Smallest verb; useful sanity check that mTLS + auth guard work end-to-end. | | FED-M3-07 | in-progress | `apps/gateway/src/federation/server/verbs/capabilities.controller.ts`. Read-only enumeration: returns `{ resources, excluded_resources, max_rows_per_query, supported_verbs }` derived from grant scope. Always allowed for an active grant — no RBAC eval. | #462 | sonnet | feat/federation-m3-verb-capabilities | M3-03 | 4K | `GET /api/federation/v1/capabilities`. Smallest verb; useful sanity check that mTLS + auth guard work end-to-end. |
| FED-M3-08 | not-started | `apps/gateway/src/federation/client/federation-client.service.ts`. Outbound mTLS dialer: picks `(certPem, sealed clientKey)` from `federation_peers`, unwraps key, builds undici Agent with mTLS, calls peer verb, parses typed response, wraps non-2xx into `FederationClientError`. | #462 | sonnet | feat/federation-m3-client | M3-01 | 8K | Independent of server stream — can land in parallel with M3-03/04. Cert/key cached per-peer; flushed by future M5/M6 logic. | | FED-M3-08 | done | `apps/gateway/src/federation/client/federation-client.service.ts`. Outbound mTLS dialer: picks `(certPem, sealed clientKey)` from `federation_peers`, unwraps key, builds undici Agent with mTLS, calls peer verb, parses typed response, wraps non-2xx into `FederationClientError`. | #462 | sonnet | feat/federation-m3-client | M3-01 | 8K | Independent of server stream — can land in parallel with M3-03/04. Cert/key cached per-peer; flushed by future M5/M6 logic. |
| FED-M3-09 | not-started | `apps/gateway/src/federation/client/query-source.service.ts`. Accepts `source: "local" \| "federated:<host>" \| "all"` from gateway query layer; for `"all"` fans out to local + each peer in parallel; merges results; tags every row with `_source`. | #462 | sonnet | feat/federation-m3-query-source | M3-08 | 8K | Per-peer failure surfaces as `_partial: true` in response, not hard failure (sets up M5 offline UX). M5 adds caching + circuit breaker on top. | | FED-M3-09 | in-progress | `apps/gateway/src/federation/client/query-source.service.ts`. Accepts `source: "local" \| "federated:<host>" \| "all"` from gateway query layer; for `"all"` fans out to local + each peer in parallel; merges results; tags every row with `_source`. | #462 | sonnet | feat/federation-m3-query-source | M3-08 | 8K | Per-peer failure surfaces as `_partial: true` in response, not hard failure (sets up M5 offline UX). M5 adds caching + circuit breaker on top. |
| FED-M3-10 | not-started | Integration tests for MILESTONES.md M3 acceptance #6 (malformed OIDs → 401; valid cert + revoked grant → 403) and #7 (`max_rows_per_query` cap). Real PG, mocked TLS context (Fastify req shim). | #462 | sonnet | feat/federation-m3-integration | M3-05, M3-06 | 8K | Vitest profile gated by `FEDERATED_INTEGRATION=1`. Single-gateway suite; no harness required. | | FED-M3-10 | not-started | Integration tests for MILESTONES.md M3 acceptance #6 (malformed OIDs → 401; valid cert + revoked grant → 403) and #7 (`max_rows_per_query` cap). Real PG, mocked TLS context (Fastify req shim). | #462 | sonnet | feat/federation-m3-integration | M3-05, M3-06 | 8K | Vitest profile gated by `FEDERATED_INTEGRATION=1`. Single-gateway suite; no harness required. |
| FED-M3-11 | not-started | E2E tests for MILESTONES.md M3 acceptance #1, #2, #3, #4, #5, #8, #9, #10 (8 cases). Uses harness from M3-02; two real gateways, real Step-CA, real mTLS. Each test asserts both happy-path response and audit/no-persist invariants. | #462 | sonnet | feat/federation-m3-e2e | M3-02, M3-09 | 12K | Largest single task. Each acceptance gets its own `it(...)` for clear failure attribution. | | FED-M3-11 | not-started | E2E tests for MILESTONES.md M3 acceptance #1, #2, #3, #4, #5, #8, #9, #10 (8 cases). Uses harness from M3-02; two real gateways, real Step-CA, real mTLS. Each test asserts both happy-path response and audit/no-persist invariants. | #462 | sonnet | feat/federation-m3-e2e | M3-02, M3-09 | 12K | Largest single task. Each acceptance gets its own `it(...)` for clear failure attribution. |
| FED-M3-12 | not-started | Independent security review (sonnet, not author of M3-03/04/05/06/07/08/09): focus on cert-SAN spoofing, OID extraction edge cases, scope-bypass via filter manipulation, RBAC-bypass via subjectUser swap, response leakage when scope deny. | #462 | sonnet | feat/federation-m3-security-review | M3-11 | 10K | Two review rounds budgeted. PRD requires explicit test for every 401/403 path — review verifies coverage. | | FED-M3-12 | not-started | Independent security review (sonnet, not author of M3-03/04/05/06/07/08/09): focus on cert-SAN spoofing, OID extraction edge cases, scope-bypass via filter manipulation, RBAC-bypass via subjectUser swap, response leakage when scope deny. | #462 | sonnet | feat/federation-m3-security-review | M3-11 | 10K | Two review rounds budgeted. PRD requires explicit test for every 401/403 path — review verifies coverage. |
@@ -118,6 +118,8 @@ Goal: Two federated gateways exchange real data over mTLS. Inbound requests pass
**Test bed fallback:** If `mos-test-1.woltje.com` / `mos-test-2.woltje.com` are still blocked on `FED-M2-DEPLOY-IMG-FIX` when M3-11 is ready to run, the harness's local `docker-compose.two-gateways.yml` is a sufficient stand-in. Production-host validation moves to M7 acceptance suite (PRD AC-12). **Test bed fallback:** If `mos-test-1.woltje.com` / `mos-test-2.woltje.com` are still blocked on `FED-M2-DEPLOY-IMG-FIX` when M3-11 is ready to run, the harness's local `docker-compose.two-gateways.yml` is a sufficient stand-in. Production-host validation moves to M7 acceptance suite (PRD AC-12).
**Backlog sync — 2026-06-24 (orchestrator):** Status reconciled against `origin/main` (release 0.0.48). Landed on main: **FED-M3-01** (DTOs, PR #506), **FED-M3-02** (harness scaffold, PR #505), **FED-M3-03** (mTLS auth-guard, PR #509 — CRIT-1/2 + HIGH-1..4 remediated in-PR), **FED-M3-08** (outbound mTLS client, PR #508). With M3-01/03/08 merged, three cards became dependency-clear and were dispatched to the idle coder lane: **FED-M3-04** scope.service → coder0 (`feat/federation-m3-scope-service`); **FED-M3-09** query-source + **FED-M3-07** capabilities verb → coder1 (`feat/federation-m3-query-source` first). Reviewer warmed for the M3 trust-boundary PRs. Remaining blocked-by-DAG: M3-05/06 (await M3-04), M3-10 (await M3-05/06), M3-11 (await M3-09), M3-12→14 (tail). Deploy chain (DEPLOY-IMG-FIX → 03/04) still independent of M3 code — harness local docker-compose fallback covers M3-11.
## Milestone 4 — search + audit + rate limit (FED-M4) ## Milestone 4 — search + audit + rate limit (FED-M4)
_Deferred. Issue #463._ _Deferred. Issue #463._

View File

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

View File

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

View File

@@ -207,16 +207,9 @@ export async function finalizeStage(
: 'none selected' : 'none selected'
: `install failed — ${skillsResult.failureReason ?? 'unknown error'}`; : `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[] = [ const summary: string[] = [
`Agent: ${state.soul.agentName ?? 'Assistant'}`, `Agent: ${state.soul.agentName ?? 'Assistant'}`,
`Style: ${state.soul.communicationStyle ?? 'direct'}`, `Style: ${state.soul.communicationStyle ?? 'direct'}`,
providerSummary,
`Runtimes: ${state.runtimes.detected.join(', ') || 'none detected'}`, `Runtimes: ${state.runtimes.detected.join(', ') || 'none detected'}`,
`Skills: ${skillsSummary}`, `Skills: ${skillsSummary}`,
`Config: ${state.mosaicHome}`, `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'); 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. // Install the gateway npm package on first install or after failure.
// MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1 forces a skip even without opts.skipInstall: if (!opts.skipInstall && !daemonRunning) {
// 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) {
installGatewayPackage(); installGatewayPackage();
} }

View File

@@ -78,7 +78,7 @@ describe('providerSetupStage', () => {
expect(state.providerType).toBe('none'); 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']; delete process.env['MOSAIC_ASSUME_YES'];
// Simulate a TTY // Simulate a TTY
const origIsTTY = process.stdin.isTTY; const origIsTTY = process.stdin.isTTY;
@@ -86,13 +86,11 @@ describe('providerSetupStage', () => {
const state = makeState(); const state = makeState();
const p = buildPrompter({ const p = buildPrompter({
select: vi.fn().mockResolvedValue('anthropic'),
text: vi.fn().mockResolvedValue('sk-ant-api03-interactive'), text: vi.fn().mockResolvedValue('sk-ant-api03-interactive'),
}); });
await providerSetupStage(p, state); await providerSetupStage(p, state);
expect(p.select).toHaveBeenCalled();
expect(p.text).toHaveBeenCalled(); expect(p.text).toHaveBeenCalled();
expect(state.providerKey).toBe('sk-ant-api03-interactive'); expect(state.providerKey).toBe('sk-ant-api03-interactive');
expect(state.providerType).toBe('anthropic'); expect(state.providerType).toBe('anthropic');
@@ -100,57 +98,20 @@ describe('providerSetupStage', () => {
Object.defineProperty(process.stdin, 'isTTY', { value: origIsTTY, configurable: true }); 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']; delete process.env['MOSAIC_ASSUME_YES'];
const origIsTTY = process.stdin.isTTY; const origIsTTY = process.stdin.isTTY;
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
let capturedValidate: ((v: string) => string | void) | undefined;
const state = makeState(); const state = makeState();
const p = buildPrompter({ const p = buildPrompter({
select: vi.fn().mockResolvedValue('anthropic'), text: vi.fn().mockResolvedValue(''),
text: vi
.fn()
.mockImplementation(async (opts: { validate?: (v: string) => string | void }) => {
capturedValidate = opts.validate;
return 'sk-ant-api03-ok';
}),
}); });
await providerSetupStage(p, state); await providerSetupStage(p, state);
expect(capturedValidate).toBeDefined(); expect(state.providerType).toBe('none');
expect(capturedValidate?.('')).toBe('API key is required'); expect(state.providerKey).toBeUndefined();
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');
Object.defineProperty(process.stdin, 'isTTY', { value: origIsTTY, configurable: true }); Object.defineProperty(process.stdin, 'isTTY', { value: origIsTTY, configurable: true });
}); });

View File

@@ -1,13 +1,12 @@
import type { WizardPrompter } from '../prompter/interface.js'; import type { WizardPrompter } from '../prompter/interface.js';
import type { WizardState } from '../types.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. * provider type from the key prefix.
* *
* In headless mode, reads from `MOSAIC_ANTHROPIC_API_KEY` or `MOSAIC_OPENAI_API_KEY`. * 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> { export async function providerSetupStage(p: WizardPrompter, state: WizardState): Promise<void> {
const isHeadless = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY; 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 openaiKey = process.env['MOSAIC_OPENAI_API_KEY'] ?? '';
const key = anthropicKey || openaiKey; const key = anthropicKey || openaiKey;
state.providerKey = key || undefined; state.providerKey = key || undefined;
if (anthropicKey) { state.providerType = detectProviderType(key);
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.',
);
}
return; return;
} }
p.separator(); p.separator();
p.note( p.note(
'Configure your LLM provider so the agent has a brain.\n' + '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', '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({ const key = await p.text({
message: providerType === 'anthropic' ? 'Anthropic API key' : 'OpenAI API key', message: 'API key (paste your Anthropic or OpenAI key, or press Enter to skip)',
placeholder: providerType === 'anthropic' ? 'sk-ant-api03-...' : 'sk-...', defaultValue: '',
validate: (value: string): string | void => { placeholder: 'sk-ant-api03-... or sk-...',
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-)';
}
},
}); });
state.providerKey = key.trim(); if (key) {
state.providerType = providerType; const provider = detectProviderType(key);
p.log(`Provider configured: ${providerType === 'anthropic' ? 'Anthropic (Claude)' : 'OpenAI'}`); 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 { ConfigService } from '../config/config-service.js';
import type { WizardState } from '../types.js'; import type { WizardState } from '../types.js';
import { DEFAULTS } from '../constants.js'; import { DEFAULTS } from '../constants.js';
import { WizardCancelledError } from '../errors.js';
import { providerSetupStage } from './provider-setup.js'; import { providerSetupStage } from './provider-setup.js';
import { runtimeSetupStage } from './runtime-setup.js'; import { runtimeSetupStage } from './runtime-setup.js';
import { hooksPreviewStage } from './hooks-preview.js'; import { hooksPreviewStage } from './hooks-preview.js';
@@ -39,25 +38,6 @@ export async function quickStartPath(
// 1. Provider setup (first question) // 1. Provider setup (first question)
await providerSetupStage(prompter, state); 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 // Apply sensible defaults for everything else
state.soul.agentName ??= 'Mosaic'; state.soul.agentName ??= 'Mosaic';
state.soul.roleDescription ??= DEFAULTS.roleDescription; state.soul.roleDescription ??= DEFAULTS.roleDescription;