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,229 @@
import Redis from 'ioredis';
import type {
TaskState,
AgentState,
TaskStatus,
AgentStatus,
OrchestratorEvent,
EventHandler,
} from './types';
import { isValidTaskTransition, isValidAgentTransition } from './types';
export interface ValkeyClientConfig {
host: string;
port: number;
password?: string;
db?: number;
}
/**
* Valkey client for state management and pub/sub
*/
export class ValkeyClient {
private readonly client: Redis;
private subscriber?: Redis;
constructor(config: ValkeyClientConfig) {
this.client = new Redis({
host: config.host,
port: config.port,
password: config.password,
db: config.db,
});
}
/**
* Disconnect from Valkey
*/
async disconnect(): Promise<void> {
await this.client.quit();
if (this.subscriber) {
await this.subscriber.quit();
}
}
/**
* Task State Management
*/
async getTaskState(taskId: string): Promise<TaskState | null> {
const key = this.getTaskKey(taskId);
const data = await this.client.get(key);
if (!data) {
return null;
}
return JSON.parse(data) as TaskState;
}
async setTaskState(state: TaskState): Promise<void> {
const key = this.getTaskKey(state.taskId);
await this.client.set(key, JSON.stringify(state));
}
async deleteTaskState(taskId: string): Promise<void> {
const key = this.getTaskKey(taskId);
await this.client.del(key);
}
async updateTaskStatus(
taskId: string,
status: TaskStatus,
agentId?: string,
error?: string
): Promise<TaskState> {
const existing = await this.getTaskState(taskId);
if (!existing) {
throw new Error(`Task ${taskId} not found`);
}
// Validate state transition
if (!isValidTaskTransition(existing.status, status)) {
throw new Error(
`Invalid task state transition from ${existing.status} to ${status}`
);
}
const updated: TaskState = {
...existing,
status,
agentId: agentId ?? existing.agentId,
updatedAt: new Date().toISOString(),
metadata: {
...existing.metadata,
...(error && { error }),
},
};
await this.setTaskState(updated);
return updated;
}
async listTasks(): Promise<TaskState[]> {
const pattern = 'orchestrator:task:*';
const keys = await this.client.keys(pattern);
const tasks: TaskState[] = [];
for (const key of keys) {
const data = await this.client.get(key);
if (data) {
tasks.push(JSON.parse(data) as TaskState);
}
}
return tasks;
}
/**
* Agent State Management
*/
async getAgentState(agentId: string): Promise<AgentState | null> {
const key = this.getAgentKey(agentId);
const data = await this.client.get(key);
if (!data) {
return null;
}
return JSON.parse(data) as AgentState;
}
async setAgentState(state: AgentState): Promise<void> {
const key = this.getAgentKey(state.agentId);
await this.client.set(key, JSON.stringify(state));
}
async deleteAgentState(agentId: string): Promise<void> {
const key = this.getAgentKey(agentId);
await this.client.del(key);
}
async updateAgentStatus(
agentId: string,
status: AgentStatus,
error?: string
): Promise<AgentState> {
const existing = await this.getAgentState(agentId);
if (!existing) {
throw new Error(`Agent ${agentId} not found`);
}
// Validate state transition
if (!isValidAgentTransition(existing.status, status)) {
throw new Error(
`Invalid agent state transition from ${existing.status} to ${status}`
);
}
const now = new Date().toISOString();
const updated: AgentState = {
...existing,
status,
...(status === 'running' && !existing.startedAt && { startedAt: now }),
...((['completed', 'failed', 'killed'] as AgentStatus[]).includes(status) && {
completedAt: now,
}),
...(error && { error }),
};
await this.setAgentState(updated);
return updated;
}
async listAgents(): Promise<AgentState[]> {
const pattern = 'orchestrator:agent:*';
const keys = await this.client.keys(pattern);
const agents: AgentState[] = [];
for (const key of keys) {
const data = await this.client.get(key);
if (data) {
agents.push(JSON.parse(data) as AgentState);
}
}
return agents;
}
/**
* Event Pub/Sub
*/
async publishEvent(event: OrchestratorEvent): Promise<void> {
const channel = 'orchestrator:events';
await this.client.publish(channel, JSON.stringify(event));
}
async subscribeToEvents(handler: EventHandler): Promise<void> {
if (!this.subscriber) {
this.subscriber = this.client.duplicate();
}
this.subscriber.on('message', (channel: string, message: string) => {
try {
const event = JSON.parse(message) as OrchestratorEvent;
void handler(event);
} catch (error) {
console.error('Failed to parse event:', error);
}
});
await this.subscriber.subscribe('orchestrator:events');
}
/**
* Private helper methods
*/
private getTaskKey(taskId: string): string {
return `orchestrator:task:${taskId}`;
}
private getAgentKey(agentId: string): string {
return `orchestrator:agent:${agentId}`;
}
}