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,8 @@
/**
* Valkey module public API
*/
export * from './types';
export * from './valkey.client';
export * from './valkey.service';
export * from './valkey.module';

View File

@@ -0,0 +1,44 @@
/**
* Event types for pub/sub
*/
export type EventType =
| 'agent.spawned'
| 'agent.running'
| 'agent.completed'
| 'agent.failed'
| 'agent.killed'
| 'task.assigned'
| 'task.queued'
| 'task.processing'
| 'task.retry'
| 'task.executing'
| 'task.completed'
| 'task.failed';
export interface BaseEvent {
type: EventType;
timestamp: string;
}
export interface AgentEvent extends BaseEvent {
type: 'agent.spawned' | 'agent.running' | 'agent.completed' | 'agent.failed' | 'agent.killed';
agentId: string;
taskId: string;
error?: string;
}
export interface TaskEvent extends BaseEvent {
type: 'task.assigned' | 'task.queued' | 'task.processing' | 'task.retry' | 'task.executing' | 'task.completed' | 'task.failed';
taskId?: string;
agentId?: string;
error?: string;
data?: Record<string, unknown>;
}
export type OrchestratorEvent = AgentEvent | TaskEvent;
/**
* Event handler type
*/
export type EventHandler = (event: OrchestratorEvent) => void | Promise<void>;

View File

@@ -0,0 +1,6 @@
/**
* Valkey module type exports
*/
export * from './state.types';
export * from './events.types';

View File

@@ -0,0 +1,69 @@
/**
* Task state management types
*/
export type TaskStatus = 'pending' | 'assigned' | 'executing' | 'completed' | 'failed';
export interface TaskContext {
repository: string;
branch: string;
workItems: string[];
skills?: string[];
}
export interface TaskState {
taskId: string;
status: TaskStatus;
agentId?: string;
context: TaskContext;
createdAt: string;
updatedAt: string;
metadata?: Record<string, unknown>;
}
/**
* Agent state management types
*/
export type AgentStatus = 'spawning' | 'running' | 'completed' | 'failed' | 'killed';
export interface AgentState {
agentId: string;
status: AgentStatus;
taskId: string;
startedAt?: string;
completedAt?: string;
error?: string;
metadata?: Record<string, unknown>;
}
/**
* State transition validation
*/
export const VALID_TASK_TRANSITIONS: Record<TaskStatus, TaskStatus[]> = {
pending: ['assigned', 'failed'],
assigned: ['executing', 'failed'],
executing: ['completed', 'failed'],
completed: [],
failed: ['pending'], // Allow retry
};
export const VALID_AGENT_TRANSITIONS: Record<AgentStatus, AgentStatus[]> = {
spawning: ['running', 'failed', 'killed'],
running: ['completed', 'failed', 'killed'],
completed: [],
failed: [],
killed: [],
};
/**
* Validate state transition
*/
export function isValidTaskTransition(from: TaskStatus, to: TaskStatus): boolean {
return VALID_TASK_TRANSITIONS[from].includes(to);
}
export function isValidAgentTransition(from: AgentStatus, to: AgentStatus): boolean {
return VALID_AGENT_TRANSITIONS[from].includes(to);
}

View File

