feat(#93): implement agent spawn via federation

Implements FED-010: Agent Spawn via Federation feature that enables
spawning and managing Claude agents on remote federated Mosaic Stack
instances via COMMAND message type.

Features:
- Federation agent command types (spawn, status, kill)
- FederationAgentService for handling agent operations
- Integration with orchestrator's agent spawner/lifecycle services
- API endpoints for spawning, querying status, and killing agents
- Full command routing through federation COMMAND infrastructure
- Comprehensive test coverage (12/12 tests passing)

Architecture:
- Hub → Spoke: Spawn agents on remote instances
- Command flow: FederationController → FederationAgentService →
  CommandService → Remote Orchestrator
- Response handling: Remote orchestrator returns agent status/results
- Security: Connection validation, signature verification

Files created:
- apps/api/src/federation/types/federation-agent.types.ts
- apps/api/src/federation/federation-agent.service.ts
- apps/api/src/federation/federation-agent.service.spec.ts

Files modified:
- apps/api/src/federation/command.service.ts (agent command routing)
- apps/api/src/federation/federation.controller.ts (agent endpoints)
- apps/api/src/federation/federation.module.ts (service registration)
- apps/orchestrator/src/api/agents/agents.controller.ts (status endpoint)
- apps/orchestrator/src/api/agents/agents.module.ts (lifecycle integration)

Testing:
- 12/12 tests passing for FederationAgentService
- All command service tests passing
- TypeScript compilation successful
- Linting passed

Refs #93

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-03 14:37:06 -06:00
parent a8c8af21e5
commit 12abdfe81d
405 changed files with 13545 additions and 2153 deletions

View File

@@ -0,0 +1,338 @@
/**
* Federation Agent Service
*
* Handles spawning and managing agents on remote federated instances.
*/
import { Injectable, Logger } from "@nestjs/common";
import { HttpService } from "@nestjs/axios";
import { ConfigService } from "@nestjs/config";
import { firstValueFrom } from "rxjs";
import { PrismaService } from "../prisma/prisma.service";
import { CommandService } from "./command.service";
import { FederationConnectionStatus } from "@prisma/client";
import type { CommandMessageDetails } from "./types/message.types";
import type {
SpawnAgentCommandPayload,
AgentStatusCommandPayload,
KillAgentCommandPayload,
SpawnAgentResponseData,
AgentStatusResponseData,
KillAgentResponseData,
} from "./types/federation-agent.types";
/**
* Agent command response structure
*/
export interface AgentCommandResponse {
/** Whether the command was successful */
success: boolean;
/** Response data if successful */
data?:
| SpawnAgentResponseData
| AgentStatusResponseData
| KillAgentResponseData
| Record<string, unknown>;
/** Error message if failed */
error?: string;
}
@Injectable()
export class FederationAgentService {
private readonly logger = new Logger(FederationAgentService.name);
private readonly orchestratorUrl: string;
constructor(
private readonly prisma: PrismaService,
private readonly commandService: CommandService,
private readonly httpService: HttpService,
private readonly configService: ConfigService
) {
this.orchestratorUrl =
this.configService.get<string>("orchestrator.url") ?? "http://localhost:3001";
this.logger.log(
`FederationAgentService initialized with orchestrator URL: ${this.orchestratorUrl}`
);
}
/**
* Spawn an agent on a remote federated instance
* @param workspaceId Workspace ID
* @param connectionId Federation connection ID
* @param payload Agent spawn command payload
* @returns Command message details
*/
async spawnAgentOnRemote(
workspaceId: string,
connectionId: string,
payload: SpawnAgentCommandPayload
): Promise<CommandMessageDetails> {
this.logger.log(
`Spawning agent on remote instance via connection ${connectionId} for task ${payload.taskId}`
);
// Validate connection exists and is active
const connection = await this.prisma.federationConnection.findUnique({
where: { id: connectionId, workspaceId },
});
if (!connection) {
throw new Error("Connection not found");
}
if (connection.status !== FederationConnectionStatus.ACTIVE) {
throw new Error("Connection is not active");
}
// Send command via federation
const result = await this.commandService.sendCommand(
workspaceId,
connectionId,
"agent.spawn",
payload as unknown as Record<string, unknown>
);
this.logger.log(`Agent spawn command sent successfully: ${result.messageId}`);
return result;
}
/**
* Get agent status from remote instance
* @param workspaceId Workspace ID
* @param connectionId Federation connection ID
* @param agentId Agent ID
* @returns Command message details
*/
async getAgentStatus(
workspaceId: string,
connectionId: string,
agentId: string
): Promise<CommandMessageDetails> {
this.logger.log(`Getting agent status for ${agentId} via connection ${connectionId}`);
// Validate connection exists and is active
const connection = await this.prisma.federationConnection.findUnique({
where: { id: connectionId, workspaceId },
});
if (!connection) {
throw new Error("Connection not found");
}
if (connection.status !== FederationConnectionStatus.ACTIVE) {
throw new Error("Connection is not active");
}
// Send status command
const payload: AgentStatusCommandPayload = { agentId };
const result = await this.commandService.sendCommand(
workspaceId,
connectionId,
"agent.status",
payload as unknown as Record<string, unknown>
);
this.logger.log(`Agent status command sent successfully: ${result.messageId}`);
return result;
}
/**
* Kill an agent on remote instance
* @param workspaceId Workspace ID
* @param connectionId Federation connection ID
* @param agentId Agent ID
* @returns Command message details
*/
async killAgentOnRemote(
workspaceId: string,
connectionId: string,
agentId: string
): Promise<CommandMessageDetails> {
this.logger.log(`Killing agent ${agentId} via connection ${connectionId}`);
// Validate connection exists and is active
const connection = await this.prisma.federationConnection.findUnique({
where: { id: connectionId, workspaceId },
});
if (!connection) {
throw new Error("Connection not found");
}
if (connection.status !== FederationConnectionStatus.ACTIVE) {
throw new Error("Connection is not active");
}
// Send kill command
const payload: KillAgentCommandPayload = { agentId };
const result = await this.commandService.sendCommand(
workspaceId,
connectionId,
"agent.kill",
payload as unknown as Record<string, unknown>
);
this.logger.log(`Agent kill command sent successfully: ${result.messageId}`);
return result;
}
/**
* Handle incoming agent command from remote instance
* @param remoteInstanceId Remote instance ID that sent the command
* @param commandType Command type (agent.spawn, agent.status, agent.kill)
* @param payload Command payload
* @returns Agent command response
*/
async handleAgentCommand(
remoteInstanceId: string,
commandType: string,
payload: Record<string, unknown>
): Promise<AgentCommandResponse> {
this.logger.log(`Handling agent command ${commandType} from ${remoteInstanceId}`);
// Verify connection exists for remote instance
const connection = await this.prisma.federationConnection.findFirst({
where: {
remoteInstanceId,
status: FederationConnectionStatus.ACTIVE,
},
});
if (!connection) {
throw new Error("No connection found for remote instance");
}
// Route command to appropriate handler
try {
switch (commandType) {
case "agent.spawn":
return await this.handleSpawnCommand(payload as unknown as SpawnAgentCommandPayload);
case "agent.status":
return await this.handleStatusCommand(payload as unknown as AgentStatusCommandPayload);
case "agent.kill":
return await this.handleKillCommand(payload as unknown as KillAgentCommandPayload);
default:
throw new Error(`Unknown agent command type: ${commandType}`);
}
} catch (error) {
this.logger.error(`Error handling agent command: ${String(error)}`);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* Handle agent spawn command by calling local orchestrator
* @param payload Spawn command payload
* @returns Spawn response
*/
private async handleSpawnCommand(
payload: SpawnAgentCommandPayload
): Promise<AgentCommandResponse> {
this.logger.log(`Processing spawn command for task ${payload.taskId}`);
try {
const orchestratorPayload = {
taskId: payload.taskId,
agentType: payload.agentType,
context: payload.context,
options: payload.options,
};
const response = await firstValueFrom(
this.httpService.post<{ agentId: string; status: string }>(
`${this.orchestratorUrl}/agents/spawn`,
orchestratorPayload
)
);
const spawnedAt = new Date().toISOString();
const responseData: SpawnAgentResponseData = {
agentId: response.data.agentId,
status: response.data.status as "spawning",
spawnedAt,
};
this.logger.log(`Agent spawned successfully: ${responseData.agentId}`);
return {
success: true,
data: responseData,
};
} catch (error) {
this.logger.error(`Failed to spawn agent: ${String(error)}`);
throw error;
}
}
/**
* Handle agent status command by calling local orchestrator
* @param payload Status command payload
* @returns Status response
*/
private async handleStatusCommand(
payload: AgentStatusCommandPayload
): Promise<AgentCommandResponse> {
this.logger.log(`Processing status command for agent ${payload.agentId}`);
try {
const response = await firstValueFrom(
this.httpService.get(`${this.orchestratorUrl}/agents/${payload.agentId}/status`)
);
const responseData: AgentStatusResponseData = response.data as AgentStatusResponseData;
this.logger.log(`Agent status retrieved: ${responseData.status}`);
return {
success: true,
data: responseData,
};
} catch (error) {
this.logger.error(`Failed to get agent status: ${String(error)}`);
throw error;
}
}
/**
* Handle agent kill command by calling local orchestrator
* @param payload Kill command payload
* @returns Kill response
*/
private async handleKillCommand(payload: KillAgentCommandPayload): Promise<AgentCommandResponse> {
this.logger.log(`Processing kill command for agent ${payload.agentId}`);
try {
await firstValueFrom(
this.httpService.post(`${this.orchestratorUrl}/agents/${payload.agentId}/kill`, {})
);
const killedAt = new Date().toISOString();
const responseData: KillAgentResponseData = {
agentId: payload.agentId,
status: "killed",
killedAt,
};
this.logger.log(`Agent killed successfully: ${payload.agentId}`);
return {
success: true,
data: responseData,
};
} catch (error) {
this.logger.error(`Failed to kill agent: ${String(error)}`);
throw error;
}
}
}