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

@@ -5,6 +5,7 @@
*/
import { Injectable, Logger } from "@nestjs/common";
import { ModuleRef } from "@nestjs/core";
import { HttpService } from "@nestjs/axios";
import { randomUUID } from "crypto";
import { firstValueFrom } from "rxjs";
@@ -26,7 +27,8 @@ export class CommandService {
private readonly prisma: PrismaService,
private readonly federationService: FederationService,
private readonly signatureService: SignatureService,
private readonly httpService: HttpService
private readonly httpService: HttpService,
private readonly moduleRef: ModuleRef
) {}
/**
@@ -158,15 +160,33 @@ export class CommandService {
throw new Error(verificationResult.error ?? "Invalid signature");
}
// Process command (placeholder - would delegate to actual command processor)
// Process command
let responseData: unknown;
let success = true;
let errorMessage: string | undefined;
try {
// TODO: Implement actual command processing
// For now, return a placeholder response
responseData = { message: "Command received and processed" };
// Route agent commands to FederationAgentService
if (commandMessage.commandType.startsWith("agent.")) {
// Import FederationAgentService dynamically to avoid circular dependency
const { FederationAgentService } = await import("./federation-agent.service");
const federationAgentService = this.moduleRef.get(FederationAgentService, {
strict: false,
});
const agentResponse = await federationAgentService.handleAgentCommand(
commandMessage.instanceId,
commandMessage.commandType,
commandMessage.payload
);
success = agentResponse.success;
responseData = agentResponse.data;
errorMessage = agentResponse.error;
} else {
// Other command types can be added here
responseData = { message: "Command received and processed" };
}
} catch (error) {
success = false;
errorMessage = error instanceof Error ? error.message : "Command processing failed";

View File

@@ -0,0 +1,457 @@
/**
* Tests for Federation Agent Service
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { HttpService } from "@nestjs/axios";
import { ConfigService } from "@nestjs/config";
import { FederationAgentService } from "./federation-agent.service";
import { CommandService } from "./command.service";
import { PrismaService } from "../prisma/prisma.service";
import { FederationConnectionStatus } from "@prisma/client";
import { of, throwError } from "rxjs";
import type {
SpawnAgentCommandPayload,
AgentStatusCommandPayload,
KillAgentCommandPayload,
SpawnAgentResponseData,
AgentStatusResponseData,
KillAgentResponseData,
} from "./types/federation-agent.types";
describe("FederationAgentService", () => {
let service: FederationAgentService;
let commandService: ReturnType<typeof vi.mocked<CommandService>>;
let prisma: ReturnType<typeof vi.mocked<PrismaService>>;
let httpService: ReturnType<typeof vi.mocked<HttpService>>;
let configService: ReturnType<typeof vi.mocked<ConfigService>>;
const mockWorkspaceId = "workspace-1";
const mockConnectionId = "connection-1";
const mockAgentId = "agent-123";
const mockTaskId = "task-456";
const mockOrchestratorUrl = "http://localhost:3001";
beforeEach(async () => {
const mockCommandService = {
sendCommand: vi.fn(),
};
const mockPrisma = {
federationConnection: {
findUnique: vi.fn(),
findFirst: vi.fn(),
},
};
const mockHttpService = {
post: vi.fn(),
get: vi.fn(),
};
const mockConfigService = {
get: vi.fn((key: string) => {
if (key === "orchestrator.url") {
return mockOrchestratorUrl;
}
return undefined;
}),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
FederationAgentService,
{ provide: CommandService, useValue: mockCommandService },
{ provide: PrismaService, useValue: mockPrisma },
{ provide: HttpService, useValue: mockHttpService },
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
service = module.get<FederationAgentService>(FederationAgentService);
commandService = module.get(CommandService);
prisma = module.get(PrismaService);
httpService = module.get(HttpService);
configService = module.get(ConfigService);
});
it("should be defined", () => {
expect(service).toBeDefined();
});
describe("spawnAgentOnRemote", () => {
const spawnPayload: SpawnAgentCommandPayload = {
taskId: mockTaskId,
agentType: "worker",
context: {
repository: "git.example.com/org/repo",
branch: "main",
workItems: ["item-1"],
},
};
const mockConnection = {
id: mockConnectionId,
workspaceId: mockWorkspaceId,
remoteInstanceId: "remote-instance-1",
remoteUrl: "https://remote.example.com",
status: FederationConnectionStatus.ACTIVE,
};
it("should spawn agent on remote instance", async () => {
prisma.federationConnection.findUnique.mockResolvedValue(mockConnection as never);
const mockCommandResponse = {
id: "msg-1",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
messageType: "COMMAND" as never,
messageId: "msg-uuid",
commandType: "agent.spawn",
payload: spawnPayload as never,
response: {
agentId: mockAgentId,
status: "spawning",
spawnedAt: "2026-02-03T14:30:00Z",
} as never,
status: "DELIVERED" as never,
createdAt: new Date(),
updatedAt: new Date(),
};
commandService.sendCommand.mockResolvedValue(mockCommandResponse as never);
const result = await service.spawnAgentOnRemote(
mockWorkspaceId,
mockConnectionId,
spawnPayload
);
expect(prisma.federationConnection.findUnique).toHaveBeenCalledWith({
where: { id: mockConnectionId, workspaceId: mockWorkspaceId },
});
expect(commandService.sendCommand).toHaveBeenCalledWith(
mockWorkspaceId,
mockConnectionId,
"agent.spawn",
spawnPayload
);
expect(result).toEqual(mockCommandResponse);
});
it("should throw error if connection not found", async () => {
prisma.federationConnection.findUnique.mockResolvedValue(null);
await expect(
service.spawnAgentOnRemote(mockWorkspaceId, mockConnectionId, spawnPayload)
).rejects.toThrow("Connection not found");
expect(commandService.sendCommand).not.toHaveBeenCalled();
});
it("should throw error if connection not active", async () => {
const inactiveConnection = {
...mockConnection,
status: FederationConnectionStatus.DISCONNECTED,
};
prisma.federationConnection.findUnique.mockResolvedValue(inactiveConnection as never);
await expect(
service.spawnAgentOnRemote(mockWorkspaceId, mockConnectionId, spawnPayload)
).rejects.toThrow("Connection is not active");
expect(commandService.sendCommand).not.toHaveBeenCalled();
});
});
describe("getAgentStatus", () => {
const statusPayload: AgentStatusCommandPayload = {
agentId: mockAgentId,
};
const mockConnection = {
id: mockConnectionId,
workspaceId: mockWorkspaceId,
remoteInstanceId: "remote-instance-1",
remoteUrl: "https://remote.example.com",
status: FederationConnectionStatus.ACTIVE,
};
it("should get agent status from remote instance", async () => {
prisma.federationConnection.findUnique.mockResolvedValue(mockConnection as never);
const mockCommandResponse = {
id: "msg-2",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
messageType: "COMMAND" as never,
messageId: "msg-uuid-2",
commandType: "agent.status",
payload: statusPayload as never,
response: {
agentId: mockAgentId,
taskId: mockTaskId,
status: "running",
spawnedAt: "2026-02-03T14:30:00Z",
startedAt: "2026-02-03T14:30:05Z",
} as never,
status: "DELIVERED" as never,
createdAt: new Date(),
updatedAt: new Date(),
};
commandService.sendCommand.mockResolvedValue(mockCommandResponse as never);
const result = await service.getAgentStatus(mockWorkspaceId, mockConnectionId, mockAgentId);
expect(commandService.sendCommand).toHaveBeenCalledWith(
mockWorkspaceId,
mockConnectionId,
"agent.status",
statusPayload
);
expect(result).toEqual(mockCommandResponse);
});
});
describe("killAgentOnRemote", () => {
const killPayload: KillAgentCommandPayload = {
agentId: mockAgentId,
};
const mockConnection = {
id: mockConnectionId,
workspaceId: mockWorkspaceId,
remoteInstanceId: "remote-instance-1",
remoteUrl: "https://remote.example.com",
status: FederationConnectionStatus.ACTIVE,
};
it("should kill agent on remote instance", async () => {
prisma.federationConnection.findUnique.mockResolvedValue(mockConnection as never);
const mockCommandResponse = {
id: "msg-3",
workspaceId: mockWorkspaceId,
connectionId: mockConnectionId,
messageType: "COMMAND" as never,
messageId: "msg-uuid-3",
commandType: "agent.kill",
payload: killPayload as never,
response: {
agentId: mockAgentId,
status: "killed",
killedAt: "2026-02-03T14:35:00Z",
} as never,
status: "DELIVERED" as never,
createdAt: new Date(),
updatedAt: new Date(),
};
commandService.sendCommand.mockResolvedValue(mockCommandResponse as never);
const result = await service.killAgentOnRemote(
mockWorkspaceId,
mockConnectionId,
mockAgentId
);
expect(commandService.sendCommand).toHaveBeenCalledWith(
mockWorkspaceId,
mockConnectionId,
"agent.kill",
killPayload
);
expect(result).toEqual(mockCommandResponse);
});
});
describe("handleAgentCommand", () => {
const mockConnection = {
id: mockConnectionId,
workspaceId: mockWorkspaceId,
remoteInstanceId: "remote-instance-1",
remoteUrl: "https://remote.example.com",
status: FederationConnectionStatus.ACTIVE,
};
it("should handle agent.spawn command", async () => {
const spawnPayload: SpawnAgentCommandPayload = {
taskId: mockTaskId,
agentType: "worker",
context: {
repository: "git.example.com/org/repo",
branch: "main",
workItems: ["item-1"],
},
};
prisma.federationConnection.findFirst.mockResolvedValue(mockConnection as never);
const mockOrchestratorResponse = {
agentId: mockAgentId,
status: "spawning",
};
httpService.post.mockReturnValue(
of({
data: mockOrchestratorResponse,
status: 200,
statusText: "OK",
headers: {},
config: {} as never,
}) as never
);
const result = await service.handleAgentCommand(
"remote-instance-1",
"agent.spawn",
spawnPayload
);
expect(httpService.post).toHaveBeenCalledWith(
`${mockOrchestratorUrl}/agents/spawn`,
expect.objectContaining({
taskId: mockTaskId,
agentType: "worker",
})
);
expect(result.success).toBe(true);
expect(result.data).toEqual({
agentId: mockAgentId,
status: "spawning",
spawnedAt: expect.any(String),
});
});
it("should handle agent.status command", async () => {
const statusPayload: AgentStatusCommandPayload = {
agentId: mockAgentId,
};
prisma.federationConnection.findFirst.mockResolvedValue(mockConnection as never);
const mockOrchestratorResponse = {
agentId: mockAgentId,
taskId: mockTaskId,
status: "running",
spawnedAt: "2026-02-03T14:30:00Z",
startedAt: "2026-02-03T14:30:05Z",
};
httpService.get.mockReturnValue(
of({
data: mockOrchestratorResponse,
status: 200,
statusText: "OK",
headers: {},
config: {} as never,
}) as never
);
const result = await service.handleAgentCommand(
"remote-instance-1",
"agent.status",
statusPayload
);
expect(httpService.get).toHaveBeenCalledWith(
`${mockOrchestratorUrl}/agents/${mockAgentId}/status`
);
expect(result.success).toBe(true);
expect(result.data).toEqual(mockOrchestratorResponse);
});
it("should handle agent.kill command", async () => {
const killPayload: KillAgentCommandPayload = {
agentId: mockAgentId,
};
prisma.federationConnection.findFirst.mockResolvedValue(mockConnection as never);
const mockOrchestratorResponse = {
message: `Agent ${mockAgentId} killed successfully`,
};
httpService.post.mockReturnValue(
of({
data: mockOrchestratorResponse,
status: 200,
statusText: "OK",
headers: {},
config: {} as never,
}) as never
);
const result = await service.handleAgentCommand(
"remote-instance-1",
"agent.kill",
killPayload
);
expect(httpService.post).toHaveBeenCalledWith(
`${mockOrchestratorUrl}/agents/${mockAgentId}/kill`,
{}
);
expect(result.success).toBe(true);
expect(result.data).toEqual({
agentId: mockAgentId,
status: "killed",
killedAt: expect.any(String),
});
});
it("should return error for unknown command type", async () => {
prisma.federationConnection.findFirst.mockResolvedValue(mockConnection as never);
const result = await service.handleAgentCommand("remote-instance-1", "agent.unknown", {});
expect(result.success).toBe(false);
expect(result.error).toContain("Unknown agent command type: agent.unknown");
});
it("should throw error if connection not found", async () => {
prisma.federationConnection.findFirst.mockResolvedValue(null);
await expect(
service.handleAgentCommand("remote-instance-1", "agent.spawn", {})
).rejects.toThrow("No connection found for remote instance");
});
it("should handle orchestrator errors", async () => {
const spawnPayload: SpawnAgentCommandPayload = {
taskId: mockTaskId,
agentType: "worker",
context: {
repository: "git.example.com/org/repo",
branch: "main",
workItems: ["item-1"],
},
};
prisma.federationConnection.findFirst.mockResolvedValue(mockConnection as never);
httpService.post.mockReturnValue(
throwError(() => new Error("Orchestrator connection failed")) as never
);
const result = await service.handleAgentCommand(
"remote-instance-1",
"agent.spawn",
spawnPayload
);
expect(result.success).toBe(false);
expect(result.error).toContain("Orchestrator connection failed");
});
});
});

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;
}
}
}

View File

@@ -8,10 +8,12 @@ import { Controller, Get, Post, UseGuards, Logger, Req, Body, Param, Query } fro
import { FederationService } from "./federation.service";
import { FederationAuditService } from "./audit.service";
import { ConnectionService } from "./connection.service";
import { FederationAgentService } from "./federation-agent.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import { AdminGuard } from "../auth/guards/admin.guard";
import type { PublicInstanceIdentity } from "./types/instance.types";
import type { ConnectionDetails } from "./types/connection.types";
import type { CommandMessageDetails } from "./types/message.types";
import type { AuthenticatedRequest } from "../common/types/user.types";
import {
InitiateConnectionDto,
@@ -20,6 +22,7 @@ import {
DisconnectConnectionDto,
IncomingConnectionRequestDto,
} from "./dto/connection.dto";
import type { SpawnAgentCommandPayload } from "./types/federation-agent.types";
import { FederationConnectionStatus } from "@prisma/client";
@Controller("api/v1/federation")
@@ -29,7 +32,8 @@ export class FederationController {
constructor(
private readonly federationService: FederationService,
private readonly auditService: FederationAuditService,
private readonly connectionService: ConnectionService
private readonly connectionService: ConnectionService,
private readonly federationAgentService: FederationAgentService
) {}
/**
@@ -211,4 +215,81 @@ export class FederationController {
connectionId: connection.id,
};
}
/**
* Spawn an agent on a remote federated instance
* Requires authentication
*/
@Post("agents/spawn")
@UseGuards(AuthGuard)
async spawnAgentOnRemote(
@Req() req: AuthenticatedRequest,
@Body() body: { connectionId: string; payload: SpawnAgentCommandPayload }
): Promise<CommandMessageDetails> {
if (!req.user?.workspaceId) {
throw new Error("Workspace ID not found in request");
}
this.logger.log(
`User ${req.user.id} spawning agent on remote instance via connection ${body.connectionId}`
);
return this.federationAgentService.spawnAgentOnRemote(
req.user.workspaceId,
body.connectionId,
body.payload
);
}
/**
* Get agent status from remote instance
* Requires authentication
*/
@Get("agents/:agentId/status")
@UseGuards(AuthGuard)
async getAgentStatus(
@Req() req: AuthenticatedRequest,
@Param("agentId") agentId: string,
@Query("connectionId") connectionId: string
): Promise<CommandMessageDetails> {
if (!req.user?.workspaceId) {
throw new Error("Workspace ID not found in request");
}
if (!connectionId) {
throw new Error("connectionId query parameter is required");
}
this.logger.log(
`User ${req.user.id} getting agent ${agentId} status via connection ${connectionId}`
);
return this.federationAgentService.getAgentStatus(req.user.workspaceId, connectionId, agentId);
}
/**
* Kill an agent on remote instance
* Requires authentication
*/
@Post("agents/:agentId/kill")
@UseGuards(AuthGuard)
async killAgentOnRemote(
@Req() req: AuthenticatedRequest,
@Param("agentId") agentId: string,
@Body() body: { connectionId: string }
): Promise<CommandMessageDetails> {
if (!req.user?.workspaceId) {
throw new Error("Workspace ID not found in request");
}
this.logger.log(
`User ${req.user.id} killing agent ${agentId} via connection ${body.connectionId}`
);
return this.federationAgentService.killAgentOnRemote(
req.user.workspaceId,
body.connectionId,
agentId
);
}
}

