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 <[email protected]>
537 lines
17 KiB
TypeScript
537 lines
17 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
import { Test, TestingModule } from "@nestjs/testing";
|
|
import { LinkResolutionService } from "./link-resolution.service";
|
|
import { PrismaService } from "../../prisma/prisma.service";
|
|
|
|
describe("LinkResolutionService", () => {
|
|
let service: LinkResolutionService;
|
|
let prisma: PrismaService;
|
|
|
|
const workspaceId = "workspace-123";
|
|
|
|
const mockEntries = [
|
|
{
|
|
id: "entry-1",
|
|
workspaceId,
|
|
slug: "typescript-guide",
|
|
title: "TypeScript Guide",
|
|
content: "...",
|
|
contentHtml: "...",
|
|
summary: null,
|
|
status: "PUBLISHED",
|
|
visibility: "WORKSPACE",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
createdBy: "user-1",
|
|
updatedBy: "user-1",
|
|
},
|
|
{
|
|
id: "entry-2",
|
|
workspaceId,
|
|
slug: "react-hooks",
|
|
title: "React Hooks",
|
|
content: "...",
|
|
contentHtml: "...",
|
|
summary: null,
|
|
status: "PUBLISHED",
|
|
visibility: "WORKSPACE",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
createdBy: "user-1",
|
|
updatedBy: "user-1",
|
|
},
|
|
{
|
|
id: "entry-3",
|
|
workspaceId,
|
|
slug: "react-hooks-advanced",
|
|
title: "React Hooks Advanced",
|
|
content: "...",
|
|
contentHtml: "...",
|
|
summary: null,
|
|
status: "PUBLISHED",
|
|
visibility: "WORKSPACE",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
createdBy: "user-1",
|
|
updatedBy: "user-1",
|
|
},
|
|
];
|
|
|
|
const mockPrismaService = {
|
|
knowledgeEntry: {
|
|
findUnique: vi.fn(),
|
|
findFirst: vi.fn(),
|
|
findMany: vi.fn(),
|
|
},
|
|
knowledgeLink: {
|
|
findMany: vi.fn(),
|
|
},
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
LinkResolutionService,
|
|
{
|
|
provide: PrismaService,
|
|
useValue: mockPrismaService,
|
|
},
|
|
],
|
|
}).compile();
|
|
|
|
service = module.get<LinkResolutionService>(LinkResolutionService);
|
|
prisma = module.get<PrismaService>(PrismaService);
|
|
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("resolveLink", () => {
|
|
describe("Exact title match", () => {
|
|
it("should resolve link by exact title match", async () => {
|
|
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(mockEntries[0]);
|
|
|
|
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,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("should be case-sensitive for exact title match", async () => {
|
|
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
|
|
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(null);
|
|
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([]);
|
|
|
|
const result = await service.resolveLink(workspaceId, "typescript guide");
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("Slug match", () => {
|
|
it("should resolve link by slug", async () => {
|
|
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
|
|
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(mockEntries[0]);
|
|
|
|
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",
|
|
},
|
|
},
|
|
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]);
|
|
|
|
const result = await service.resolveLink(workspaceId, "TypeScript Guide");
|
|
|
|
expect(result).toBe("entry-1");
|
|
expect(mockPrismaService.knowledgeEntry.findUnique).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("Fuzzy title match", () => {
|
|
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]]);
|
|
|
|
const result = await service.resolveLink(workspaceId, "typescript guide");
|
|
|
|
expect(result).toBe("entry-1");
|
|
expect(mockPrismaService.knowledgeEntry.findMany).toHaveBeenCalledWith({
|
|
where: {
|
|
workspaceId,
|
|
title: {
|
|
mode: "insensitive",
|
|
equals: "typescript guide",
|
|
},
|
|
},
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("should return null when fuzzy match finds multiple results", async () => {
|
|
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
|
|
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(null);
|
|
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([
|
|
mockEntries[1],
|
|
mockEntries[2],
|
|
]);
|
|
|
|
const result = await service.resolveLink(workspaceId, "react hooks");
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("should return null when no match is found", async () => {
|
|
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
|
|
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(null);
|
|
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([]);
|
|
|
|
const result = await service.resolveLink(workspaceId, "Non-existent Entry");
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("Workspace scoping", () => {
|
|
it("should only resolve links within the specified workspace", async () => {
|
|
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
|
|
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(null);
|
|
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([]);
|
|
|
|
await service.resolveLink("different-workspace", "TypeScript Guide");
|
|
|
|
expect(mockPrismaService.knowledgeEntry.findFirst).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({
|
|
workspaceId: "different-workspace",
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("Edge cases", () => {
|
|
it("should handle empty string input", async () => {
|
|
const result = await service.resolveLink(workspaceId, "");
|
|
|
|
expect(result).toBeNull();
|
|
expect(mockPrismaService.knowledgeEntry.findFirst).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should handle null input", async () => {
|
|
const result = await service.resolveLink(workspaceId, null as any);
|
|
|
|
expect(result).toBeNull();
|
|
expect(mockPrismaService.knowledgeEntry.findFirst).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should handle whitespace-only input", async () => {
|
|
const result = await service.resolveLink(workspaceId, " ");
|
|
|
|
expect(result).toBeNull();
|
|
expect(mockPrismaService.knowledgeEntry.findFirst).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should trim whitespace from target before resolving", async () => {
|
|
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(mockEntries[0]);
|
|
|
|
const result = await service.resolveLink(workspaceId, " TypeScript Guide ");
|
|
|
|
expect(result).toBe("entry-1");
|
|
expect(mockPrismaService.knowledgeEntry.findFirst).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({
|
|
title: "TypeScript Guide",
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("resolveLinks", () => {
|
|
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.findUnique.mockImplementation(async ({ where }: any) => {
|
|
if (where.workspaceId_slug?.slug === "react-hooks") {
|
|
return mockEntries[1];
|
|
}
|
|
return null;
|
|
});
|
|
|
|
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([]);
|
|
|
|
const targets = ["TypeScript Guide", "react-hooks"];
|
|
const result = await service.resolveLinks(workspaceId, targets);
|
|
|
|
expect(result).toEqual({
|
|
"TypeScript Guide": "entry-1",
|
|
"react-hooks": "entry-2",
|
|
});
|
|
});
|
|
|
|
it("should handle empty array", async () => {
|
|
const result = await service.resolveLinks(workspaceId, []);
|
|
|
|
expect(result).toEqual({});
|
|
expect(mockPrismaService.knowledgeEntry.findFirst).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should handle unresolved links", async () => {
|
|
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValue(null);
|
|
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValue(null);
|
|
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([]);
|
|
|
|
const result = await service.resolveLinks(workspaceId, [
|
|
"Non-existent",
|
|
"Another-Non-existent",
|
|
]);
|
|
|
|
expect(result).toEqual({
|
|
"Non-existent": null,
|
|
"Another-Non-existent": null,
|
|
});
|
|
});
|
|
|
|
it("should deduplicate targets", async () => {
|
|
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(mockEntries[0]);
|
|
|
|
const result = await service.resolveLinks(workspaceId, [
|
|
"TypeScript Guide",
|
|
"TypeScript Guide",
|
|
]);
|
|
|
|
expect(result).toEqual({
|
|
"TypeScript Guide": "entry-1",
|
|
});
|
|
// Should only be called once for the deduplicated target
|
|
expect(mockPrismaService.knowledgeEntry.findFirst).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("getAmbiguousMatches", () => {
|
|
it("should return multiple entries that match case-insensitively", async () => {
|
|
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([
|
|
{ id: "entry-2", title: "React Hooks" },
|
|
{ id: "entry-3", title: "React Hooks Advanced" },
|
|
]);
|
|
|
|
const result = await service.getAmbiguousMatches(workspaceId, "react hooks");
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result).toEqual([
|
|
{ id: "entry-2", title: "React Hooks" },
|
|
{ id: "entry-3", title: "React Hooks Advanced" },
|
|
]);
|
|
});
|
|
|
|
it("should return empty array when no matches found", async () => {
|
|
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([]);
|
|
|
|
const result = await service.getAmbiguousMatches(workspaceId, "Non-existent");
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it("should return single entry if only one match", async () => {
|
|
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([
|
|
{ id: "entry-1", title: "TypeScript Guide" },
|
|
]);
|
|
|
|
const result = await service.getAmbiguousMatches(workspaceId, "typescript guide");
|
|
|
|
expect(result).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("resolveLinksFromContent", () => {
|
|
it("should parse and resolve wiki links from content", async () => {
|
|
const content = "Check out [[TypeScript Guide]] and [[React Hooks]] for more info.";
|
|
|
|
// Mock resolveLink for each target
|
|
mockPrismaService.knowledgeEntry.findFirst
|
|
.mockResolvedValueOnce({ id: "entry-1" }) // TypeScript Guide
|
|
.mockResolvedValueOnce({ id: "entry-2" }); // React Hooks
|
|
|
|
const result = await service.resolveLinksFromContent(content, workspaceId);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].link.target).toBe("TypeScript Guide");
|
|
expect(result[0].entryId).toBe("entry-1");
|
|
expect(result[1].link.target).toBe("React Hooks");
|
|
expect(result[1].entryId).toBe("entry-2");
|
|
});
|
|
|
|
it("should return null entryId for unresolved links", async () => {
|
|
const content = "See [[Non-existent Page]] for details.";
|
|
|
|
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
|
|
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(null);
|
|
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([]);
|
|
|
|
const result = await service.resolveLinksFromContent(content, workspaceId);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].link.target).toBe("Non-existent Page");
|
|
expect(result[0].entryId).toBeNull();
|
|
});
|
|
|
|
it("should return empty array for content with no wiki links", async () => {
|
|
const content = "This content has no wiki links.";
|
|
|
|
const result = await service.resolveLinksFromContent(content, workspaceId);
|
|
|
|
expect(result).toEqual([]);
|
|
expect(mockPrismaService.knowledgeEntry.findFirst).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should handle content with display text syntax", async () => {
|
|
const content = "Read the [[typescript-guide|TS Guide]] first.";
|
|
|
|
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
|
|
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce({
|
|
id: "entry-1",
|
|
});
|
|
|
|
const result = await service.resolveLinksFromContent(content, workspaceId);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].link.target).toBe("typescript-guide");
|
|
expect(result[0].link.displayText).toBe("TS Guide");
|
|
expect(result[0].entryId).toBe("entry-1");
|
|
});
|
|
|
|
it("should preserve link position information", async () => {
|
|
const content = "Start [[Page One]] middle [[Page Two]] end.";
|
|
|
|
mockPrismaService.knowledgeEntry.findFirst
|
|
.mockResolvedValueOnce({ id: "entry-1" })
|
|
.mockResolvedValueOnce({ id: "entry-2" });
|
|
|
|
const result = await service.resolveLinksFromContent(content, workspaceId);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].link.start).toBe(6);
|
|
expect(result[0].link.end).toBe(18);
|
|
expect(result[1].link.start).toBe(26);
|
|
expect(result[1].link.end).toBe(38);
|
|
});
|
|
});
|
|
|
|
describe("getBacklinks", () => {
|
|
it("should return all entries that link to the target entry", async () => {
|
|
const targetEntryId = "entry-target";
|
|
const mockBacklinks = [
|
|
{
|
|
id: "link-1",
|
|
sourceId: "entry-1",
|
|
targetId: targetEntryId,
|
|
linkText: "Target Page",
|
|
displayText: "Target Page",
|
|
positionStart: 10,
|
|
positionEnd: 25,
|
|
resolved: true,
|
|
context: null,
|
|
createdAt: new Date(),
|
|
source: {
|
|
id: "entry-1",
|
|
title: "TypeScript Guide",
|
|
slug: "typescript-guide",
|
|
},
|
|
},
|
|
{
|
|
id: "link-2",
|
|
sourceId: "entry-2",
|
|
targetId: targetEntryId,
|
|
linkText: "Target Page",
|
|
displayText: "See Target",
|
|
positionStart: 50,
|
|
positionEnd: 70,
|
|
resolved: true,
|
|
context: null,
|
|
createdAt: new Date(),
|
|
source: {
|
|
id: "entry-2",
|
|
title: "React Hooks",
|
|
slug: "react-hooks",
|
|
},
|
|
},
|
|
];
|
|
|
|
mockPrismaService.knowledgeLink.findMany.mockResolvedValueOnce(mockBacklinks);
|
|
|
|
const result = await service.getBacklinks(targetEntryId);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0]).toEqual({
|
|
sourceId: "entry-1",
|
|
sourceTitle: "TypeScript Guide",
|
|
sourceSlug: "typescript-guide",
|
|
linkText: "Target Page",
|
|
displayText: "Target Page",
|
|
});
|
|
expect(result[1]).toEqual({
|
|
sourceId: "entry-2",
|
|
sourceTitle: "React Hooks",
|
|
sourceSlug: "react-hooks",
|
|
linkText: "Target Page",
|
|
displayText: "See Target",
|
|
});
|
|
|
|
expect(mockPrismaService.knowledgeLink.findMany).toHaveBeenCalledWith({
|
|
where: {
|
|
targetId: targetEntryId,
|
|
resolved: true,
|
|
},
|
|
include: {
|
|
source: {
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
slug: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: {
|
|
createdAt: "desc",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("should return empty array when no backlinks exist", async () => {
|
|
mockPrismaService.knowledgeLink.findMany.mockResolvedValueOnce([]);
|
|
|
|
const result = await service.getBacklinks("entry-with-no-backlinks");
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it("should only return resolved backlinks", async () => {
|
|
const targetEntryId = "entry-target";
|
|
|
|
mockPrismaService.knowledgeLink.findMany.mockResolvedValueOnce([]);
|
|
|
|
await service.getBacklinks(targetEntryId);
|
|
|
|
expect(mockPrismaService.knowledgeLink.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({
|
|
resolved: true,
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
});
|
|
});
|