@@ -0,0 +1,411 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { ValkeyClient } from './valkey.client';
import type { TaskState, AgentState, OrchestratorEvent } from './types';
// Create a shared mock instance that will be used across all tests
const mockRedisInstance = {
get: vi.fn(),
set: vi.fn(),
del: vi.fn(),
publish: vi.fn(),
subscribe: vi.fn(),
on: vi.fn(),
quit: vi.fn(),
duplicate: vi.fn(),
keys: vi.fn(),
};
// Mock ioredis
vi.mock('ioredis', () => {
return {
default: class {
constructor() {
return mockRedisInstance;
}
},
};
});
describe('ValkeyClient', () => {
let client: ValkeyClient;
let mockRedis: typeof mockRedisInstance;
beforeEach(() => {
// Reset all mocks
vi.clearAllMocks();
// Create client instance
client = new ValkeyClient({
host: 'localhost',
port: 6379,
});
// Reference the mock instance
mockRedis = mockRedisInstance;
// Mock duplicate to return another mock client
mockRedis.duplicate.mockReturnValue(mockRedis);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Connection Management', () => {
it('should disconnect on close', async () => {
mockRedis.quit.mockResolvedValue('OK');
await client.disconnect();
expect(mockRedis.quit).toHaveBeenCalled();
});
it('should disconnect subscriber if it exists', async () => {
mockRedis.quit.mockResolvedValue('OK');
mockRedis.subscribe.mockResolvedValue(1);
// Create subscriber
await client.subscribeToEvents(vi.fn());
await client.disconnect();
// Should call quit twice (main client and subscriber)
expect(mockRedis.quit).toHaveBeenCalledTimes(2);
});
});
describe('Task State Management', () => {
const mockTaskState: TaskState = {
taskId: 'task-123',
status: 'pending',
context: {
repository: 'https://github.com/example/repo',
branch: 'main',
workItems: ['item-1'],
},
createdAt: '2026-02-02T10:00:00Z',
updatedAt: '2026-02-02T10:00:00Z',
};
it('should get task state', async () => {
mockRedis.get.mockResolvedValue(JSON.stringify(mockTaskState));
const result = await client.getTaskState('task-123');
expect(mockRedis.get).toHaveBeenCalledWith('orchestrator:task:task-123');
expect(result).toEqual(mockTaskState);
});
it('should return null for non-existent task', async () => {
mockRedis.get.mockResolvedValue(null);
const result = await client.getTaskState('task-999');
expect(result).toBeNull();
});
it('should set task state', async () => {
mockRedis.set.mockResolvedValue('OK');
await client.setTaskState(mockTaskState);
expect(mockRedis.set).toHaveBeenCalledWith(
'orchestrator:task:task-123',
JSON.stringify(mockTaskState)
);
});
it('should delete task state', async () => {
mockRedis.del.mockResolvedValue(1);
await client.deleteTaskState('task-123');
expect(mockRedis.del).toHaveBeenCalledWith('orchestrator:task:task-123');
});
it('should update task status', async () => {
mockRedis.get.mockResolvedValue(JSON.stringify(mockTaskState));
mockRedis.set.mockResolvedValue('OK');
const result = await client.updateTaskStatus('task-123', 'assigned', 'agent-456');
expect(mockRedis.get).toHaveBeenCalledWith('orchestrator:task:task-123');
expect(mockRedis.set).toHaveBeenCalled();
expect(result?.status).toBe('assigned');
expect(result?.agentId).toBe('agent-456');
expect(result?.updatedAt).toBeDefined();
});
it('should throw error when updating non-existent task', async () => {
mockRedis.get.mockResolvedValue(null);
await expect(client.updateTaskStatus('task-999', 'assigned')).rejects.toThrow(
'Task task-999 not found'
);
});
it('should throw error for invalid task status transition', async () => {
const completedTask = { ...mockTaskState, status: 'completed' as const };
mockRedis.get.mockResolvedValue(JSON.stringify(completedTask));
await expect(client.updateTaskStatus('task-123', 'assigned')).rejects.toThrow(
'Invalid task state transition from completed to assigned'
);
});
it('should list all task states', async () => {
mockRedis.keys.mockResolvedValue(['orchestrator:task:task-1', 'orchestrator:task:task-2']);
mockRedis.get
.mockResolvedValueOnce(JSON.stringify({ ...mockTaskState, taskId: 'task-1' }))
.mockResolvedValueOnce(JSON.stringify({ ...mockTaskState, taskId: 'task-2' }));
const result = await client.listTasks();
expect(mockRedis.keys).toHaveBeenCalledWith('orchestrator:task:*');
expect(result).toHaveLength(2);
expect(result[0].taskId).toBe('task-1');
expect(result[1].taskId).toBe('task-2');
});
});
describe('Agent State Management', () => {
const mockAgentState: AgentState = {
agentId: 'agent-456',
status: 'spawning',
taskId: 'task-123',
};
it('should get agent state', async () => {
mockRedis.get.mockResolvedValue(JSON.stringify(mockAgentState));
const result = await client.getAgentState('agent-456');
expect(mockRedis.get).toHaveBeenCalledWith('orchestrator:agent:agent-456');
expect(result).toEqual(mockAgentState);
});
it('should return null for non-existent agent', async () => {
mockRedis.get.mockResolvedValue(null);
const result = await client.getAgentState('agent-999');
expect(result).toBeNull();
});
it('should set agent state', async () => {
mockRedis.set.mockResolvedValue('OK');
await client.setAgentState(mockAgentState);
expect(mockRedis.set).toHaveBeenCalledWith(
'orchestrator:agent:agent-456',
JSON.stringify(mockAgentState)
);
});
it('should delete agent state', async () => {
mockRedis.del.mockResolvedValue(1);
await client.deleteAgentState('agent-456');
expect(mockRedis.del).toHaveBeenCalledWith('orchestrator:agent:agent-456');
});
it('should update agent status', async () => {
mockRedis.get.mockResolvedValue(JSON.stringify(mockAgentState));
mockRedis.set.mockResolvedValue('OK');
const result = await client.updateAgentStatus('agent-456', 'running');
expect(mockRedis.get).toHaveBeenCalledWith('orchestrator:agent:agent-456');
expect(mockRedis.set).toHaveBeenCalled();
expect(result?.status).toBe('running');
expect(result?.startedAt).toBeDefined();
});
it('should set completedAt when status is completed', async () => {
const runningAgent = { ...mockAgentState, status: 'running' as const };
mockRedis.get.mockResolvedValue(JSON.stringify(runningAgent));
mockRedis.set.mockResolvedValue('OK');
const result = await client.updateAgentStatus('agent-456', 'completed');
expect(result?.status).toBe('completed');
expect(result?.completedAt).toBeDefined();
});
it('should throw error when updating non-existent agent', async () => {
mockRedis.get.mockResolvedValue(null);
await expect(client.updateAgentStatus('agent-999', 'running')).rejects.toThrow(
'Agent agent-999 not found'
);
});
it('should throw error for invalid agent status transition', async () => {
const completedAgent = { ...mockAgentState, status: 'completed' as const };
mockRedis.get.mockResolvedValue(JSON.stringify(completedAgent));
await expect(client.updateAgentStatus('agent-456', 'running')).rejects.toThrow(
'Invalid agent state transition from completed to running'
);
});
it('should list all agent states', async () => {
mockRedis.keys.mockResolvedValue(['orchestrator:agent:agent-1', 'orchestrator:agent:agent-2']);
mockRedis.get
.mockResolvedValueOnce(JSON.stringify({ ...mockAgentState, agentId: 'agent-1' }))
.mockResolvedValueOnce(JSON.stringify({ ...mockAgentState, agentId: 'agent-2' }));
const result = await client.listAgents();
expect(mockRedis.keys).toHaveBeenCalledWith('orchestrator:agent:*');
expect(result).toHaveLength(2);
expect(result[0].agentId).toBe('agent-1');
expect(result[1].agentId).toBe('agent-2');
});
});
describe('Event Pub/Sub', () => {
const mockEvent: OrchestratorEvent = {
type: 'agent.spawned',
agentId: 'agent-456',
taskId: 'task-123',
timestamp: '2026-02-02T10:00:00Z',
};
it('should publish events', async () => {
mockRedis.publish.mockResolvedValue(1);
await client.publishEvent(mockEvent);
expect(mockRedis.publish).toHaveBeenCalledWith(
'orchestrator:events',
JSON.stringify(mockEvent)
);
});
it('should subscribe to events', async () => {
mockRedis.subscribe.mockResolvedValue(1);
const handler = vi.fn();
await client.subscribeToEvents(handler);
expect(mockRedis.duplicate).toHaveBeenCalled();
expect(mockRedis.subscribe).toHaveBeenCalledWith('orchestrator:events');
});
it('should call handler when event is received', async () => {
mockRedis.subscribe.mockResolvedValue(1);
let messageHandler: ((channel: string, message: string) => void) | undefined;
mockRedis.on.mockImplementation((event: string, handler: (channel: string, message: string) => void) => {
if (event === 'message') {
messageHandler = handler;
}
return mockRedis;
});
const handler = vi.fn();
await client.subscribeToEvents(handler);
// Simulate receiving a message
if (messageHandler) {
messageHandler('orchestrator:events', JSON.stringify(mockEvent));
}
expect(handler).toHaveBeenCalledWith(mockEvent);
});
it('should handle invalid JSON in events gracefully', async () => {
mockRedis.subscribe.mockResolvedValue(1);
let messageHandler: ((channel: string, message: string) => void) | undefined;
mockRedis.on.mockImplementation((event: string, handler: (channel: string, message: string) => void) => {
if (event === 'message') {
messageHandler = handler;
}
return mockRedis;
});
const handler = vi.fn();
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await client.subscribeToEvents(handler);
// Simulate receiving invalid JSON
if (messageHandler) {
messageHandler('orchestrator:events', 'invalid json');
}
expect(handler).not.toHaveBeenCalled();
expect(consoleErrorSpy).toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
});
describe('Edge Cases', () => {
it('should handle task updates with error parameter', async () => {
const taskState: TaskState = {
taskId: 'task-123',
status: 'pending',
context: {
repository: 'https://github.com/example/repo',
branch: 'main',
workItems: ['item-1'],
},
createdAt: '2026-02-02T10:00:00Z',
updatedAt: '2026-02-02T10:00:00Z',
};
mockRedis.get.mockResolvedValue(JSON.stringify(taskState));
mockRedis.set.mockResolvedValue('OK');
const result = await client.updateTaskStatus('task-123', 'failed', undefined, 'Test error');
expect(result.status).toBe('failed');
expect(result.metadata?.error).toBe('Test error');
});
it('should handle agent updates with error parameter', async () => {
const agentState: AgentState = {
agentId: 'agent-456',
status: 'running',
taskId: 'task-123',
};
mockRedis.get.mockResolvedValue(JSON.stringify(agentState));
mockRedis.set.mockResolvedValue('OK');
const result = await client.updateAgentStatus('agent-456', 'failed', 'Test error');
expect(result.status).toBe('failed');
expect(result.error).toBe('Test error');
});
it('should filter out null values in listTasks', async () => {
mockRedis.keys.mockResolvedValue(['orchestrator:task:task-1', 'orchestrator:task:task-2']);
mockRedis.get
.mockResolvedValueOnce(JSON.stringify({ taskId: 'task-1', status: 'pending' }))
.mockResolvedValueOnce(null); // Simulate deleted task
const result = await client.listTasks();
expect(result).toHaveLength(1);
expect(result[0].taskId).toBe('task-1');
});
it('should filter out null values in listAgents', async () => {
mockRedis.keys.mockResolvedValue(['orchestrator:agent:agent-1', 'orchestrator:agent:agent-2']);
mockRedis.get
.mockResolvedValueOnce(JSON.stringify({ agentId: 'agent-1', status: 'running' }))
.mockResolvedValueOnce(null); // Simulate deleted agent
const result = await client.listAgents();
expect(result).toHaveLength(1);
expect(result[0].agentId).toBe('agent-1');
});
});
});

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}`;
}
}

View File

@@ -1,4 +1,13 @@
import { Module } from "@nestjs/common";
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ValkeyService } from './valkey.service';
@Module({})
/**
* Valkey module for state management and pub/sub
*/
@Module({
imports: [ConfigModule],
providers: [ValkeyService],
exports: [ValkeyService],
})
export class ValkeyModule {}

View File

@@ -0,0 +1,275 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ConfigService } from '@nestjs/config';
import { ValkeyService } from './valkey.service';
import type { TaskState, AgentState, OrchestratorEvent } from './types';
// Create mock client methods that will be shared
const mockClient = {
getTaskState: vi.fn(),
setTaskState: vi.fn(),
deleteTaskState: vi.fn(),
updateTaskStatus: vi.fn(),
listTasks: vi.fn(),
getAgentState: vi.fn(),
setAgentState: vi.fn(),
deleteAgentState: vi.fn(),
updateAgentStatus: vi.fn(),
listAgents: vi.fn(),
publishEvent: vi.fn(),
subscribeToEvents: vi.fn(),
disconnect: vi.fn(),
};
// Mock ValkeyClient before importing
vi.mock('./valkey.client', () => {
return {
ValkeyClient: class {
constructor() {
return mockClient;
}
},
};
});
describe('ValkeyService', () => {
let service: ValkeyService;
let mockConfigService: ConfigService;
beforeEach(() => {
// Clear all mock calls
vi.clearAllMocks();
// Create mock config service
mockConfigService = {
get: vi.fn((key: string, defaultValue?: unknown) => {
const config: Record<string, unknown> = {
'orchestrator.valkey.host': 'localhost',
'orchestrator.valkey.port': 6379,
};
return config[key] ?? defaultValue;
}),
} as any;
// Create service directly
service = new ValkeyService(mockConfigService);
});
describe('Initialization', () => {
it('should be defined', () => {
expect(service).toBeDefined();
});
it('should create ValkeyClient with config from ConfigService', () => {
expect(mockConfigService.get).toHaveBeenCalledWith('orchestrator.valkey.host', 'localhost');
expect(mockConfigService.get).toHaveBeenCalledWith('orchestrator.valkey.port', 6379);
});
it('should use password from config if provided', () => {
const configWithPassword = {
get: vi.fn((key: string, defaultValue?: unknown) => {
const config: Record<string, unknown> = {
'orchestrator.valkey.host': 'localhost',
'orchestrator.valkey.port': 6379,
'orchestrator.valkey.password': 'secret',
};
return config[key] ?? defaultValue;
}),
} as any;
const serviceWithPassword = new ValkeyService(configWithPassword);
expect(configWithPassword.get).toHaveBeenCalledWith('orchestrator.valkey.password');
});
});
describe('Lifecycle', () => {
it('should disconnect on module destroy', async () => {
mockClient.disconnect.mockResolvedValue(undefined);
await service.onModuleDestroy();
expect(mockClient.disconnect).toHaveBeenCalled();
});
});
describe('Task State Management', () => {
const mockTaskState: TaskState = {
taskId: 'task-123',
status: 'pending',
context: {
repository: 'https://github.com/example/repo',
branch: 'main',
workItems: ['item-1'],
},
createdAt: '2026-02-02T10:00:00Z',
updatedAt: '2026-02-02T10:00:00Z',
};
it('should get task state', async () => {
mockClient.getTaskState.mockResolvedValue(mockTaskState);
const result = await service.getTaskState('task-123');
expect(mockClient.getTaskState).toHaveBeenCalledWith('task-123');
expect(result).toEqual(mockTaskState);
});
it('should set task state', async () => {
mockClient.setTaskState.mockResolvedValue(undefined);
await service.setTaskState(mockTaskState);
expect(mockClient.setTaskState).toHaveBeenCalledWith(mockTaskState);
});
it('should delete task state', async () => {
mockClient.deleteTaskState.mockResolvedValue(undefined);
await service.deleteTaskState('task-123');
expect(mockClient.deleteTaskState).toHaveBeenCalledWith('task-123');
});
it('should update task status', async () => {
const updatedTask = { ...mockTaskState, status: 'assigned' as const };
mockClient.updateTaskStatus.mockResolvedValue(updatedTask);
const result = await service.updateTaskStatus('task-123', 'assigned', 'agent-456');
expect(mockClient.updateTaskStatus).toHaveBeenCalledWith(
'task-123',
'assigned',
'agent-456',
undefined
);
expect(result).toEqual(updatedTask);
});
it('should list all tasks', async () => {
const tasks = [mockTaskState];
mockClient.listTasks.mockResolvedValue(tasks);
const result = await service.listTasks();
expect(mockClient.listTasks).toHaveBeenCalled();
expect(result).toEqual(tasks);
});
});
describe('Agent State Management', () => {
const mockAgentState: AgentState = {
agentId: 'agent-456',
status: 'spawning',
taskId: 'task-123',
};
it('should get agent state', async () => {
mockClient.getAgentState.mockResolvedValue(mockAgentState);
const result = await service.getAgentState('agent-456');
expect(mockClient.getAgentState).toHaveBeenCalledWith('agent-456');
expect(result).toEqual(mockAgentState);
});
it('should set agent state', async () => {
mockClient.setAgentState.mockResolvedValue(undefined);
await service.setAgentState(mockAgentState);
expect(mockClient.setAgentState).toHaveBeenCalledWith(mockAgentState);
});
it('should delete agent state', async () => {
mockClient.deleteAgentState.mockResolvedValue(undefined);
await service.deleteAgentState('agent-456');
expect(mockClient.deleteAgentState).toHaveBeenCalledWith('agent-456');
});
it('should update agent status', async () => {
const updatedAgent = { ...mockAgentState, status: 'running' as const };
mockClient.updateAgentStatus.mockResolvedValue(updatedAgent);
const result = await service.updateAgentStatus('agent-456', 'running');
expect(mockClient.updateAgentStatus).toHaveBeenCalledWith(
'agent-456',
'running',
undefined
);
expect(result).toEqual(updatedAgent);
});
it('should list all agents', async () => {
const agents = [mockAgentState];
mockClient.listAgents.mockResolvedValue(agents);
const result = await service.listAgents();
expect(mockClient.listAgents).toHaveBeenCalled();
expect(result).toEqual(agents);
});
});
describe('Event Pub/Sub', () => {
const mockEvent: OrchestratorEvent = {
type: 'agent.spawned',
agentId: 'agent-456',
taskId: 'task-123',
timestamp: '2026-02-02T10:00:00Z',
};
it('should publish events', async () => {
mockClient.publishEvent.mockResolvedValue(undefined);
await service.publishEvent(mockEvent);
expect(mockClient.publishEvent).toHaveBeenCalledWith(mockEvent);
});
it('should subscribe to events', async () => {
mockClient.subscribeToEvents.mockResolvedValue(undefined);
const handler = vi.fn();
await service.subscribeToEvents(handler);
expect(mockClient.subscribeToEvents).toHaveBeenCalledWith(handler);
});
});
describe('Convenience Methods', () => {
it('should create task state with timestamps', async () => {
mockClient.setTaskState.mockResolvedValue(undefined);
const context = {
repository: 'https://github.com/example/repo',
branch: 'main',
workItems: ['item-1'],
};
await service.createTask('task-123', context);
expect(mockClient.setTaskState).toHaveBeenCalledWith({
taskId: 'task-123',
status: 'pending',
context,
createdAt: expect.any(String),
updatedAt: expect.any(String),
});
});
it('should create agent state', async () => {
mockClient.setAgentState.mockResolvedValue(undefined);
await service.createAgent('agent-456', 'task-123');
expect(mockClient.setAgentState).toHaveBeenCalledWith({
agentId: 'agent-456',
status: 'spawning',
taskId: 'task-123',
});
});
});
});

View File

@@ -0,0 +1,132 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ValkeyClient, ValkeyClientConfig } from './valkey.client';
import type {
TaskState,
AgentState,
TaskStatus,
AgentStatus,
OrchestratorEvent,
EventHandler,
TaskContext,
} from './types';
/**
* NestJS service for Valkey state management and pub/sub
*/
@Injectable()
export class ValkeyService implements OnModuleDestroy {
private readonly client: ValkeyClient;
constructor(private readonly configService: ConfigService) {
const config: ValkeyClientConfig = {
host: this.configService.get<string>('orchestrator.valkey.host', 'localhost'),
port: this.configService.get<number>('orchestrator.valkey.port', 6379),
};
const password = this.configService.get<string>('orchestrator.valkey.password');
if (password) {
config.password = password;
}
this.client = new ValkeyClient(config);
}
async onModuleDestroy(): Promise<void> {
await this.client.disconnect();
}
/**
* Task State Management
*/
async getTaskState(taskId: string): Promise<TaskState | null> {
return this.client.getTaskState(taskId);
}
async setTaskState(state: TaskState): Promise<void> {
return this.client.setTaskState(state);
}
async deleteTaskState(taskId: string): Promise<void> {
return this.client.deleteTaskState(taskId);
}
async updateTaskStatus(
taskId: string,
status: TaskStatus,
agentId?: string,
error?: string
): Promise<TaskState> {
return this.client.updateTaskStatus(taskId, status, agentId, error);
}
async listTasks(): Promise<TaskState[]> {
return this.client.listTasks();
}
/**
* Agent State Management
*/
async getAgentState(agentId: string): Promise<AgentState | null> {
return this.client.getAgentState(agentId);
}
async setAgentState(state: AgentState): Promise<void> {
return this.client.setAgentState(state);
}
async deleteAgentState(agentId: string): Promise<void> {
return this.client.deleteAgentState(agentId);
}
async updateAgentStatus(
agentId: string,
status: AgentStatus,
error?: string
): Promise<AgentState> {
return this.client.updateAgentStatus(agentId, status, error);
}
async listAgents(): Promise<AgentState[]> {
return this.client.listAgents();
}
/**
* Event Pub/Sub
*/
async publishEvent(event: OrchestratorEvent): Promise<void> {
return this.client.publishEvent(event);
}
async subscribeToEvents(handler: EventHandler): Promise<void> {
return this.client.subscribeToEvents(handler);
}
/**
* Convenience methods
*/
async createTask(taskId: string, context: TaskContext): Promise<void> {
const now = new Date().toISOString();
const state: TaskState = {
taskId,
status: 'pending',
context,
createdAt: now,
updatedAt: now,
};
await this.setTaskState(state);
}
async createAgent(agentId: string, taskId: string): Promise<void> {
const state: AgentState = {
agentId,
status: 'spawning',
taskId,
};
await this.setAgentState(state);
}
}