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:
229
apps/orchestrator/src/valkey/valkey.client.ts
Normal file
229
apps/orchestrator/src/valkey/valkey.client.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user