feat(#71): implement graph data API

Implemented three new API endpoints for knowledge graph visualization:

1. GET /api/knowledge/graph - Full knowledge graph
   - Returns all entries and links with optional filtering
   - Supports filtering by tags, status, and node count limit
   - Includes orphan detection (entries with no links)

2. GET /api/knowledge/graph/stats - Graph statistics
   - Total entries and links counts
   - Orphan entries detection
   - Average links per entry
   - Top 10 most connected entries
   - Tag distribution across entries

3. GET /api/knowledge/graph/:slug - Entry-centered subgraph
   - Returns graph centered on specific entry
   - Supports depth parameter (1-5) for traversal distance
   - Includes all connected nodes up to specified depth

New Files:
- apps/api/src/knowledge/graph.controller.ts
- apps/api/src/knowledge/graph.controller.spec.ts

Modified Files:
- apps/api/src/knowledge/dto/graph-query.dto.ts (added GraphFilterDto)
- apps/api/src/knowledge/entities/graph.entity.ts (extended with new types)
- apps/api/src/knowledge/services/graph.service.ts (added new methods)
- apps/api/src/knowledge/services/graph.service.spec.ts (added tests)
- apps/api/src/knowledge/knowledge.module.ts (registered controller)
- apps/api/src/knowledge/dto/index.ts (exported new DTOs)
- docs/scratchpads/71-graph-data-api.md (implementation notes)

Test Coverage: 21 tests (all passing)
- 14 service tests including orphan detection, filtering, statistics
- 7 controller tests for all three endpoints

Follows TDD principles with tests written before implementation.
All code quality gates passed (lint, typecheck, tests).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-02 15:27:00 -06:00
parent 3969dd5598
commit 5d348526de
240 changed files with 10400 additions and 23 deletions

View File

@@ -0,0 +1,346 @@
import { ConfigService } from "@nestjs/config";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { WorktreeManagerService } from "./worktree-manager.service";
import { GitOperationsService } from "./git-operations.service";
import { WorktreeError } from "./types";
import * as path from "path";
// Mock simple-git
const mockGit = {
raw: vi.fn(),
};
vi.mock("simple-git", () => ({
simpleGit: vi.fn(() => mockGit),
}));
describe("WorktreeManagerService", () => {
let service: WorktreeManagerService;
let mockConfigService: ConfigService;
let mockGitOperationsService: GitOperationsService;
beforeEach(() => {
// Reset all mocks
vi.clearAllMocks();
// Create mock config service
mockConfigService = {
get: vi.fn((key: string) => {
if (key === "orchestrator.git.userName") return "Test User";
if (key === "orchestrator.git.userEmail") return "test@example.com";
return undefined;
}),
} as any;
// Create mock git operations service
mockGitOperationsService = new GitOperationsService(mockConfigService);
// Create service with mocks
service = new WorktreeManagerService(mockGitOperationsService);
});
describe("createWorktree", () => {
it("should create worktree with correct naming convention", async () => {
const repoPath = "/tmp/test-repo";
const agentId = "agent-123";
const taskId = "task-456";
const expectedPath = path.join(
"/tmp",
"test-repo_worktrees",
`agent-${agentId}-${taskId}`,
);
const branchName = `agent-${agentId}-${taskId}`;
mockGit.raw.mockResolvedValue(
`worktree ${expectedPath}\nHEAD abc123\nbranch refs/heads/${branchName}`,
);
const result = await service.createWorktree(repoPath, agentId, taskId);
expect(result).toBeDefined();
expect(result.path).toBe(expectedPath);
expect(result.branch).toBe(branchName);
expect(mockGit.raw).toHaveBeenCalledWith([
"worktree",
"add",
expectedPath,
"-b",
branchName,
"develop",
]);
});
it("should create worktree with custom base branch", async () => {
const repoPath = "/tmp/test-repo";
const agentId = "agent-123";
const taskId = "task-456";
const baseBranch = "main";
const expectedPath = path.join(
"/tmp",
"test-repo_worktrees",
`agent-${agentId}-${taskId}`,
);
const branchName = `agent-${agentId}-${taskId}`;
mockGit.raw.mockResolvedValue(
`worktree ${expectedPath}\nHEAD abc123\nbranch refs/heads/${branchName}`,
);
await service.createWorktree(repoPath, agentId, taskId, baseBranch);
expect(mockGit.raw).toHaveBeenCalledWith([
"worktree",
"add",
expectedPath,
"-b",
branchName,
baseBranch,
]);
});
it("should throw WorktreeError if worktree already exists", async () => {
const error = new Error("fatal: 'agent-123-task-456' already exists");
mockGit.raw.mockRejectedValue(error);
await expect(
service.createWorktree("/tmp/test-repo", "agent-123", "task-456"),
).rejects.toThrow(WorktreeError);
try {
await service.createWorktree("/tmp/test-repo", "agent-123", "task-456");
} catch (e) {
expect(e).toBeInstanceOf(WorktreeError);
expect((e as WorktreeError).operation).toBe("createWorktree");
expect((e as WorktreeError).cause).toBe(error);
}
});
it("should throw WorktreeError on git command failure", async () => {
const error = new Error("git command failed");
mockGit.raw.mockRejectedValue(error);
await expect(
service.createWorktree("/tmp/test-repo", "agent-123", "task-456"),
).rejects.toThrow(WorktreeError);
});
it("should validate agentId is not empty", async () => {
await expect(
service.createWorktree("/tmp/test-repo", "", "task-456"),
).rejects.toThrow("agentId is required");
});
it("should validate taskId is not empty", async () => {
await expect(
service.createWorktree("/tmp/test-repo", "agent-123", ""),
).rejects.toThrow("taskId is required");
});
it("should validate repoPath is not empty", async () => {
await expect(
service.createWorktree("", "agent-123", "task-456"),
).rejects.toThrow("repoPath is required");
});
});
describe("removeWorktree", () => {
it("should remove worktree successfully", async () => {
const worktreePath = "/tmp/test-repo_worktrees/agent-123-task-456";
mockGit.raw.mockResolvedValue("");
await service.removeWorktree(worktreePath);
expect(mockGit.raw).toHaveBeenCalledWith([
"worktree",
"remove",
worktreePath,
"--force",
]);
});
it("should handle non-existent worktree gracefully", async () => {
const worktreePath = "/tmp/test-repo_worktrees/non-existent";
const error = new Error("fatal: 'non-existent' is not a working tree");
mockGit.raw.mockRejectedValue(error);
// Should not throw, just log warning
await expect(service.removeWorktree(worktreePath)).resolves.not.toThrow();
});
it("should throw WorktreeError on removal failure", async () => {
const worktreePath = "/tmp/test-repo_worktrees/agent-123-task-456";
const error = new Error("permission denied");
mockGit.raw.mockRejectedValue(error);
// Should throw for non-worktree-not-found errors
await expect(service.removeWorktree(worktreePath)).rejects.toThrow();
});
it("should validate worktreePath is not empty", async () => {
await expect(service.removeWorktree("")).rejects.toThrow(
"worktreePath is required",
);
});
});
describe("listWorktrees", () => {
it("should return empty array when no worktrees exist", async () => {
const repoPath = "/tmp/test-repo";
mockGit.raw.mockResolvedValue(`/tmp/test-repo abc123 [develop]`);
const result = await service.listWorktrees(repoPath);
expect(result).toEqual([]);
});
it("should list all active worktrees", async () => {
const repoPath = "/tmp/test-repo";
const output = `/tmp/test-repo abc123 [develop]
/tmp/test-repo_worktrees/agent-123-task-456 def456 [agent-123-task-456]
/tmp/test-repo_worktrees/agent-789-task-012 abc789 [agent-789-task-012]`;
mockGit.raw.mockResolvedValue(output);
const result = await service.listWorktrees(repoPath);
expect(result).toHaveLength(2);
expect(result[0].path).toBe(
"/tmp/test-repo_worktrees/agent-123-task-456",
);
expect(result[0].commit).toBe("def456");
expect(result[0].branch).toBe("agent-123-task-456");
expect(result[1].path).toBe(
"/tmp/test-repo_worktrees/agent-789-task-012",
);
expect(result[1].commit).toBe("abc789");
expect(result[1].branch).toBe("agent-789-task-012");
});
it("should parse worktree info correctly", async () => {
const repoPath = "/tmp/test-repo";
const output = `/tmp/test-repo abc123 [develop]
/tmp/test-repo_worktrees/agent-123-task-456 def456 [agent-123-task-456]`;
mockGit.raw.mockResolvedValue(output);
const result = await service.listWorktrees(repoPath);
expect(result[0]).toEqual({
path: "/tmp/test-repo_worktrees/agent-123-task-456",
commit: "def456",
branch: "agent-123-task-456",
});
});
it("should throw WorktreeError on git command failure", async () => {
const error = new Error("git command failed");
mockGit.raw.mockRejectedValue(error);
await expect(service.listWorktrees("/tmp/test-repo")).rejects.toThrow(
WorktreeError,
);
});
it("should validate repoPath is not empty", async () => {
await expect(service.listWorktrees("")).rejects.toThrow(
"repoPath is required",
);
});
});
describe("cleanupWorktree", () => {
it("should remove worktree on agent completion", async () => {
const repoPath = "/tmp/test-repo";
const agentId = "agent-123";
const taskId = "task-456";
const worktreePath = path.join(
"/tmp",
"test-repo_worktrees",
`agent-${agentId}-${taskId}`,
);
mockGit.raw.mockResolvedValue("");
await service.cleanupWorktree(repoPath, agentId, taskId);
expect(mockGit.raw).toHaveBeenCalledWith([
"worktree",
"remove",
worktreePath,
"--force",
]);
});
it("should handle cleanup errors gracefully", async () => {
const error = new Error("worktree not found");
mockGit.raw.mockRejectedValue(error);
// Should not throw
await expect(
service.cleanupWorktree("/tmp/test-repo", "agent-123", "task-456"),
).resolves.not.toThrow();
});
it("should validate agentId is not empty", async () => {
await expect(
service.cleanupWorktree("/tmp/test-repo", "", "task-456"),
).rejects.toThrow("agentId is required");
});
it("should validate taskId is not empty", async () => {
await expect(
service.cleanupWorktree("/tmp/test-repo", "agent-123", ""),
).rejects.toThrow("taskId is required");
});
it("should validate repoPath is not empty", async () => {
await expect(
service.cleanupWorktree("", "agent-123", "task-456"),
).rejects.toThrow("repoPath is required");
});
});
describe("getWorktreePath", () => {
it("should generate correct worktree path", () => {
const repoPath = "/tmp/test-repo";
const agentId = "agent-123";
const taskId = "task-456";
const expectedPath = path.join(
"/tmp",
"test-repo_worktrees",
`agent-${agentId}-${taskId}`,
);
const result = service.getWorktreePath(repoPath, agentId, taskId);
expect(result).toBe(expectedPath);
});
it("should handle repo paths with trailing slashes", () => {
const repoPath = "/tmp/test-repo/";
const agentId = "agent-123";
const taskId = "task-456";
const expectedPath = path.join(
"/tmp",
"test-repo_worktrees",
`agent-${agentId}-${taskId}`,
);
const result = service.getWorktreePath(repoPath, agentId, taskId);
expect(result).toBe(expectedPath);
});
});
describe("getBranchName", () => {
it("should generate correct branch name", () => {
const agentId = "agent-123";
const taskId = "task-456";
const expectedBranch = `agent-${agentId}-${taskId}`;
const result = service.getBranchName(agentId, taskId);
expect(result).toBe(expectedBranch);
});
});
});