Merge develop into main — branch consolidation #432

Merged
jason.woltje merged 47 commits from merge/develop-to-main into main 2026-02-21 20:56:41 +00:00
13 changed files with 185 additions and 35 deletions
Showing only changes of commit 6fd8e85266 - Show all commits

View File

@@ -406,8 +406,7 @@ AI_PROVIDER=ollama
OLLAMA_MODEL=llama3.1:latest
# Claude API Key
# Required by the orchestrator service in swarm deployment.
# Also used when AI_PROVIDER=claude for other services.
# Required only when AI_PROVIDER=claude.
# Get your API key from: https://console.anthropic.com/
CLAUDE_API_KEY=REPLACE_WITH_CLAUDE_API_KEY

View File

@@ -1,6 +1,8 @@
# Orchestrator Configuration
ORCHESTRATOR_PORT=3001
NODE_ENV=development
# AI provider for orchestrator agents: ollama, claude, openai
AI_PROVIDER=ollama
# Valkey
VALKEY_HOST=localhost
@@ -8,6 +10,7 @@ VALKEY_PORT=6379
VALKEY_URL=redis://localhost:6379
# Claude API
# Required only when AI_PROVIDER=claude.
CLAUDE_API_KEY=your-api-key-here
# Docker

View File

@@ -186,17 +186,18 @@ pnpm --filter @mosaic/orchestrator lint
Environment variables loaded via `@nestjs/config`. Key variables:
| Variable | Description |
| -------------------------------- | -------------------------------------------------- |
| `ORCHESTRATOR_PORT` | HTTP port (default: 3001) |
| `CLAUDE_API_KEY` | Claude API key for agents |
| `VALKEY_HOST` | Valkey/Redis host (default: localhost) |
| `VALKEY_PORT` | Valkey/Redis port (default: 6379) |
| `COORDINATOR_URL` | Quality Coordinator base URL |
| `SANDBOX_ENABLED` | Enable Docker sandbox (true/false) |
| `MAX_CONCURRENT_AGENTS` | Maximum concurrent in-memory sessions (default: 2) |
| `ORCHESTRATOR_QUEUE_CONCURRENCY` | BullMQ worker concurrency (default: 1) |
| `SANDBOX_DEFAULT_MEMORY_MB` | Sandbox memory limit in MB (default: 256) |
| Variable | Description |
| -------------------------------- | ------------------------------------------------------------ |
| `ORCHESTRATOR_PORT` | HTTP port (default: 3001) |
| `AI_PROVIDER` | LLM provider for orchestrator (`ollama`, `claude`, `openai`) |
| `CLAUDE_API_KEY` | Required only when `AI_PROVIDER=claude` |
| `VALKEY_HOST` | Valkey/Redis host (default: localhost) |
| `VALKEY_PORT` | Valkey/Redis port (default: 6379) |
| `COORDINATOR_URL` | Quality Coordinator base URL |
| `SANDBOX_ENABLED` | Enable Docker sandbox (true/false) |
| `MAX_CONCURRENT_AGENTS` | Maximum concurrent in-memory sessions (default: 2) |
| `ORCHESTRATOR_QUEUE_CONCURRENCY` | BullMQ worker concurrency (default: 1) |
| `SANDBOX_DEFAULT_MEMORY_MB` | Sandbox memory limit in MB (default: 256) |
## Related Documentation

View File

@@ -192,7 +192,8 @@ LABEL com.mosaic.security.non-root=true
Sensitive configuration is passed via environment variables:
- `CLAUDE_API_KEY`: Claude API credentials
- `AI_PROVIDER`: Orchestrator LLM provider
- `CLAUDE_API_KEY`: Claude credentials (required only for `AI_PROVIDER=claude`)
- `VALKEY_URL`: Cache connection string
**Best Practices:**

View File

@@ -181,4 +181,30 @@ describe("orchestratorConfig", () => {
expect(config.spawner.maxConcurrentAgents).toBe(10);
});
});
describe("AI provider config", () => {
it("should default aiProvider to ollama when unset", () => {
delete process.env.AI_PROVIDER;
const config = orchestratorConfig();
expect(config.aiProvider).toBe("ollama");
});
it("should normalize AI provider to lowercase", () => {
process.env.AI_PROVIDER = " cLaUdE ";
const config = orchestratorConfig();
expect(config.aiProvider).toBe("claude");
});
it("should fallback unsupported AI provider to ollama", () => {
process.env.AI_PROVIDER = "bad-provider";
const config = orchestratorConfig();
expect(config.aiProvider).toBe("ollama");
});
});
});