View File

@@ -24,6 +24,7 @@ import { IdentityResolutionService } from "./identity-resolution.service";
import { QueryService } from "./query.service";
import { CommandService } from "./command.service";
import { EventService } from "./event.service";
import { FederationAgentService } from "./federation-agent.service";
import { PrismaModule } from "../prisma/prisma.module";
@Module({
@@ -55,6 +56,7 @@ import { PrismaModule } from "../prisma/prisma.module";
QueryService,
CommandService,
EventService,
FederationAgentService,
],
exports: [
FederationService,
@@ -67,6 +69,7 @@ import { PrismaModule } from "../prisma/prisma.module";
QueryService,
CommandService,
EventService,
FederationAgentService,
],
})
export class FederationModule {}

View File

@@ -0,0 +1,149 @@
/**
* Federation Agent Command Types
*
* Types for agent spawn commands sent via federation COMMAND messages.
*/
/**
* Agent type options for spawning
*/
export type FederationAgentType = "worker" | "reviewer" | "tester";
/**
* Agent status returned from remote instance
*/
export type FederationAgentStatus = "spawning" | "running" | "completed" | "failed" | "killed";
/**
* Context for agent execution
*/
export interface FederationAgentContext {
/** Git repository URL or path */
repository: string;
/** Git branch to work on */
branch: string;
/** Work items for the agent to complete */
workItems: string[];
/** Optional skills to load */
skills?: string[];
/** Optional instructions */
instructions?: string;
}
/**
* Options for spawning an agent
*/
export interface FederationAgentOptions {
/** Enable Docker sandbox isolation */
sandbox?: boolean;
/** Timeout in milliseconds */
timeout?: number;
/** Maximum retry attempts */
maxRetries?: number;
}
/**
* Payload for agent.spawn command
*/
export interface SpawnAgentCommandPayload {
/** Unique task identifier */
taskId: string;
/** Type of agent to spawn */
agentType: FederationAgentType;
/** Context for task execution */
context: FederationAgentContext;
/** Optional configuration */
options?: FederationAgentOptions;
}
/**
* Payload for agent.status command
*/
export interface AgentStatusCommandPayload {
/** Unique agent identifier */
agentId: string;
}
/**
* Payload for agent.kill command
*/
export interface KillAgentCommandPayload {
/** Unique agent identifier */
agentId: string;
}
/**
* Response data for agent.spawn command
*/
export interface SpawnAgentResponseData {
/** Unique agent identifier */
agentId: string;
/** Current agent status */
status: FederationAgentStatus;
/** Timestamp when agent was spawned */
spawnedAt: string;
}
/**
* Response data for agent.status command
*/
export interface AgentStatusResponseData {
/** Unique agent identifier */
agentId: string;
/** Task identifier */
taskId: string;
/** Current agent status */
status: FederationAgentStatus;
/** Timestamp when agent was spawned */
spawnedAt: string;
/** Timestamp when agent started (if running/completed) */
startedAt?: string;
/** Timestamp when agent completed (if completed/failed/killed) */
completedAt?: string;
/** Error message (if failed) */
error?: string;
/** Agent progress data */
progress?: Record<string, unknown>;
}
/**
* Response data for agent.kill command
*/
export interface KillAgentResponseData {
/** Unique agent identifier */
agentId: string;
/** Status after kill operation */
status: FederationAgentStatus;
/** Timestamp when agent was killed */
killedAt: string;
}
/**
* Details about a federated agent
*/
export interface FederatedAgentDetails {
/** Agent ID */
agentId: string;
/** Task ID */
taskId: string;
/** Remote instance ID where agent is running */
remoteInstanceId: string;
/** Connection ID used to spawn the agent */
connectionId: string;
/** Agent type */
agentType: FederationAgentType;
/** Current status */
status: FederationAgentStatus;
/** Spawn timestamp */
spawnedAt: Date;
/** Start timestamp */
startedAt?: Date;
/** Completion timestamp */
completedAt?: Date;
/** Error message if failed */
error?: string;
/** Context used to spawn agent */
context: FederationAgentContext;
/** Options used to spawn agent */
options?: FederationAgentOptions;
}

View File

@@ -9,3 +9,4 @@ export * from "./connection.types";
export * from "./oidc.types";
export * from "./identity-linking.types";
export * from "./message.types";
export * from "./federation-agent.types";