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

@@ -146,9 +146,9 @@ describe("KnowledgeGraphController", () => {
it("should throw error if entry not found", async () => {
mockGraphService.getEntryGraphBySlug.mockRejectedValue(new Error("Entry not found"));
await expect(
controller.getEntryGraph("workspace-1", "non-existent", {})
).rejects.toThrow("Entry not found");
await expect(controller.getEntryGraph("workspace-1", "non-existent", {})).rejects.toThrow(
"Entry not found"
);
});
});
});

View File

@@ -1,17 +1,17 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { Test, TestingModule } from '@nestjs/testing';
import { KnowledgeCacheService } from './cache.service';
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', () => {
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';
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],
@@ -27,35 +27,35 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)('KnowledgeCacheService', () => {
}
});
describe('Cache Enabled/Disabled', () => {
it('should be enabled by default', () => {
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';
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';
describe("Entry Caching", () => {
const workspaceId = "test-workspace-id";
const slug = "test-entry";
const entryData = {
id: 'entry-id',
id: "entry-id",
workspaceId,
slug,
title: 'Test Entry',
content: 'Test content',
title: "Test Entry",
content: "Test content",
tags: [],
};
it('should return null on cache miss', async () => {
it("should return null on cache miss", async () => {
if (!service.isEnabled()) {
return; // Skip if cache is disabled
}
@@ -65,206 +65,206 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)('KnowledgeCacheService', () => {
expect(result).toBeNull();
});
it('should cache and retrieve entry data', async () => {
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 () => {
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 };
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 () => {
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 () => {
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 () => {
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);
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', {});
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';
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 },
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 () => {
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 () => {
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 () => {
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 () => {
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' };
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();
@@ -272,21 +272,21 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)('KnowledgeCacheService', () => {
expect(stats.hitRate).toBeCloseTo(0.5); // 1 hit, 1 miss = 50%
});
it('should reset statistics', async () => {
it("should reset statistics", async () => {
if (!service.isEnabled()) {
return;
}
await service.onModuleInit();
const workspaceId = 'test-workspace-id';
const slug = 'test-entry';
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);
@@ -295,29 +295,29 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)('KnowledgeCacheService', () => {
});
});
describe('Clear Workspace Cache', () => {
it('should clear all caches for a workspace', async () => {
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';
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: [] });
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);
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();

View File

@@ -271,9 +271,7 @@ describe("GraphService", () => {
});
it("should filter by status", async () => {
const entries = [
{ ...mockEntry, id: "entry-1", status: "PUBLISHED", tags: [] },
];
const entries = [{ ...mockEntry, id: "entry-1", status: "PUBLISHED", tags: [] }];
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue(entries);
mockPrismaService.knowledgeLink.findMany.mockResolvedValue([]);
@@ -351,9 +349,7 @@ describe("GraphService", () => {
{ id: "entry-1", slug: "entry-1", title: "Entry 1", link_count: "5" },
{ id: "entry-2", slug: "entry-2", title: "Entry 2", link_count: "3" },
]);
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([
{ id: "orphan-1" },
]);
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([{ id: "orphan-1" }]);
const result = await service.getGraphStats("workspace-1");

View File

@@ -170,9 +170,9 @@ This is the content of the entry.`;
path: "",
};
await expect(
service.importEntries(workspaceId, userId, file)
).rejects.toThrow(BadRequestException);
await expect(service.importEntries(workspaceId, userId, file)).rejects.toThrow(
BadRequestException
);
});
it("should handle import errors gracefully", async () => {
@@ -195,9 +195,7 @@ Content`;
path: "",
};
mockKnowledgeService.create.mockRejectedValue(
new Error("Database error")
);
mockKnowledgeService.create.mockRejectedValue(new Error("Database error"));
const result = await service.importEntries(workspaceId, userId, file);
@@ -240,10 +238,7 @@ title: Empty Entry
it("should export entries as markdown format", async () => {
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([mockEntry]);
const result = await service.exportEntries(
workspaceId,
ExportFormat.MARKDOWN
);
const result = await service.exportEntries(workspaceId, ExportFormat.MARKDOWN);
expect(result.filename).toMatch(/knowledge-export-\d{4}-\d{2}-\d{2}\.zip/);
expect(result.stream).toBeDefined();
@@ -289,9 +284,9 @@ title: Empty Entry
it("should throw error when no entries found", async () => {
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([]);
await expect(
service.exportEntries(workspaceId, ExportFormat.MARKDOWN)
).rejects.toThrow(BadRequestException);
await expect(service.exportEntries(workspaceId, ExportFormat.MARKDOWN)).rejects.toThrow(
BadRequestException
);
});
});
});

View File

@@ -88,27 +88,20 @@ describe("LinkResolutionService", () => {
describe("resolveLink", () => {
describe("Exact title match", () => {
it("should resolve link by exact title match", async () => {
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(
mockEntries[0]
);
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(mockEntries[0]);
const result = await service.resolveLink(
workspaceId,
"TypeScript Guide"
);
const result = await service.resolveLink(workspaceId, "TypeScript Guide");
expect(result).toBe("entry-1");
expect(mockPrismaService.knowledgeEntry.findFirst).toHaveBeenCalledWith(
{
where: {
workspaceId,
title: "TypeScript Guide",
},
select: {
id: true,
},
}
);
expect(mockPrismaService.knowledgeEntry.findFirst).toHaveBeenCalledWith({
where: {
workspaceId,
title: "TypeScript Guide",
},
select: {
id: true,
},
});
});
it("should be case-sensitive for exact title match", async () => {
@@ -116,10 +109,7 @@ describe("LinkResolutionService", () => {
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(null);
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([]);
const result = await service.resolveLink(
workspaceId,
"typescript guide"
);
const result = await service.resolveLink(workspaceId, "typescript guide");
expect(result).toBeNull();
});
@@ -128,41 +118,29 @@ describe("LinkResolutionService", () => {
describe("Slug match", () => {
it("should resolve link by slug", async () => {
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(
mockEntries[0]
);
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(mockEntries[0]);
const result = await service.resolveLink(
workspaceId,
"typescript-guide"
);
const result = await service.resolveLink(workspaceId, "typescript-guide");
expect(result).toBe("entry-1");
expect(mockPrismaService.knowledgeEntry.findUnique).toHaveBeenCalledWith(
{
where: {
workspaceId_slug: {
workspaceId,
slug: "typescript-guide",
},
expect(mockPrismaService.knowledgeEntry.findUnique).toHaveBeenCalledWith({
where: {
workspaceId_slug: {
workspaceId,
slug: "typescript-guide",
},
select: {
id: true,
},
}
);
},
select: {
id: true,
},
});
});
it("should prioritize exact title match over slug match", async () => {
// If exact title matches, slug should not be checked
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(
mockEntries[0]
);
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(mockEntries[0]);
const result = await service.resolveLink(
workspaceId,
"TypeScript Guide"
);
const result = await service.resolveLink(workspaceId, "TypeScript Guide");
expect(result).toBe("entry-1");
expect(mockPrismaService.knowledgeEntry.findUnique).not.toHaveBeenCalled();
@@ -173,14 +151,9 @@ describe("LinkResolutionService", () => {
it("should resolve link by case-insensitive fuzzy match", async () => {
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(null);
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([
mockEntries[0],
]);
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([mockEntries[0]]);
const result = await service.resolveLink(
workspaceId,
"typescript guide"
);
const result = await service.resolveLink(workspaceId, "typescript guide");
expect(result).toBe("entry-1");
expect(mockPrismaService.knowledgeEntry.findMany).toHaveBeenCalledWith({
@@ -216,10 +189,7 @@ describe("LinkResolutionService", () => {
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(null);
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([]);
const result = await service.resolveLink(
workspaceId,
"Non-existent Entry"
);
const result = await service.resolveLink(workspaceId, "Non-existent Entry");
expect(result).toBeNull();
});
@@ -266,14 +236,9 @@ describe("LinkResolutionService", () => {
});
it("should trim whitespace from target before resolving", async () => {
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(
mockEntries[0]
);
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(mockEntries[0]);
const result = await service.resolveLink(
workspaceId,
" TypeScript Guide "
);
const result = await service.resolveLink(workspaceId, " TypeScript Guide ");
expect(result).toBe("entry-1");
expect(mockPrismaService.knowledgeEntry.findFirst).toHaveBeenCalledWith(
@@ -291,23 +256,19 @@ describe("LinkResolutionService", () => {
it("should resolve multiple links in batch", async () => {
// First link: "TypeScript Guide" -> exact title match
// Second link: "react-hooks" -> slug match
mockPrismaService.knowledgeEntry.findFirst.mockImplementation(
async ({ where }: any) => {
if (where.title === "TypeScript Guide") {
return mockEntries[0];
}
return null;
mockPrismaService.knowledgeEntry.findFirst.mockImplementation(async ({ where }: any) => {
if (where.title === "TypeScript Guide") {
return mockEntries[0];
}
);
return null;
});
mockPrismaService.knowledgeEntry.findUnique.mockImplementation(
async ({ where }: any) => {
if (where.workspaceId_slug?.slug === "react-hooks") {
return mockEntries[1];
}
return null;
mockPrismaService.knowledgeEntry.findUnique.mockImplementation(async ({ where }: any) => {
if (where.workspaceId_slug?.slug === "react-hooks") {
return mockEntries[1];
}
);
return null;
});
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([]);
@@ -344,9 +305,7 @@ describe("LinkResolutionService", () => {
});
it("should deduplicate targets", async () => {
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(
mockEntries[0]
);
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(mockEntries[0]);
const result = await service.resolveLinks(workspaceId, [
"TypeScript Guide",
@@ -357,9 +316,7 @@ describe("LinkResolutionService", () => {
"TypeScript Guide": "entry-1",
});
// Should only be called once for the deduplicated target
expect(mockPrismaService.knowledgeEntry.findFirst).toHaveBeenCalledTimes(
1
);
expect(mockPrismaService.knowledgeEntry.findFirst).toHaveBeenCalledTimes(1);
});
});
@@ -370,10 +327,7 @@ describe("LinkResolutionService", () => {
{ id: "entry-3", title: "React Hooks Advanced" },
]);
const result = await service.getAmbiguousMatches(
workspaceId,
"react hooks"
);
const result = await service.getAmbiguousMatches(workspaceId, "react hooks");
expect(result).toHaveLength(2);
expect(result).toEqual([
@@ -385,10 +339,7 @@ describe("LinkResolutionService", () => {
it("should return empty array when no matches found", async () => {
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([]);
const result = await service.getAmbiguousMatches(
workspaceId,
"Non-existent"
);
const result = await service.getAmbiguousMatches(workspaceId, "Non-existent");
expect(result).toEqual([]);
});
@@ -398,10 +349,7 @@ describe("LinkResolutionService", () => {
{ id: "entry-1", title: "TypeScript Guide" },
]);
const result = await service.getAmbiguousMatches(
workspaceId,
"typescript guide"
);
const result = await service.getAmbiguousMatches(workspaceId, "typescript guide");
expect(result).toHaveLength(1);
});
@@ -409,8 +357,7 @@ describe("LinkResolutionService", () => {
describe("resolveLinksFromContent", () => {
it("should parse and resolve wiki links from content", async () => {
const content =
"Check out [[TypeScript Guide]] and [[React Hooks]] for more info.";
const content = "Check out [[TypeScript Guide]] and [[React Hooks]] for more info.";
// Mock resolveLink for each target
mockPrismaService.knowledgeEntry.findFirst
@@ -522,9 +469,7 @@ describe("LinkResolutionService", () => {
},
];
mockPrismaService.knowledgeLink.findMany.mockResolvedValueOnce(
mockBacklinks
);
mockPrismaService.knowledgeLink.findMany.mockResolvedValueOnce(mockBacklinks);
const result = await service.getBacklinks(targetEntryId);

View File

@@ -26,7 +26,7 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)("Semantic Search Integration", (
// Initialize services
prisma = new PrismaClient();
const prismaService = prisma as unknown as PrismaService;
// Mock cache service for testing
cacheService = {
getSearch: async () => null,
@@ -37,11 +37,7 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)("Semantic Search Integration", (
} as unknown as KnowledgeCacheService;
embeddingService = new EmbeddingService(prismaService);
searchService = new SearchService(
prismaService,
cacheService,
embeddingService
);
searchService = new SearchService(prismaService, cacheService, embeddingService);
// Create test workspace and user
const workspace = await prisma.workspace.create({
@@ -84,10 +80,7 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)("Semantic Search Integration", (
const title = "Introduction to PostgreSQL";
const content = "PostgreSQL is a powerful open-source database.";
const prepared = embeddingService.prepareContentForEmbedding(
title,
content
);
const prepared = embeddingService.prepareContentForEmbedding(title, content);
// Title should appear twice for weighting
expect(prepared).toContain(title);
@@ -122,10 +115,7 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)("Semantic Search Integration", (
it("should skip semantic search if OpenAI not configured", async () => {
if (!embeddingService.isConfigured()) {
await expect(
searchService.semanticSearch(
"database performance",
testWorkspaceId
)
searchService.semanticSearch("database performance", testWorkspaceId)
).rejects.toThrow();
} else {
// If configured, this is expected to work (tested below)
@@ -156,10 +146,7 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)("Semantic Search Integration", (
entry.title,
entry.content
);
await embeddingService.generateAndStoreEmbedding(
created.id,
preparedContent
);
await embeddingService.generateAndStoreEmbedding(created.id, preparedContent);
}
// Wait a bit for embeddings to be stored
@@ -175,9 +162,7 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)("Semantic Search Integration", (
expect(results.data.length).toBeGreaterThan(0);
// PostgreSQL entry should rank high for "relational database"
const postgresEntry = results.data.find(
(r) => r.slug === "postgresql-intro"
);
const postgresEntry = results.data.find((r) => r.slug === "postgresql-intro");
expect(postgresEntry).toBeDefined();
expect(postgresEntry!.rank).toBeGreaterThan(0);
},
@@ -187,18 +172,13 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)("Semantic Search Integration", (
it.skipIf(!process.env["OPENAI_API_KEY"])(
"should perform hybrid search combining vector and keyword",
async () => {
const results = await searchService.hybridSearch(
"indexing",
testWorkspaceId
);
const results = await searchService.hybridSearch("indexing", testWorkspaceId);
// Should return results
expect(results.data.length).toBeGreaterThan(0);
// Should find the indexing entry
const indexingEntry = results.data.find(
(r) => r.slug === "database-indexing"
);
const indexingEntry = results.data.find((r) => r.slug === "database-indexing");
expect(indexingEntry).toBeDefined();
},
30000
@@ -230,15 +210,10 @@ describe.skipIf(!process.env.INTEGRATION_TESTS)("Semantic Search Integration", (
// Batch generate embeddings
const entriesForEmbedding = entries.map((e) => ({
id: e.id,
content: embeddingService.prepareContentForEmbedding(
e.title,
e.content
),
content: embeddingService.prepareContentForEmbedding(e.title, e.content),
}));
const successCount = await embeddingService.batchGenerateEmbeddings(
entriesForEmbedding
);
const successCount = await embeddingService.batchGenerateEmbeddings(entriesForEmbedding);
expect(successCount).toBe(3);

View File

@@ -48,10 +48,7 @@ describe("TagsController", () => {
const result = await controller.create(createDto, workspaceId);
expect(result).toEqual(mockTag);
expect(mockTagsService.create).toHaveBeenCalledWith(
workspaceId,
createDto
);
expect(mockTagsService.create).toHaveBeenCalledWith(workspaceId, createDto);
});
it("should pass undefined workspaceId to service (validation handled by guards)", async () => {
@@ -108,10 +105,7 @@ describe("TagsController", () => {
const result = await controller.findOne("architecture", workspaceId);
expect(result).toEqual(mockTagWithCount);
expect(mockTagsService.findOne).toHaveBeenCalledWith(
"architecture",
workspaceId
);
expect(mockTagsService.findOne).toHaveBeenCalledWith("architecture", workspaceId);
});
it("should pass undefined workspaceId to service (validation handled by guards)", async () => {
@@ -138,18 +132,10 @@ describe("TagsController", () => {
mockTagsService.update.mockResolvedValue(updatedTag);
const result = await controller.update(
"architecture",
updateDto,
workspaceId
);
const result = await controller.update("architecture", updateDto, workspaceId);
expect(result).toEqual(updatedTag);
expect(mockTagsService.update).toHaveBeenCalledWith(
"architecture",
workspaceId,
updateDto
);
expect(mockTagsService.update).toHaveBeenCalledWith("architecture", workspaceId, updateDto);
});
it("should pass undefined workspaceId to service (validation handled by guards)", async () => {
@@ -171,10 +157,7 @@ describe("TagsController", () => {
await controller.remove("architecture", workspaceId);
expect(mockTagsService.remove).toHaveBeenCalledWith(
"architecture",
workspaceId
);
expect(mockTagsService.remove).toHaveBeenCalledWith("architecture", workspaceId);
});
it("should pass undefined workspaceId to service (validation handled by guards)", async () => {
@@ -206,10 +189,7 @@ describe("TagsController", () => {
const result = await controller.getEntries("architecture", workspaceId);
expect(result).toEqual(mockEntries);
expect(mockTagsService.getEntriesWithTag).toHaveBeenCalledWith(
"architecture",
workspaceId
);
expect(mockTagsService.getEntriesWithTag).toHaveBeenCalledWith("architecture", workspaceId);
});
it("should pass undefined workspaceId to service (validation handled by guards)", async () => {

View File

@@ -2,11 +2,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { TagsService } from "./tags.service";
import { PrismaService } from "../prisma/prisma.service";
import {
NotFoundException,
ConflictException,
BadRequestException,
} from "@nestjs/common";
import { NotFoundException, ConflictException, BadRequestException } from "@nestjs/common";
import type { CreateTagDto, UpdateTagDto } from "./dto";
describe("TagsService", () => {
@@ -113,9 +109,7 @@ describe("TagsService", () => {
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(mockTag);
await expect(service.create(workspaceId, createDto)).rejects.toThrow(
ConflictException
);
await expect(service.create(workspaceId, createDto)).rejects.toThrow(ConflictException);
});
it("should throw BadRequestException for invalid slug format", async () => {
@@ -124,9 +118,7 @@ describe("TagsService", () => {
slug: "Invalid_Slug!",
};
await expect(service.create(workspaceId, createDto)).rejects.toThrow(
BadRequestException
);
await expect(service.create(workspaceId, createDto)).rejects.toThrow(BadRequestException);
});
it("should generate slug from name with spaces and special chars", async () => {
@@ -135,12 +127,10 @@ describe("TagsService", () => {
};
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null);
mockPrismaService.knowledgeTag.create.mockImplementation(
async ({ data }: any) => ({
...mockTag,
slug: data.slug,
})
);
mockPrismaService.knowledgeTag.create.mockImplementation(async ({ data }: any) => ({
...mockTag,
slug: data.slug,
}));
const result = await service.create(workspaceId, createDto);
@@ -183,9 +173,7 @@ describe("TagsService", () => {
describe("findOne", () => {
it("should return a tag by slug", async () => {
const mockTagWithCount = { ...mockTag, _count: { entries: 5 } };
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(
mockTagWithCount
);
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(mockTagWithCount);
const result = await service.findOne("architecture", workspaceId);
@@ -208,9 +196,7 @@ describe("TagsService", () => {
it("should throw NotFoundException if tag not found", async () => {
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null);
await expect(
service.findOne("nonexistent", workspaceId)
).rejects.toThrow(NotFoundException);
await expect(service.findOne("nonexistent", workspaceId)).rejects.toThrow(NotFoundException);
});
});
@@ -245,9 +231,9 @@ describe("TagsService", () => {
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null);
await expect(
service.update("nonexistent", workspaceId, updateDto)
).rejects.toThrow(NotFoundException);
await expect(service.update("nonexistent", workspaceId, updateDto)).rejects.toThrow(
NotFoundException
);
});
it("should throw ConflictException if new slug conflicts", async () => {
@@ -263,9 +249,9 @@ describe("TagsService", () => {
slug: "design",
} as any);
await expect(
service.update("architecture", workspaceId, updateDto)
).rejects.toThrow(ConflictException);
await expect(service.update("architecture", workspaceId, updateDto)).rejects.toThrow(
ConflictException
);
});
});
@@ -292,9 +278,7 @@ describe("TagsService", () => {
it("should throw NotFoundException if tag not found", async () => {
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null);
await expect(
service.remove("nonexistent", workspaceId)
).rejects.toThrow(NotFoundException);
await expect(service.remove("nonexistent", workspaceId)).rejects.toThrow(NotFoundException);
});
});
@@ -398,9 +382,9 @@ describe("TagsService", () => {
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null);
await expect(
service.findOrCreateTags(workspaceId, slugs, false)
).rejects.toThrow(NotFoundException);
await expect(service.findOrCreateTags(workspaceId, slugs, false)).rejects.toThrow(
NotFoundException
);
});
});
});

View File

@@ -17,9 +17,9 @@ The `wiki-link-parser.ts` utility provides parsing of wiki-style `[[links]]` fro
### Usage
```typescript
import { parseWikiLinks } from './utils/wiki-link-parser';
import { parseWikiLinks } from "./utils/wiki-link-parser";
const content = 'See [[Main Page]] and [[Getting Started|start here]].';
const content = "See [[Main Page]] and [[Getting Started|start here]].";
const links = parseWikiLinks(content);
// Result:
@@ -44,32 +44,41 @@ const links = parseWikiLinks(content);
### Supported Link Formats
#### Basic Link (by title)
```markdown
[[Page Name]]
```
Links to a page by its title. Display text will be "Page Name".
#### Link with Display Text
```markdown
[[Page Name|custom display]]
```
Links to "Page Name" but displays "custom display".
#### Link by Slug
```markdown
[[page-slug-name]]
```
Links to a page by its URL slug (kebab-case).
### Edge Cases
#### Nested Brackets
```markdown
[[Page [with] brackets]] ✓ Parsed correctly
[[Page [with] brackets]] ✓ Parsed correctly
```
Single brackets inside link text are allowed.
#### Code Blocks (Not Parsed)
```markdown
Use `[[WikiLink]]` syntax for linking.
@@ -77,36 +86,41 @@ Use `[[WikiLink]]` syntax for linking.
const link = "[[not parsed]]";
\`\`\`
```
Links inside inline code or fenced code blocks are ignored.
#### Escaped Brackets
```markdown
\[[not a link]] but [[real link]] works
```
Escaped brackets are not parsed as links.
#### Empty or Invalid Links
```markdown
[[]] ✗ Empty link (ignored)
[[ ]] ✗ Whitespace only (ignored)
[[ Target ]] ✓ Trimmed to "Target"
[[]] ✗ Whitespace only (ignored)
[[Target]] ✓ Trimmed to "Target"
```
### Return Type
```typescript
interface WikiLink {
raw: string; // Full matched text: "[[Page Name]]"
target: string; // Target page: "Page Name"
raw: string; // Full matched text: "[[Page Name]]"
target: string; // Target page: "Page Name"
displayText: string; // Display text: "Page Name" or custom
start: number; // Start position in content
end: number; // End position in content
start: number; // Start position in content
end: number; // End position in content
}
```
### Testing
Comprehensive test suite (100% coverage) includes:
- Basic parsing (single, multiple, consecutive links)
- Display text variations
- Edge cases (brackets, escapes, empty links)
@@ -116,6 +130,7 @@ Comprehensive test suite (100% coverage) includes:
- Malformed input handling
Run tests:
```bash
pnpm test --filter=@mosaic/api -- wiki-link-parser.spec.ts
```
@@ -130,6 +145,7 @@ This parser is designed to work with the Knowledge Module's linking system:
4. **Link Rendering**: Replace `[[links]]` with HTML anchors
See related issues:
- #59 - Wiki-link parser (this implementation)
- Future: Link resolution and storage
- Future: Backlink display and navigation
@@ -151,33 +167,38 @@ The `markdown.ts` utility provides secure markdown rendering with GFM (GitHub Fl
### Usage
```typescript
import { renderMarkdown, markdownToPlainText } from './utils/markdown';
import { renderMarkdown, markdownToPlainText } from "./utils/markdown";
// Render markdown to HTML (async)
const html = await renderMarkdown('# Hello **World**');
const html = await renderMarkdown("# Hello **World**");
// Result: <h1 id="hello-world">Hello <strong>World</strong></h1>
// Extract plain text (for search indexing)
const plainText = await markdownToPlainText('# Hello **World**');
const plainText = await markdownToPlainText("# Hello **World**");
// Result: "Hello World"
```
### Supported Markdown Features
#### Basic Formatting
- **Bold**: `**text**` or `__text__`
- *Italic*: `*text*` or `_text_`
- _Italic_: `*text*` or `_text_`
- ~~Strikethrough~~: `~~text~~`
- `Inline code`: `` `code` ``
#### Headers
```markdown
# H1
## H2
### H3
```
#### Lists
```markdown
- Unordered list
- Nested item
@@ -187,19 +208,22 @@ const plainText = await markdownToPlainText('# Hello **World**');
```
#### Task Lists
```markdown
- [ ] Unchecked task
- [x] Completed task
```
#### Tables
```markdown
| Header 1 | Header 2 |
|----------|----------|
| -------- | -------- |
| Cell 1 | Cell 2 |
```
#### Code Blocks
````markdown
```typescript
const greeting: string = "Hello";
@@ -208,12 +232,14 @@ console.log(greeting);
````
#### Links and Images
```markdown
[Link text](https://example.com)
![Alt text](https://example.com/image.png)
```
#### Blockquotes
```markdown
> This is a quote
> Multi-line quote
@@ -233,6 +259,7 @@ The renderer implements multiple layers of security:
### Testing
Comprehensive test suite covers:
- Basic markdown rendering
- GFM features (tables, task lists, strikethrough)
- Code syntax highlighting
@@ -240,6 +267,7 @@ Comprehensive test suite covers:
- Edge cases (unicode, long content, nested structures)
Run tests:
```bash
pnpm test --filter=@mosaic/api -- markdown.spec.ts
```

View File

@@ -1,9 +1,5 @@
import { describe, it, expect } from "vitest";
import {
renderMarkdown,
renderMarkdownSync,
markdownToPlainText,
} from "./markdown";
import { renderMarkdown, renderMarkdownSync, markdownToPlainText } from "./markdown";
describe("Markdown Rendering", () => {
describe("renderMarkdown", () => {
@@ -77,7 +73,7 @@ describe("Markdown Rendering", () => {
const html = await renderMarkdown(markdown);
expect(html).toContain('<input');
expect(html).toContain("<input");
expect(html).toContain('type="checkbox"');
expect(html).toContain('disabled="disabled"'); // Should be disabled for safety
});
@@ -145,16 +141,17 @@ plain text code
const markdown = "![Alt text](https://example.com/image.png)";
const html = await renderMarkdown(markdown);
expect(html).toContain('<img');
expect(html).toContain("<img");
expect(html).toContain('src="https://example.com/image.png"');
expect(html).toContain('alt="Alt text"');
});
it("should allow data URIs for images", async () => {
const markdown = "![Image](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==)";
const markdown =
"![Image](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==)";
const html = await renderMarkdown(markdown);
expect(html).toContain('<img');
expect(html).toContain("<img");
expect(html).toContain('src="data:image/png;base64');
});
});
@@ -164,7 +161,7 @@ plain text code
const markdown = "# My Header Title";
const html = await renderMarkdown(markdown);
expect(html).toContain('<h1');
expect(html).toContain("<h1");
expect(html).toContain('id="');
});
@@ -282,7 +279,7 @@ plain text code
});
it("should strip all HTML tags", async () => {
const markdown = '[Link](https://example.com)\n\n![Image](image.png)';
const markdown = "[Link](https://example.com)\n\n![Image](image.png)";
const plainText = await markdownToPlainText(markdown);
expect(plainText).not.toContain("<a");

View File

@@ -333,9 +333,7 @@ const link = "[[Not A Link]]";
expect(links[0].start).toBe(5);
expect(links[0].end).toBe(23);
expect(content.substring(links[0].start, links[0].end)).toBe(
"[[Target|Display]]"
);
expect(content.substring(links[0].start, links[0].end)).toBe("[[Target|Display]]");
});
it("should track positions in multiline content", () => {