View File

@@ -1,4 +1,17 @@
import { registerAs } from "@nestjs/config";
const normalizeAiProvider = (): "ollama" | "claude" | "openai" => {
const provider = process.env.AI_PROVIDER?.trim().toLowerCase();
if (!provider) {
return "ollama";
}
if (provider !== "ollama" && provider !== "claude" && provider !== "openai") {
return "ollama";
}
return provider;
};
export const orchestratorConfig = registerAs("orchestrator", () => ({
host: process.env.HOST ?? process.env.BIND_ADDRESS ?? "127.0.0.1",
@@ -14,6 +27,7 @@ export const orchestratorConfig = registerAs("orchestrator", () => ({
claude: {
apiKey: process.env.CLAUDE_API_KEY,
},
aiProvider: normalizeAiProvider(),
docker: {
socketPath: process.env.DOCKER_SOCKET ?? "/var/run/docker.sock",
},

View File

@@ -12,6 +12,9 @@ describe("AgentSpawnerService", () => {
// Create mock ConfigService
mockConfigService = {
get: vi.fn((key: string) => {
if (key === "orchestrator.aiProvider") {
return "ollama";
}
if (key === "orchestrator.claude.apiKey") {
return "test-api-key";
}
@@ -31,19 +34,80 @@ describe("AgentSpawnerService", () => {
expect(service).toBeDefined();
});
it("should initialize with Claude API key from config", () => {
it("should initialize with default AI provider when API key is omitted", () => {
const noClaudeConfigService = {
get: vi.fn((key: string) => {
if (key === "orchestrator.aiProvider") {
return "ollama";
}
if (key === "orchestrator.spawner.maxConcurrentAgents") {
return 20;
}
if (key === "orchestrator.spawner.sessionCleanupDelayMs") {
return 30000;
}
return undefined;
}),
} as unknown as ConfigService;
const serviceNoKey = new AgentSpawnerService(noClaudeConfigService);
expect(serviceNoKey).toBeDefined();
});
it("should initialize with Claude provider when key is present", () => {
expect(mockConfigService.get).toHaveBeenCalledWith("orchestrator.claude.apiKey");
});
it("should throw error if Claude API key is missing", () => {
it("should initialize with CLAUDE provider when API key is present", () => {
const claudeConfigService = {
get: vi.fn((key: string) => {
if (key === "orchestrator.aiProvider") {
return "claude";
}
if (key === "orchestrator.claude.apiKey") {
return "test-api-key";
}
if (key === "orchestrator.spawner.maxConcurrentAgents") {
return 20;
}
return undefined;
}),
} as unknown as ConfigService;
const claudeService = new AgentSpawnerService(claudeConfigService);
expect(claudeService).toBeDefined();
});
it("should throw error if Claude API key is missing when provider is claude", () => {
const badConfigService = {
get: vi.fn(() => undefined),
get: vi.fn((key: string) => {
if (key === "orchestrator.aiProvider") {
return "claude";
}
return undefined;
}),
} as unknown as ConfigService;
expect(() => new AgentSpawnerService(badConfigService)).toThrow(
"CLAUDE_API_KEY is not configured"
"CLAUDE_API_KEY is required when AI_PROVIDER is set to 'claude'"
);
});
it("should still initialize when CLAUDE_API_KEY is missing for non-Claude provider", () => {
const nonClaudeConfigService = {
get: vi.fn((key: string) => {
if (key === "orchestrator.aiProvider") {
return "ollama";
}
if (key === "orchestrator.spawner.maxConcurrentAgents") {
return 20;
}
return undefined;
}),
} as unknown as ConfigService;
expect(() => new AgentSpawnerService(nonClaudeConfigService)).not.toThrow();
});
});
describe("spawnAgent", () => {

View File

@@ -14,6 +14,8 @@ import {
* This allows time for status queries before the session is removed
*/
const DEFAULT_SESSION_CLEANUP_DELAY_MS = 30000; // 30 seconds
const SUPPORTED_AI_PROVIDERS = ["ollama", "claude", "openai"] as const;
type SupportedAiProvider = (typeof SUPPORTED_AI_PROVIDERS)[number];
/**
* Service responsible for spawning Claude agents using Anthropic SDK
@@ -21,22 +23,38 @@ const DEFAULT_SESSION_CLEANUP_DELAY_MS = 30000; // 30 seconds
@Injectable()
export class AgentSpawnerService implements OnModuleDestroy {
private readonly logger = new Logger(AgentSpawnerService.name);
private readonly anthropic: Anthropic;
private readonly anthropic: Anthropic | undefined;
private readonly aiProvider: SupportedAiProvider;
private readonly sessions = new Map<string, AgentSession>();
private readonly maxConcurrentAgents: number;
private readonly sessionCleanupDelayMs: number;
private readonly cleanupTimers = new Map<string, NodeJS.Timeout>();
constructor(private readonly configService: ConfigService) {
const configuredProvider = this.configService.get<string>("orchestrator.aiProvider");
this.aiProvider = this.normalizeAiProvider(configuredProvider);
this.logger.log(`AgentSpawnerService resolved AI provider: ${this.aiProvider}`);
const apiKey = this.configService.get<string>("orchestrator.claude.apiKey");
if (!apiKey) {
throw new Error("CLAUDE_API_KEY is not configured");
}
if (this.aiProvider === "claude") {
if (!apiKey) {
throw new Error("CLAUDE_API_KEY is required when AI_PROVIDER is set to 'claude'");
}
this.anthropic = new Anthropic({
apiKey,
});
this.logger.log("CLAUDE_API_KEY is configured. Initializing Anthropic client.");
this.anthropic = new Anthropic({ apiKey });
} else {
if (apiKey) {
this.logger.debug(
`CLAUDE_API_KEY is set but ignored because AI provider is '${this.aiProvider}'`
);
} else {
this.logger.log(`CLAUDE_API_KEY not required for AI provider '${this.aiProvider}'.`);
}
this.anthropic = undefined;
}
// Default to 20 if not configured
this.maxConcurrentAgents =
@@ -48,10 +66,27 @@ export class AgentSpawnerService implements OnModuleDestroy {
DEFAULT_SESSION_CLEANUP_DELAY_MS;
this.logger.log(
`AgentSpawnerService initialized with Claude SDK (max concurrent agents: ${String(this.maxConcurrentAgents)}, cleanup delay: ${String(this.sessionCleanupDelayMs)}ms)`
`AgentSpawnerService initialized with ${this.aiProvider} AI provider (max concurrent agents: ${String(
this.maxConcurrentAgents
)}, cleanup delay: ${String(this.sessionCleanupDelayMs)}ms)`
);
}
private normalizeAiProvider(provider?: string): SupportedAiProvider {
const normalizedProvider = provider?.trim().toLowerCase();
if (!normalizedProvider) {
return "ollama";
}
if (!SUPPORTED_AI_PROVIDERS.includes(normalizedProvider as SupportedAiProvider)) {
this.logger.warn(`Unsupported AI provider '${normalizedProvider}'. Defaulting to 'ollama'.`);
return "ollama";
}
return normalizedProvider as SupportedAiProvider;
}
/**
* Clean up all pending cleanup timers on module destroy
*/

View File

@@ -248,7 +248,9 @@ services:
environment:
NODE_ENV: production
ORCHESTRATOR_PORT: 3001
AI_PROVIDER: ${AI_PROVIDER:-ollama}
VALKEY_URL: redis://valkey:6379
# Claude API (required only when AI_PROVIDER=claude)
CLAUDE_API_KEY: ${CLAUDE_API_KEY}
DOCKER_SOCKET: /var/run/docker.sock
GIT_USER_NAME: "Mosaic Orchestrator"

View File

@@ -432,9 +432,10 @@ services:
NODE_ENV: production
# Orchestrator Configuration
ORCHESTRATOR_PORT: 3001
AI_PROVIDER: ${AI_PROVIDER:-ollama}
# Valkey
VALKEY_URL: redis://valkey:6379
# Claude API
# Claude API (required only when AI_PROVIDER=claude)
CLAUDE_API_KEY: ${CLAUDE_API_KEY}
# Docker
DOCKER_SOCKET: /var/run/docker.sock

View File

@@ -441,9 +441,10 @@ services:
NODE_ENV: production
# Orchestrator Configuration
ORCHESTRATOR_PORT: 3001
AI_PROVIDER: ${AI_PROVIDER:-ollama}
# Valkey
VALKEY_URL: redis://valkey:6379
# Claude API
# Claude API (required only when AI_PROVIDER=claude)
CLAUDE_API_KEY: ${CLAUDE_API_KEY}
# Docker
DOCKER_SOCKET: /var/run/docker.sock

View File

@@ -43,7 +43,10 @@ ENCRYPTION_KEY=$(openssl rand -hex 32)
ORCHESTRATOR_API_KEY=$(openssl rand -base64 32)
COORDINATOR_API_KEY=$(openssl rand -base64 32)
# Claude API Key
# AI Provider for Orchestrator
AI_PROVIDER=ollama
# Claude API Key (only required when AI_PROVIDER=claude)
CLAUDE_API_KEY=your-claude-api-key
# Authentik Bootstrap

View File

@@ -397,9 +397,9 @@
### Tasks
| id | status | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used |
| ------------ | ----------- | ----------------------------------------------------------------------------------------------------- | ----- | ----------------- | ---------------------------------------- | -------------------------------------- | ------------ | ----- | ---------- | ------------ | -------- | ---- |
| ORCH-424-001 | not-started | Remove hard startup dependency on `CLAUDE_API_KEY` unless provider explicitly requires it | #424 | orchestrator | fix/orchestrator-runtime-provider-config | | ORCH-424-002 | | | | 12K | |
| ORCH-424-002 | not-started | Add provider/runtime-aware validation and startup diagnostics for required key availability | #424 | orchestrator | fix/orchestrator-runtime-provider-config | ORCH-424-001 | ORCH-424-003 | | | | 10K | |
| ORCH-424-003 | not-started | Update env example docs for Codex/OpenCode/Claude multi-provider startup behavior | #424 | orchestrator,docs | fix/orchestrator-runtime-provider-config | ORCH-424-002 | ORCH-424-V01 | | | | 8K | |
| ORCH-424-V01 | not-started | Verification: `pnpm lint && pnpm typecheck && pnpm test` healthy for non-Claude startup/runtime paths | #424 | all | fix/orchestrator-runtime-provider-config | ORCH-424-001,ORCH-424-002,ORCH-424-003 | | | | | 5K | |
| id | status | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used |
| ------------ | ------ | ----------------------------------------------------------------------------------------------------- | ----- | ----------------- | ---------------------------------------- | -------------------------------------- | ------------ | ----- | ---------- | ------------------------- | -------- | ---- |
| ORCH-424-001 | done | Remove hard startup dependency on `CLAUDE_API_KEY` unless provider explicitly requires it | #424 | orchestrator | fix/orchestrator-runtime-provider-config | | ORCH-424-002 | | | 2026-02-17T17:15:18-06:00 | 12K | |
| ORCH-424-002 | done | Add provider/runtime-aware validation and startup diagnostics for required key availability | #424 | orchestrator | fix/orchestrator-runtime-provider-config | ORCH-424-001 | ORCH-424-003 | | | 2026-02-17T17:15:18-06:00 | 10K | |
| ORCH-424-003 | done | Update env example docs for Codex/OpenCode/Claude multi-provider startup behavior | #424 | orchestrator,docs | fix/orchestrator-runtime-provider-config | ORCH-424-002 | ORCH-424-V01 | | | 2026-02-17T17:15:18-06:00 | 8K | |
| ORCH-424-V01 | done | Verification: `pnpm lint && pnpm typecheck && pnpm test` healthy for non-Claude startup/runtime paths | #424 | all | fix/orchestrator-runtime-provider-config | ORCH-424-001,ORCH-424-002,ORCH-424-003 | | | | 2026-02-17T17:15:18-06:00 | 5K | |