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>
327 lines
9.4 KiB
TypeScript
327 lines
9.4 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import { Test, TestingModule } from "@nestjs/testing";
|
|
import { KnowledgeCacheService } from "./cache.service";
|
|
|
|
// Integration tests - require running Valkey instance
|
|
// Skip in unit test runs, enable with: INTEGRATION_TESTS=true pnpm test
|
|
describe.skipIf(!process.env.INTEGRATION_TESTS)("KnowledgeCacheService", () => {
|
|
let service: KnowledgeCacheService;
|
|
|
|
beforeEach(async () => {
|
|
// Set environment variables for testing
|
|
process.env.KNOWLEDGE_CACHE_ENABLED = "true";
|
|
process.env.KNOWLEDGE_CACHE_TTL = "300";
|
|
process.env.VALKEY_URL = "redis://localhost:6379";
|
|
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [KnowledgeCacheService],
|
|
}).compile();
|
|
|
|
service = module.get<KnowledgeCacheService>(KnowledgeCacheService);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
// Clean up
|
|
if (service && service.isEnabled()) {
|
|
await service.onModuleDestroy();
|
|
}
|
|
});
|
|
|
|
describe("Cache Enabled/Disabled", () => {
|
|
it("should be enabled by default", () => {
|
|
expect(service.isEnabled()).toBe(true);
|
|
});
|
|
|
|
it("should be disabled when KNOWLEDGE_CACHE_ENABLED=false", async () => {
|
|
process.env.KNOWLEDGE_CACHE_ENABLED = "false";
|
|
const module = await Test.createTestingModule({
|
|
providers: [KnowledgeCacheService],
|
|
}).compile();
|
|
const disabledService = module.get<KnowledgeCacheService>(KnowledgeCacheService);
|
|
|
|
expect(disabledService.isEnabled()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("Entry Caching", () => {
|
|
const workspaceId = "test-workspace-id";
|
|
const slug = "test-entry";
|
|
const entryData = {
|
|
id: "entry-id",
|
|
workspaceId,
|
|
slug,
|
|
title: "Test Entry",
|
|
content: "Test content",
|
|
tags: [],
|
|
};
|
|
|
|
it("should return null on cache miss", async () => {
|
|
if (!service.isEnabled()) {
|
|
return; // Skip if cache is disabled
|
|
}
|
|
|
|
await service.onModuleInit();
|
|
const result = await service.getEntry(workspaceId, slug);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("should cache and retrieve entry data", async () => {
|
|
if (!service.isEnabled()) {
|
|
return;
|
|
}
|
|
|
|
await service.onModuleInit();
|
|
|
|
// Set cache
|
|
await service.setEntry(workspaceId, slug, entryData);
|
|
|
|
// Get from cache
|
|
const result = await service.getEntry(workspaceId, slug);
|
|
expect(result).toEqual(entryData);
|
|
});
|
|
|
|
it("should invalidate entry cache", async () => {
|
|
if (!service.isEnabled()) {
|
|
return;
|
|
}
|
|
|
|
await service.onModuleInit();
|
|
|
|
// Set cache
|
|
await service.setEntry(workspaceId, slug, entryData);
|
|
|
|
// Verify it's cached
|
|
let result = await service.getEntry(workspaceId, slug);
|
|
expect(result).toEqual(entryData);
|
|
|
|
// Invalidate
|
|
await service.invalidateEntry(workspaceId, slug);
|
|
|
|
// Verify it's gone
|
|
result = await service.getEntry(workspaceId, slug);
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("Search Caching", () => {
|
|
const workspaceId = "test-workspace-id";
|
|
const query = "test search";
|
|
const filters = { status: "PUBLISHED", page: 1, limit: 20 };
|
|
const searchResults = {
|
|
data: [],
|
|
pagination: { page: 1, limit: 20, total: 0, totalPages: 0 },
|
|
query,
|
|
};
|
|
|
|
it("should cache and retrieve search results", async () => {
|
|
if (!service.isEnabled()) {
|
|
return;
|
|
}
|
|
|
|
await service.onModuleInit();
|
|
|
|
// Set cache
|
|
await service.setSearch(workspaceId, query, filters, searchResults);
|
|
|
|
// Get from cache
|
|
const result = await service.getSearch(workspaceId, query, filters);
|
|
expect(result).toEqual(searchResults);
|
|
});
|
|
|
|
it("should differentiate search results by filters", async () => {
|
|
if (!service.isEnabled()) {
|
|
return;
|
|
}
|
|
|
|
await service.onModuleInit();
|
|
|
|
const filters1 = { page: 1, limit: 20 };
|
|
const filters2 = { page: 2, limit: 20 };
|
|
|
|
const results1 = { ...searchResults, pagination: { ...searchResults.pagination, page: 1 } };
|
|
const results2 = { ...searchResults, pagination: { ...searchResults.pagination, page: 2 } };
|
|
|
|
await service.setSearch(workspaceId, query, filters1, results1);
|
|
await service.setSearch(workspaceId, query, filters2, results2);
|
|
|
|
const result1 = await service.getSearch(workspaceId, query, filters1);
|
|
const result2 = await service.getSearch(workspaceId, query, filters2);
|
|
|
|
expect(result1.pagination.page).toBe(1);
|
|
expect(result2.pagination.page).toBe(2);
|
|
});
|
|
|
|
it("should invalidate all search caches for workspace", async () => {
|
|
if (!service.isEnabled()) {
|
|
return;
|
|
}
|
|
|
|
await service.onModuleInit();
|
|
|
|
// Set multiple search caches
|
|
await service.setSearch(workspaceId, "query1", {}, searchResults);
|
|
await service.setSearch(workspaceId, "query2", {}, searchResults);
|
|
|
|
// Invalidate all
|
|
await service.invalidateSearches(workspaceId);
|
|
|
|
// Verify both are gone
|
|
const result1 = await service.getSearch(workspaceId, "query1", {});
|
|
const result2 = await service.getSearch(workspaceId, "query2", {});
|
|
|
|
expect(result1).toBeNull();
|
|
expect(result2).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("Graph Caching", () => {
|
|
const workspaceId = "test-workspace-id";
|
|
const entryId = "entry-id";
|
|
const maxDepth = 2;
|
|
const graphData = {
|
|
centerNode: { id: entryId, slug: "test", title: "Test", tags: [], depth: 0 },
|
|
nodes: [],
|
|
edges: [],
|
|
stats: { totalNodes: 1, totalEdges: 0, maxDepth },
|
|
};
|
|
|
|
it("should cache and retrieve graph data", async () => {
|
|
if (!service.isEnabled()) {
|
|
return;
|
|
}
|
|
|
|
await service.onModuleInit();
|
|
|
|
// Set cache
|
|
await service.setGraph(workspaceId, entryId, maxDepth, graphData);
|
|
|
|
// Get from cache
|
|
const result = await service.getGraph(workspaceId, entryId, maxDepth);
|
|
expect(result).toEqual(graphData);
|
|
});
|
|
|
|
it("should differentiate graphs by maxDepth", async () => {
|
|
if (!service.isEnabled()) {
|
|
return;
|
|
}
|
|
|
|
await service.onModuleInit();
|
|
|
|
const graph1 = { ...graphData, stats: { ...graphData.stats, maxDepth: 1 } };
|
|
const graph2 = { ...graphData, stats: { ...graphData.stats, maxDepth: 2 } };
|
|
|
|
await service.setGraph(workspaceId, entryId, 1, graph1);
|
|
await service.setGraph(workspaceId, entryId, 2, graph2);
|
|
|
|
const result1 = await service.getGraph(workspaceId, entryId, 1);
|
|
const result2 = await service.getGraph(workspaceId, entryId, 2);
|
|
|
|
expect(result1.stats.maxDepth).toBe(1);
|
|
expect(result2.stats.maxDepth).toBe(2);
|
|
});
|
|
|
|
it("should invalidate all graph caches for workspace", async () => {
|
|
if (!service.isEnabled()) {
|
|
return;
|
|
}
|
|
|
|
await service.onModuleInit();
|
|
|
|
// Set cache
|
|
await service.setGraph(workspaceId, entryId, maxDepth, graphData);
|
|
|
|
// Invalidate
|
|
await service.invalidateGraphs(workspaceId);
|
|
|
|
// Verify it's gone
|
|
const result = await service.getGraph(workspaceId, entryId, maxDepth);
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("Cache Statistics", () => {
|
|
it("should track hits and misses", async () => {
|
|
if (!service.isEnabled()) {
|
|
return;
|
|
}
|
|
|
|
await service.onModuleInit();
|
|
|
|
const workspaceId = "test-workspace-id";
|
|
const slug = "test-entry";
|
|
const entryData = { id: "1", slug, title: "Test" };
|
|
|
|
// Reset stats
|
|
service.resetStats();
|
|
|
|
// Miss
|
|
await service.getEntry(workspaceId, slug);
|
|
let stats = service.getStats();
|
|
expect(stats.misses).toBe(1);
|
|
expect(stats.hits).toBe(0);
|
|
|
|
// Set
|
|
await service.setEntry(workspaceId, slug, entryData);
|
|
stats = service.getStats();
|
|
expect(stats.sets).toBe(1);
|
|
|
|
// Hit
|
|
await service.getEntry(workspaceId, slug);
|
|
stats = service.getStats();
|
|
expect(stats.hits).toBe(1);
|
|
expect(stats.hitRate).toBeCloseTo(0.5); // 1 hit, 1 miss = 50%
|
|
});
|
|
|
|
it("should reset statistics", async () => {
|
|
if (!service.isEnabled()) {
|
|
return;
|
|
}
|
|
|
|
await service.onModuleInit();
|
|
|
|
const workspaceId = "test-workspace-id";
|
|
const slug = "test-entry";
|
|
|
|
await service.getEntry(workspaceId, slug); // miss
|
|
|
|
service.resetStats();
|
|
const stats = service.getStats();
|
|
|
|
expect(stats.hits).toBe(0);
|
|
expect(stats.misses).toBe(0);
|
|
expect(stats.sets).toBe(0);
|
|
expect(stats.deletes).toBe(0);
|
|
expect(stats.hitRate).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("Clear Workspace Cache", () => {
|
|
it("should clear all caches for a workspace", async () => {
|
|
if (!service.isEnabled()) {
|
|
return;
|
|
}
|
|
|
|
await service.onModuleInit();
|
|
|
|
const workspaceId = "test-workspace-id";
|
|
|
|
// Set various caches
|
|
await service.setEntry(workspaceId, "entry1", { id: "1" });
|
|
await service.setSearch(workspaceId, "query", {}, { data: [] });
|
|
await service.setGraph(workspaceId, "entry-id", 1, { nodes: [] });
|
|
|
|
// Clear all
|
|
await service.clearWorkspaceCache(workspaceId);
|
|
|
|
// Verify all are gone
|
|
const entry = await service.getEntry(workspaceId, "entry1");
|
|
const search = await service.getSearch(workspaceId, "query", {});
|
|
const graph = await service.getGraph(workspaceId, "entry-id", 1);
|
|
|
|
expect(entry).toBeNull();
|
|
expect(search).toBeNull();
|
|
expect(graph).toBeNull();
|
|
});
|
|
});
|
|
});
|