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:
238
apps/orchestrator/src/git/worktree-manager.service.ts
Normal file
238
apps/orchestrator/src/git/worktree-manager.service.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { simpleGit, SimpleGit } from "simple-git";
|
||||
import * as path from "path";
|
||||
import { GitOperationsService } from "./git-operations.service";
|
||||
import { WorktreeInfo, WorktreeError } from "./types";
|
||||
|
||||
/**
|
||||
* Service for managing git worktrees for agent isolation
|
||||
*/
|
||||
@Injectable()
|
||||
export class WorktreeManagerService {
|
||||
private readonly logger = new Logger(WorktreeManagerService.name);
|
||||
|
||||
constructor(
|
||||
private readonly gitOperationsService: GitOperationsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get a simple-git instance for a local path
|
||||
*/
|
||||
private getGit(localPath: string): SimpleGit {
|
||||
return simpleGit(localPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate worktree path for an agent
|
||||
*/
|
||||
public getWorktreePath(
|
||||
repoPath: string,
|
||||
agentId: string,
|
||||
taskId: string,
|
||||
): string {
|
||||
// Remove trailing slash if present
|
||||
const cleanRepoPath = repoPath.replace(/\/$/, "");
|
||||
const repoDir = path.dirname(cleanRepoPath);
|
||||
const repoName = path.basename(cleanRepoPath);
|
||||
const worktreeName = `agent-${agentId}-${taskId}`;
|
||||
|
||||
return path.join(repoDir, `${repoName}_worktrees`, worktreeName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate branch name for an agent
|
||||
*/
|
||||
public getBranchName(agentId: string, taskId: string): string {
|
||||
return `agent-${agentId}-${taskId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a worktree for an agent
|
||||
*/
|
||||
async createWorktree(
|
||||
repoPath: string,
|
||||
agentId: string,
|
||||
taskId: string,
|
||||
baseBranch: string = "develop",
|
||||
): Promise<WorktreeInfo> {
|
||||
// Validate inputs
|
||||
if (!repoPath) {
|
||||
throw new Error("repoPath is required");
|
||||
}
|
||||
if (!agentId) {
|
||||
throw new Error("agentId is required");
|
||||
}
|
||||
if (!taskId) {
|
||||
throw new Error("taskId is required");
|
||||
}
|
||||
|
||||
const worktreePath = this.getWorktreePath(repoPath, agentId, taskId);
|
||||
const branchName = this.getBranchName(agentId, taskId);
|
||||
|
||||
try {
|
||||
this.logger.log(
|
||||
`Creating worktree for agent ${agentId}, task ${taskId} at ${worktreePath}`,
|
||||
);
|
||||
|
||||
const git = this.getGit(repoPath);
|
||||
|
||||
// Create worktree with new branch
|
||||
await git.raw([
|
||||
"worktree",
|
||||
"add",
|
||||
worktreePath,
|
||||
"-b",
|
||||
branchName,
|
||||
baseBranch,
|
||||
]);
|
||||
|
||||
this.logger.log(`Successfully created worktree at ${worktreePath}`);
|
||||
|
||||
// Return worktree info
|
||||
return {
|
||||
path: worktreePath,
|
||||
branch: branchName,
|
||||
commit: "HEAD", // Will be updated after first commit
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to create worktree: ${error}`);
|
||||
throw new WorktreeError(
|
||||
`Failed to create worktree for agent ${agentId}, task ${taskId}`,
|
||||
"createWorktree",
|
||||
error as Error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a worktree
|
||||
*/
|
||||
async removeWorktree(worktreePath: string): Promise<void> {
|
||||
// Validate input
|
||||
if (!worktreePath) {
|
||||
throw new Error("worktreePath is required");
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger.log(`Removing worktree at ${worktreePath}`);
|
||||
|
||||
// Get the parent repo path by going up from worktree
|
||||
const worktreeParent = path.dirname(worktreePath);
|
||||
const repoName = path.basename(worktreeParent).replace("_worktrees", "");
|
||||
const repoPath = path.join(path.dirname(worktreeParent), repoName);
|
||||
|
||||
const git = this.getGit(repoPath);
|
||||
|
||||
// Remove worktree
|
||||
await git.raw(["worktree", "remove", worktreePath, "--force"]);
|
||||
|
||||
this.logger.log(`Successfully removed worktree at ${worktreePath}`);
|
||||
} catch (error) {
|
||||
const errorMessage = (error as Error).message || String(error);
|
||||
|
||||
// If worktree doesn't exist, log warning but don't throw
|
||||
if (
|
||||
errorMessage.includes("is not a working tree") ||
|
||||
errorMessage.includes("does not exist")
|
||||
) {
|
||||
this.logger.warn(`Worktree ${worktreePath} does not exist, skipping removal`);
|
||||
return;
|
||||
}
|
||||
|
||||
// For other errors, throw
|
||||
this.logger.error(`Failed to remove worktree: ${error}`);
|
||||
throw new WorktreeError(
|
||||
`Failed to remove worktree at ${worktreePath}`,
|
||||
"removeWorktree",
|
||||
error as Error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all worktrees for a repository
|
||||
*/
|
||||
async listWorktrees(repoPath: string): Promise<WorktreeInfo[]> {
|
||||
// Validate input
|
||||
if (!repoPath) {
|
||||
throw new Error("repoPath is required");
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger.log(`Listing worktrees for repository at ${repoPath}`);
|
||||
|
||||
const git = this.getGit(repoPath);
|
||||
|
||||
// Get worktree list
|
||||
const output = await git.raw(["worktree", "list"]);
|
||||
|
||||
// Parse output
|
||||
const worktrees: WorktreeInfo[] = [];
|
||||
const lines = output.trim().split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
// Format: /path/to/worktree commit [branch]
|
||||
const match = line.match(/^(.+?)\s+([a-f0-9]+)\s+\[(.+?)\]$/);
|
||||
if (!match) continue;
|
||||
|
||||
const [, worktreePath, commit, branch] = match;
|
||||
|
||||
// Only include agent worktrees (not the main repo)
|
||||
if (worktreePath.includes("_worktrees")) {
|
||||
worktrees.push({
|
||||
path: worktreePath,
|
||||
commit,
|
||||
branch,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Found ${worktrees.length} active worktrees`);
|
||||
return worktrees;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to list worktrees: ${error}`);
|
||||
throw new WorktreeError(
|
||||
`Failed to list worktrees for repository at ${repoPath}`,
|
||||
"listWorktrees",
|
||||
error as Error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup worktree for a specific agent
|
||||
*/
|
||||
async cleanupWorktree(
|
||||
repoPath: string,
|
||||
agentId: string,
|
||||
taskId: string,
|
||||
): Promise<void> {
|
||||
// Validate inputs
|
||||
if (!repoPath) {
|
||||
throw new Error("repoPath is required");
|
||||
}
|
||||
if (!agentId) {
|
||||
throw new Error("agentId is required");
|
||||
}
|
||||
if (!taskId) {
|
||||
throw new Error("taskId is required");
|
||||
}
|
||||
|
||||
const worktreePath = this.getWorktreePath(repoPath, agentId, taskId);
|
||||
|
||||
try {
|
||||
this.logger.log(
|
||||
`Cleaning up worktree for agent ${agentId}, task ${taskId}`,
|
||||
);
|
||||
await this.removeWorktree(worktreePath);
|
||||
this.logger.log(
|
||||
`Successfully cleaned up worktree for agent ${agentId}, task ${taskId}`,
|
||||
);
|
||||
} catch (error) {
|
||||
// Log error but don't throw - cleanup should be best-effort
|
||||
this.logger.warn(
|
||||
`Failed to cleanup worktree for agent ${agentId}, task ${taskId}: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user