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:
@@ -1,5 +1,6 @@
|
||||
import { IsOptional, IsInt, Min, Max } from "class-validator";
|
||||
import { IsOptional, IsInt, Min, Max, IsString, IsEnum, IsArray } from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
import { EntryStatus } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Query parameters for entry-centered graph view
|
||||
@@ -12,3 +13,24 @@ export class GraphQueryDto {
|
||||
@Max(5)
|
||||
depth?: number = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query parameters for full graph view with filtering
|
||||
*/
|
||||
export class GraphFilterDto {
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(EntryStatus)
|
||||
status?: EntryStatus;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(1000)
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,6 @@ export { CreateTagDto } from "./create-tag.dto";
|
||||
export { UpdateTagDto } from "./update-tag.dto";
|
||||
export { RestoreVersionDto } from "./restore-version.dto";
|
||||
export { SearchQueryDto, TagSearchDto, RecentEntriesDto } from "./search-query.dto";
|
||||
export { GraphQueryDto } from "./graph-query.dto";
|
||||
export { GraphQueryDto, GraphFilterDto } from "./graph-query.dto";
|
||||
export { ExportQueryDto, ExportFormat } from "./import-export.dto";
|
||||
export type { ImportResult, ImportResponseDto } from "./import-export.dto";
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface GraphNode {
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
status?: string;
|
||||
tags: {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -13,6 +14,7 @@ export interface GraphNode {
|
||||
color: string | null;
|
||||
}[];
|
||||
depth: number;
|
||||
isOrphan?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,3 +40,37 @@ export interface EntryGraphResponse {
|
||||
maxDepth: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Full knowledge graph response
|
||||
*/
|
||||
export interface FullGraphResponse {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
stats: {
|
||||
totalNodes: number;
|
||||
totalEdges: number;
|
||||
orphanCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Graph statistics response
|
||||
*/
|
||||
export interface GraphStatsResponse {
|
||||
totalEntries: number;
|
||||
totalLinks: number;
|
||||
orphanEntries: number;
|
||||
averageLinks: number;
|
||||
mostConnectedEntries: {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
linkCount: number;
|
||||
}[];
|
||||
tagDistribution: {
|
||||
tagId: string;
|
||||
tagName: string;
|
||||
entryCount: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
154
apps/api/src/knowledge/graph.controller.spec.ts
Normal file
154
apps/api/src/knowledge/graph.controller.spec.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { KnowledgeGraphController } from "./graph.controller";
|
||||
import { GraphService } from "./services/graph.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { WorkspaceGuard } from "../common/guards/workspace.guard";
|
||||
import { PermissionGuard } from "../common/guards/permission.guard";
|
||||
|
||||
describe("KnowledgeGraphController", () => {
|
||||
let controller: KnowledgeGraphController;
|
||||
let graphService: GraphService;
|
||||
let prismaService: PrismaService;
|
||||
|
||||
const mockGraphService = {
|
||||
getFullGraph: vi.fn(),
|
||||
getGraphStats: vi.fn(),
|
||||
getEntryGraph: vi.fn(),
|
||||
getEntryGraphBySlug: vi.fn(),
|
||||
};
|
||||
|
||||
const mockPrismaService = {
|
||||
knowledgeEntry: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [KnowledgeGraphController],
|
||||
providers: [
|
||||
{
|
||||
provide: GraphService,
|
||||
useValue: mockGraphService,
|
||||
},
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: mockPrismaService,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: vi.fn(() => true) })
|
||||
.overrideGuard(WorkspaceGuard)
|
||||
.useValue({ canActivate: vi.fn(() => true) })
|
||||
.overrideGuard(PermissionGuard)
|
||||
.useValue({ canActivate: vi.fn(() => true) })
|
||||
.compile();
|
||||
|
||||
controller = module.get<KnowledgeGraphController>(KnowledgeGraphController);
|
||||
graphService = module.get<GraphService>(GraphService);
|
||||
prismaService = module.get<PrismaService>(PrismaService);
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe("getFullGraph", () => {
|
||||
it("should return full graph without filters", async () => {
|
||||
const mockGraph = {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
stats: { totalNodes: 0, totalEdges: 0, orphanCount: 0 },
|
||||
};
|
||||
mockGraphService.getFullGraph.mockResolvedValue(mockGraph);
|
||||
|
||||
const result = await controller.getFullGraph("workspace-1", {});
|
||||
|
||||
expect(graphService.getFullGraph).toHaveBeenCalledWith("workspace-1", {});
|
||||
expect(result).toEqual(mockGraph);
|
||||
});
|
||||
|
||||
it("should pass filters to service", async () => {
|
||||
const mockGraph = {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
stats: { totalNodes: 0, totalEdges: 0, orphanCount: 0 },
|
||||
};
|
||||
mockGraphService.getFullGraph.mockResolvedValue(mockGraph);
|
||||
|
||||
const filters = {
|
||||
tags: ["tag-1"],
|
||||
status: "PUBLISHED",
|
||||
limit: 100,
|
||||
};
|
||||
|
||||
await controller.getFullGraph("workspace-1", filters);
|
||||
|
||||
expect(graphService.getFullGraph).toHaveBeenCalledWith("workspace-1", filters);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getGraphStats", () => {
|
||||
it("should return graph statistics", async () => {
|
||||
const mockStats = {
|
||||
totalEntries: 10,
|
||||
totalLinks: 15,
|
||||
orphanEntries: 2,
|
||||
averageLinks: 1.5,
|
||||
mostConnectedEntries: [],
|
||||
tagDistribution: [],
|
||||
};
|
||||
mockGraphService.getGraphStats.mockResolvedValue(mockStats);
|
||||
|
||||
const result = await controller.getGraphStats("workspace-1");
|
||||
|
||||
expect(graphService.getGraphStats).toHaveBeenCalledWith("workspace-1");
|
||||
expect(result).toEqual(mockStats);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEntryGraph", () => {
|
||||
it("should return entry-centered graph", async () => {
|
||||
const mockEntry = {
|
||||
id: "entry-1",
|
||||
slug: "test-entry",
|
||||
title: "Test Entry",
|
||||
};
|
||||
|
||||
const mockGraph = {
|
||||
centerNode: mockEntry,
|
||||
nodes: [mockEntry],
|
||||
edges: [],
|
||||
stats: { totalNodes: 1, totalEdges: 0, maxDepth: 1 },
|
||||
};
|
||||
|
||||
mockGraphService.getEntryGraphBySlug.mockResolvedValue(mockGraph);
|
||||
|
||||
const result = await controller.getEntryGraph("workspace-1", "test-entry", { depth: 2 });
|
||||
|
||||
expect(graphService.getEntryGraphBySlug).toHaveBeenCalledWith("workspace-1", "test-entry", 2);
|
||||
expect(result).toEqual(mockGraph);
|
||||
});
|
||||
|
||||
it("should use default depth if not provided", async () => {
|
||||
mockGraphService.getEntryGraphBySlug.mockResolvedValue({});
|
||||
|
||||
await controller.getEntryGraph("workspace-1", "test-entry", {});
|
||||
|
||||
expect(graphService.getEntryGraphBySlug).toHaveBeenCalledWith("workspace-1", "test-entry", 1);
|
||||
});
|
||||
|
||||
it("should throw error if entry not found", async () => {
|
||||
mockGraphService.getEntryGraphBySlug.mockRejectedValue(new Error("Entry not found"));
|
||||
|
||||
await expect(
|
||||
controller.getEntryGraph("workspace-1", "non-existent", {})
|
||||
).rejects.toThrow("Entry not found");
|
||||
});
|
||||
});
|
||||
});
|
||||
54
apps/api/src/knowledge/graph.controller.ts
Normal file
54
apps/api/src/knowledge/graph.controller.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Controller, Get, Query, Param, UseGuards } from "@nestjs/common";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||
import { Workspace, RequirePermission, Permission } from "../common/decorators";
|
||||
import { GraphService } from "./services";
|
||||
import { GraphQueryDto, GraphFilterDto } from "./dto/graph-query.dto";
|
||||
|
||||
/**
|
||||
* Controller for knowledge graph endpoints
|
||||
* All endpoints require authentication and workspace context
|
||||
*/
|
||||
@Controller("knowledge/graph")
|
||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||
export class KnowledgeGraphController {
|
||||
constructor(private readonly graphService: GraphService) {}
|
||||
|
||||
/**
|
||||
* GET /api/knowledge/graph
|
||||
* Get full knowledge graph with optional filtering
|
||||
* Requires: Any workspace member
|
||||
*/
|
||||
@Get()
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async getFullGraph(@Workspace() workspaceId: string, @Query() filters: GraphFilterDto) {
|
||||
return this.graphService.getFullGraph(workspaceId, filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/knowledge/graph/stats
|
||||
* Get graph statistics including orphan detection
|
||||
* Requires: Any workspace member
|
||||
*/
|
||||
@Get("stats")
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async getGraphStats(@Workspace() workspaceId: string) {
|
||||
return this.graphService.getGraphStats(workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/knowledge/graph/:slug
|
||||
* Get entry-centered graph view (subgraph)
|
||||
* Requires: Any workspace member
|
||||
*/
|
||||
@Get(":slug")
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async getEntryGraph(
|
||||
@Workspace() workspaceId: string,
|
||||
@Param("slug") slug: string,
|
||||
@Query() query: GraphQueryDto
|
||||
) {
|
||||
// Get entry by slug to find its ID
|
||||
return this.graphService.getEntryGraphBySlug(workspaceId, slug, query.depth ?? 1);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "./knowledge.controller";
|
||||
import { SearchController } from "./search.controller";
|
||||
import { KnowledgeStatsController } from "./stats.controller";
|
||||
import { KnowledgeGraphController } from "./graph.controller";
|
||||
import {
|
||||
LinkResolutionService,
|
||||
SearchService,
|
||||
@@ -46,6 +47,7 @@ import { EmbeddingProcessor } from "./queues/embedding.processor";
|
||||
KnowledgeEmbeddingsController,
|
||||
SearchController,
|
||||
KnowledgeStatsController,
|
||||
KnowledgeGraphController,
|
||||
],
|
||||
providers: [
|
||||
KnowledgeService,
|
||||
|
||||
@@ -69,6 +69,43 @@ describe("GraphService", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe("getEntryGraphBySlug", () => {
|
||||
it("should throw NotFoundException if entry does not exist", async () => {
|
||||
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getEntryGraphBySlug("workspace-1", "non-existent", 1)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
|
||||
it("should call getEntryGraph with entry ID", async () => {
|
||||
const mockEntry = {
|
||||
id: "entry-1",
|
||||
workspaceId: "workspace-1",
|
||||
slug: "test-entry",
|
||||
tags: [],
|
||||
outgoingLinks: [],
|
||||
incomingLinks: [],
|
||||
};
|
||||
|
||||
mockPrismaService.knowledgeEntry.findUnique
|
||||
.mockResolvedValueOnce(mockEntry) // First call in getEntryGraphBySlug
|
||||
.mockResolvedValueOnce(mockEntry) // Second call in getEntryGraph validation
|
||||
.mockResolvedValueOnce(mockEntry); // Third call in getEntryGraph BFS
|
||||
|
||||
await service.getEntryGraphBySlug("workspace-1", "test-entry", 1);
|
||||
|
||||
expect(mockPrismaService.knowledgeEntry.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId_slug: {
|
||||
workspaceId: "workspace-1",
|
||||
slug: "test-entry",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEntryGraph", () => {
|
||||
it("should throw NotFoundException if entry does not exist", async () => {
|
||||
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValue(null);
|
||||
@@ -150,4 +187,195 @@ describe("GraphService", () => {
|
||||
expect(result.stats.totalEdges).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFullGraph", () => {
|
||||
beforeEach(() => {
|
||||
// Add findMany mock
|
||||
mockPrismaService.knowledgeEntry.findMany = vi.fn();
|
||||
mockPrismaService.knowledgeLink = {
|
||||
findMany: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it("should return full graph with all entries and links", async () => {
|
||||
const entries = [
|
||||
{ ...mockEntry, id: "entry-1", slug: "entry-1", tags: [] },
|
||||
{
|
||||
...mockEntry,
|
||||
id: "entry-2",
|
||||
slug: "entry-2",
|
||||
title: "Entry 2",
|
||||
tags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const links = [
|
||||
{
|
||||
id: "link-1",
|
||||
sourceId: "entry-1",
|
||||
targetId: "entry-2",
|
||||
linkText: "link text",
|
||||
resolved: true,
|
||||
},
|
||||
];
|
||||
|
||||
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue(entries);
|
||||
mockPrismaService.knowledgeLink.findMany.mockResolvedValue(links);
|
||||
|
||||
const result = await service.getFullGraph("workspace-1");
|
||||
|
||||
expect(result.nodes).toHaveLength(2);
|
||||
expect(result.edges).toHaveLength(1);
|
||||
expect(result.stats.totalNodes).toBe(2);
|
||||
expect(result.stats.totalEdges).toBe(1);
|
||||
expect(result.stats.orphanCount).toBe(0);
|
||||
});
|
||||
|
||||
it("should detect orphan entries (entries with no links)", async () => {
|
||||
const entries = [
|
||||
{ ...mockEntry, id: "entry-1", slug: "entry-1", tags: [] },
|
||||
{
|
||||
...mockEntry,
|
||||
id: "entry-2",
|
||||
slug: "entry-2",
|
||||
title: "Entry 2",
|
||||
tags: [],
|
||||
},
|
||||
{
|
||||
...mockEntry,
|
||||
id: "entry-3",
|
||||
slug: "entry-3",
|
||||
title: "Entry 3 (orphan)",
|
||||
tags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const links = [
|
||||
{
|
||||
id: "link-1",
|
||||
sourceId: "entry-1",
|
||||
targetId: "entry-2",
|
||||
linkText: "link text",
|
||||
resolved: true,
|
||||
},
|
||||
];
|
||||
|
||||
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue(entries);
|
||||
mockPrismaService.knowledgeLink.findMany.mockResolvedValue(links);
|
||||
|
||||
const result = await service.getFullGraph("workspace-1");
|
||||
|
||||
expect(result.stats.orphanCount).toBe(1);
|
||||
const orphanNode = result.nodes.find((n) => n.id === "entry-3");
|
||||
expect(orphanNode?.isOrphan).toBe(true);
|
||||
});
|
||||
|
||||
it("should filter by status", async () => {
|
||||
const entries = [
|
||||
{ ...mockEntry, id: "entry-1", status: "PUBLISHED", tags: [] },
|
||||
];
|
||||
|
||||
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue(entries);
|
||||
mockPrismaService.knowledgeLink.findMany.mockResolvedValue([]);
|
||||
|
||||
await service.getFullGraph("workspace-1", { status: "PUBLISHED" });
|
||||
|
||||
expect(mockPrismaService.knowledgeEntry.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
status: "PUBLISHED",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should filter by tags", async () => {
|
||||
const entries = [{ ...mockEntry, id: "entry-1", tags: [] }];
|
||||
|
||||
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue(entries);
|
||||
mockPrismaService.knowledgeLink.findMany.mockResolvedValue([]);
|
||||
|
||||
await service.getFullGraph("workspace-1", { tags: ["tag-1", "tag-2"] });
|
||||
|
||||
expect(mockPrismaService.knowledgeEntry.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
tags: {
|
||||
some: {
|
||||
tag: {
|
||||
slug: {
|
||||
in: ["tag-1", "tag-2"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should limit number of nodes", async () => {
|
||||
const entries = [
|
||||
{ ...mockEntry, id: "entry-1", slug: "entry-1", tags: [] },
|
||||
{ ...mockEntry, id: "entry-2", slug: "entry-2", tags: [] },
|
||||
];
|
||||
|
||||
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue(entries);
|
||||
mockPrismaService.knowledgeLink.findMany.mockResolvedValue([]);
|
||||
|
||||
await service.getFullGraph("workspace-1", { limit: 1 });
|
||||
|
||||
expect(mockPrismaService.knowledgeEntry.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
take: 1,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getGraphStats", () => {
|
||||
beforeEach(() => {
|
||||
mockPrismaService.knowledgeEntry.count = vi.fn();
|
||||
mockPrismaService.knowledgeEntry.findMany = vi.fn();
|
||||
mockPrismaService.knowledgeLink = {
|
||||
count: vi.fn(),
|
||||
groupBy: vi.fn(),
|
||||
};
|
||||
mockPrismaService.$queryRaw = vi.fn();
|
||||
});
|
||||
|
||||
it("should return graph statistics", async () => {
|
||||
mockPrismaService.knowledgeEntry.count.mockResolvedValue(10);
|
||||
mockPrismaService.knowledgeLink.count.mockResolvedValue(15);
|
||||
mockPrismaService.$queryRaw.mockResolvedValue([
|
||||
{ id: "entry-1", slug: "entry-1", title: "Entry 1", link_count: "5" },
|
||||
{ id: "entry-2", slug: "entry-2", title: "Entry 2", link_count: "3" },
|
||||
]);
|
||||
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([
|
||||
{ id: "orphan-1" },
|
||||
]);
|
||||
|
||||
const result = await service.getGraphStats("workspace-1");
|
||||
|
||||
expect(result.totalEntries).toBe(10);
|
||||
expect(result.totalLinks).toBe(15);
|
||||
expect(result.averageLinks).toBe(1.5);
|
||||
expect(result.mostConnectedEntries).toHaveLength(2);
|
||||
expect(result.mostConnectedEntries[0].linkCount).toBe(5);
|
||||
});
|
||||
|
||||
it("should calculate orphan entries correctly", async () => {
|
||||
mockPrismaService.knowledgeEntry.count.mockResolvedValue(5);
|
||||
mockPrismaService.knowledgeLink.count.mockResolvedValue(2);
|
||||
mockPrismaService.$queryRaw.mockResolvedValue([]);
|
||||
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([
|
||||
{ id: "orphan-1" },
|
||||
{ id: "orphan-2" },
|
||||
]);
|
||||
|
||||
const result = await service.getGraphStats("workspace-1");
|
||||
|
||||
expect(result.orphanEntries).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import type { EntryGraphResponse, GraphNode, GraphEdge } from "../entities/graph.entity";
|
||||
import type {
|
||||
EntryGraphResponse,
|
||||
GraphNode,
|
||||
GraphEdge,
|
||||
FullGraphResponse,
|
||||
GraphStatsResponse,
|
||||
} from "../entities/graph.entity";
|
||||
import { KnowledgeCacheService } from "./cache.service";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
interface GraphFilterOptions {
|
||||
tags?: string[];
|
||||
status?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for knowledge graph operations
|
||||
@@ -13,6 +26,32 @@ export class GraphService {
|
||||
private readonly cache: KnowledgeCacheService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get entry-centered graph view by slug
|
||||
* Helper method that looks up the entry ID first
|
||||
*/
|
||||
async getEntryGraphBySlug(
|
||||
workspaceId: string,
|
||||
slug: string,
|
||||
maxDepth = 1
|
||||
): Promise<EntryGraphResponse> {
|
||||
// Find entry by slug
|
||||
const entry = await this.prisma.knowledgeEntry.findUnique({
|
||||
where: {
|
||||
workspaceId_slug: {
|
||||
workspaceId,
|
||||
slug,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!entry) {
|
||||
throw new NotFoundException("Entry not found");
|
||||
}
|
||||
|
||||
return this.getEntryGraph(workspaceId, entry.id, maxDepth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entry-centered graph view
|
||||
* Returns the entry and all connected nodes up to specified depth
|
||||
@@ -187,4 +226,245 @@ export class GraphService {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full knowledge graph with optional filtering
|
||||
* Returns all entries and links in the workspace
|
||||
*/
|
||||
async getFullGraph(
|
||||
workspaceId: string,
|
||||
filters?: GraphFilterOptions
|
||||
): Promise<FullGraphResponse> {
|
||||
// Build where clause for entries
|
||||
const where: Prisma.KnowledgeEntryWhereInput = {
|
||||
workspaceId,
|
||||
};
|
||||
|
||||
if (filters?.status) {
|
||||
where.status = filters.status as Prisma.EnumEntryStatusFilter;
|
||||
}
|
||||
|
||||
if (filters?.tags && filters.tags.length > 0) {
|
||||
where.tags = {
|
||||
some: {
|
||||
tag: {
|
||||
slug: {
|
||||
in: filters.tags,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Build query options
|
||||
const queryOptions: {
|
||||
where: Prisma.KnowledgeEntryWhereInput;
|
||||
include: {
|
||||
tags: {
|
||||
include: {
|
||||
tag: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
take?: number;
|
||||
orderBy: {
|
||||
updatedAt: "desc";
|
||||
};
|
||||
} = {
|
||||
where,
|
||||
include: {
|
||||
tags: {
|
||||
include: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: "desc",
|
||||
},
|
||||
};
|
||||
|
||||
if (filters?.limit !== undefined) {
|
||||
queryOptions.take = filters.limit;
|
||||
}
|
||||
|
||||
// Fetch entries
|
||||
const entries = await this.prisma.knowledgeEntry.findMany(queryOptions);
|
||||
|
||||
// Get entry IDs for link filtering
|
||||
const entryIds = entries.map((e) => e.id);
|
||||
|
||||
// Fetch all links between these entries
|
||||
const links = await this.prisma.knowledgeLink.findMany({
|
||||
where: {
|
||||
sourceId: { in: entryIds },
|
||||
targetId: { in: entryIds },
|
||||
resolved: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Build nodes
|
||||
const nodes: GraphNode[] = entries.map((entry) => ({
|
||||
id: entry.id,
|
||||
slug: entry.slug,
|
||||
title: entry.title,
|
||||
summary: entry.summary,
|
||||
status: entry.status,
|
||||
tags: entry.tags.map(
|
||||
(et: { tag: { id: string; name: string; slug: string; color: string | null } }) => ({
|
||||
id: et.tag.id,
|
||||
name: et.tag.name,
|
||||
slug: et.tag.slug,
|
||||
color: et.tag.color,
|
||||
})
|
||||
),
|
||||
depth: 0, // Full graph has no depth concept
|
||||
isOrphan: false, // Will be calculated next
|
||||
}));
|
||||
|
||||
// Build edges
|
||||
const edges: GraphEdge[] = links.map((link) => ({
|
||||
id: link.id,
|
||||
sourceId: link.sourceId,
|
||||
targetId: link.targetId,
|
||||
linkText: link.linkText,
|
||||
}));
|
||||
|
||||
// Detect orphans (entries with no incoming or outgoing links)
|
||||
const connectedIds = new Set<string>();
|
||||
for (const edge of edges) {
|
||||
connectedIds.add(edge.sourceId);
|
||||
connectedIds.add(edge.targetId);
|
||||
}
|
||||
|
||||
let orphanCount = 0;
|
||||
for (const node of nodes) {
|
||||
if (!connectedIds.has(node.id)) {
|
||||
node.isOrphan = true;
|
||||
orphanCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
stats: {
|
||||
totalNodes: nodes.length,
|
||||
totalEdges: edges.length,
|
||||
orphanCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get graph statistics including orphan detection
|
||||
*/
|
||||
async getGraphStats(workspaceId: string): Promise<GraphStatsResponse> {
|
||||
// Get total counts
|
||||
const [totalEntries, totalLinks] = await Promise.all([
|
||||
this.prisma.knowledgeEntry.count({
|
||||
where: { workspaceId },
|
||||
}),
|
||||
this.prisma.knowledgeLink.count({
|
||||
where: {
|
||||
source: { workspaceId },
|
||||
resolved: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Calculate average links per entry
|
||||
const averageLinks = totalEntries > 0 ? totalLinks / totalEntries : 0;
|
||||
|
||||
// Find most connected entries using raw query for better performance
|
||||
const mostConnected = await this.prisma.$queryRaw<
|
||||
{
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
link_count: string;
|
||||
}[]
|
||||
>`
|
||||
SELECT
|
||||
e.id,
|
||||
e.slug,
|
||||
e.title,
|
||||
COUNT(DISTINCT l.id) as link_count
|
||||
FROM knowledge_entries e
|
||||
LEFT JOIN knowledge_links l ON (l.source_id = e.id OR l.target_id = e.id)
|
||||
WHERE e.workspace_id = ${workspaceId}::uuid
|
||||
AND (l.resolved = true OR l.id IS NULL)
|
||||
GROUP BY e.id, e.slug, e.title
|
||||
ORDER BY link_count DESC
|
||||
LIMIT 10
|
||||
`;
|
||||
|
||||
const mostConnectedEntries = mostConnected.map((entry) => ({
|
||||
id: entry.id,
|
||||
slug: entry.slug,
|
||||
title: entry.title,
|
||||
linkCount: parseInt(entry.link_count, 10),
|
||||
}));
|
||||
|
||||
// Find orphan entries (entries with no links)
|
||||
const orphanEntries = await this.prisma.knowledgeEntry.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
AND: [
|
||||
{
|
||||
outgoingLinks: {
|
||||
none: {
|
||||
resolved: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
incomingLinks: {
|
||||
none: {
|
||||
resolved: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Get tag distribution
|
||||
const tagGroups = await this.prisma.$queryRaw<
|
||||
{
|
||||
tag_id: string;
|
||||
tag_name: string;
|
||||
entry_count: string;
|
||||
}[]
|
||||
>`
|
||||
SELECT
|
||||
t.id as tag_id,
|
||||
t.name as tag_name,
|
||||
COUNT(DISTINCT et.entry_id) as entry_count
|
||||
FROM knowledge_tags t
|
||||
LEFT JOIN knowledge_entry_tags et ON et.tag_id = t.id
|
||||
WHERE t.workspace_id = ${workspaceId}::uuid
|
||||
GROUP BY t.id, t.name
|
||||
ORDER BY entry_count DESC
|
||||
LIMIT 20
|
||||
`;
|
||||
|
||||
const tagDistribution = tagGroups.map((tag) => ({
|
||||
tagId: tag.tag_id,
|
||||
tagName: tag.tag_name,
|
||||
entryCount: parseInt(tag.entry_count, 10),
|
||||
}));
|
||||
|
||||
return {
|
||||
totalEntries,
|
||||
totalLinks,
|
||||
orphanEntries: orphanEntries.length,
|
||||
averageLinks,
|
||||
mostConnectedEntries,
|
||||
tagDistribution,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export const orchestratorConfig = registerAs("orchestrator", () => ({
|
||||
valkey: {
|
||||
host: process.env.VALKEY_HOST ?? "localhost",
|
||||
port: parseInt(process.env.VALKEY_PORT ?? "6379", 10),
|
||||
password: process.env.VALKEY_PASSWORD,
|
||||
url: process.env.VALKEY_URL ?? "redis://localhost:6379",
|
||||
},
|
||||
claude: {
|
||||
@@ -22,5 +23,9 @@ export const orchestratorConfig = registerAs("orchestrator", () => ({
|
||||
},
|
||||
sandbox: {
|
||||
enabled: process.env.SANDBOX_ENABLED === "true",
|
||||
defaultImage: process.env.SANDBOX_DEFAULT_IMAGE ?? "node:20-alpine",
|
||||
defaultMemoryMB: parseInt(process.env.SANDBOX_DEFAULT_MEMORY_MB ?? "512", 10),
|
||||
defaultCpuLimit: parseFloat(process.env.SANDBOX_DEFAULT_CPU_LIMIT ?? "1.0"),
|
||||
networkMode: process.env.SANDBOX_NETWORK_MODE ?? "bridge",
|
||||
},
|
||||
}));
|
||||
|
||||
412
apps/orchestrator/src/git/conflict-detection.service.spec.ts
Normal file
412
apps/orchestrator/src/git/conflict-detection.service.spec.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ConflictDetectionService } from "./conflict-detection.service";
|
||||
import { ConflictDetectionError } from "./types";
|
||||
|
||||
// Mock simple-git
|
||||
const mockGit = {
|
||||
fetch: vi.fn(),
|
||||
status: vi.fn(),
|
||||
raw: vi.fn(),
|
||||
revparse: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("simple-git", () => ({
|
||||
simpleGit: vi.fn(() => mockGit),
|
||||
}));
|
||||
|
||||
describe("ConflictDetectionService", () => {
|
||||
let service: ConflictDetectionService;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Create service
|
||||
service = new ConflictDetectionService();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe("checkForConflicts", () => {
|
||||
it("should return no conflicts when branches can merge cleanly", async () => {
|
||||
// Mock successful fetch
|
||||
mockGit.fetch.mockResolvedValue(undefined);
|
||||
|
||||
// Mock current branch
|
||||
mockGit.revparse.mockResolvedValue("feature-branch");
|
||||
|
||||
// Mock merge test - no conflicts
|
||||
mockGit.raw.mockResolvedValue("");
|
||||
|
||||
// Mock status - no conflicted files
|
||||
mockGit.status.mockResolvedValue({
|
||||
conflicted: [],
|
||||
files: [],
|
||||
});
|
||||
|
||||
const result = await service.checkForConflicts("/test/repo", {
|
||||
remote: "origin",
|
||||
remoteBranch: "develop",
|
||||
strategy: "merge",
|
||||
});
|
||||
|
||||
expect(result.hasConflicts).toBe(false);
|
||||
expect(result.conflicts).toHaveLength(0);
|
||||
expect(result.strategy).toBe("merge");
|
||||
expect(result.remoteBranch).toBe("develop");
|
||||
expect(mockGit.fetch).toHaveBeenCalledWith("origin", "develop");
|
||||
});
|
||||
|
||||
it("should detect merge conflicts", async () => {
|
||||
// Mock successful fetch
|
||||
mockGit.fetch.mockResolvedValue(undefined);
|
||||
|
||||
// Mock current branch
|
||||
mockGit.revparse.mockResolvedValue("feature-branch");
|
||||
|
||||
// Mock merge test - conflicts detected
|
||||
mockGit.raw.mockRejectedValueOnce(
|
||||
new Error("CONFLICT (content): Merge conflict in file.ts"),
|
||||
);
|
||||
|
||||
// Mock status - show conflicted files
|
||||
mockGit.status.mockResolvedValue({
|
||||
conflicted: ["src/file.ts", "src/other.ts"],
|
||||
files: [
|
||||
{
|
||||
path: "src/file.ts",
|
||||
index: "U",
|
||||
working_dir: "U",
|
||||
},
|
||||
{
|
||||
path: "src/other.ts",
|
||||
index: "U",
|
||||
working_dir: "U",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Mock merge abort (cleanup)
|
||||
mockGit.raw.mockResolvedValue("");
|
||||
|
||||
const result = await service.checkForConflicts("/test/repo", {
|
||||
remote: "origin",
|
||||
remoteBranch: "develop",
|
||||
strategy: "merge",
|
||||
});
|
||||
|
||||
expect(result.hasConflicts).toBe(true);
|
||||
expect(result.conflicts).toHaveLength(2);
|
||||
expect(result.conflicts[0].file).toBe("src/file.ts");
|
||||
expect(result.conflicts[0].type).toBe("content");
|
||||
expect(result.canRetry).toBe(true);
|
||||
});
|
||||
|
||||
it("should detect rebase conflicts", async () => {
|
||||
// Mock successful fetch
|
||||
mockGit.fetch.mockResolvedValue(undefined);
|
||||
|
||||
// Mock current branch
|
||||
mockGit.revparse.mockResolvedValue("feature-branch");
|
||||
|
||||
// Mock rebase test - conflicts detected
|
||||
mockGit.raw.mockRejectedValueOnce(
|
||||
new Error("CONFLICT (content): Rebase conflict in file.ts"),
|
||||
);
|
||||
|
||||
// Mock status - show conflicted files
|
||||
mockGit.status.mockResolvedValue({
|
||||
conflicted: ["src/file.ts"],
|
||||
files: [
|
||||
{
|
||||
path: "src/file.ts",
|
||||
index: "U",
|
||||
working_dir: "U",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Mock rebase abort (cleanup)
|
||||
mockGit.raw.mockResolvedValue("");
|
||||
|
||||
const result = await service.checkForConflicts("/test/repo", {
|
||||
remote: "origin",
|
||||
remoteBranch: "develop",
|
||||
strategy: "rebase",
|
||||
});
|
||||
|
||||
expect(result.hasConflicts).toBe(true);
|
||||
expect(result.conflicts).toHaveLength(1);
|
||||
expect(result.strategy).toBe("rebase");
|
||||
});
|
||||
|
||||
it("should handle fetch failure", async () => {
|
||||
// Mock fetch failure
|
||||
mockGit.fetch.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
await expect(
|
||||
service.checkForConflicts("/test/repo", {
|
||||
remote: "origin",
|
||||
remoteBranch: "develop",
|
||||
}),
|
||||
).rejects.toThrow(ConflictDetectionError);
|
||||
});
|
||||
|
||||
it("should detect delete conflicts", async () => {
|
||||
// Mock successful fetch
|
||||
mockGit.fetch.mockResolvedValue(undefined);
|
||||
|
||||
// Mock current branch
|
||||
mockGit.revparse.mockResolvedValue("feature-branch");
|
||||
|
||||
// Mock merge test - conflicts detected
|
||||
mockGit.raw.mockRejectedValueOnce(
|
||||
new Error("CONFLICT (delete/modify): file.ts deleted in HEAD"),
|
||||
);
|
||||
|
||||
// Mock status - show conflicted files with delete
|
||||
mockGit.status.mockResolvedValue({
|
||||
conflicted: ["src/file.ts"],
|
||||
files: [
|
||||
{
|
||||
path: "src/file.ts",
|
||||
index: "D",
|
||||
working_dir: "U",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Mock merge abort
|
||||
mockGit.raw.mockResolvedValue("");
|
||||
|
||||
const result = await service.checkForConflicts("/test/repo", {
|
||||
remote: "origin",
|
||||
remoteBranch: "develop",
|
||||
strategy: "merge",
|
||||
});
|
||||
|
||||
expect(result.hasConflicts).toBe(true);
|
||||
expect(result.conflicts[0].type).toBe("delete");
|
||||
});
|
||||
|
||||
it("should detect add conflicts", async () => {
|
||||
// Mock successful fetch
|
||||
mockGit.fetch.mockResolvedValue(undefined);
|
||||
|
||||
// Mock current branch
|
||||
mockGit.revparse.mockResolvedValue("feature-branch");
|
||||
|
||||
// Mock merge test - conflicts detected
|
||||
mockGit.raw.mockRejectedValueOnce(
|
||||
new Error("CONFLICT (add/add): Merge conflict in file.ts"),
|
||||
);
|
||||
|
||||
// Mock status - show conflicted files with add
|
||||
mockGit.status.mockResolvedValue({
|
||||
conflicted: ["src/file.ts"],
|
||||
files: [
|
||||
{
|
||||
path: "src/file.ts",
|
||||
index: "A",
|
||||
working_dir: "A",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Mock merge abort
|
||||
mockGit.raw.mockResolvedValue("");
|
||||
|
||||
const result = await service.checkForConflicts("/test/repo", {
|
||||
remote: "origin",
|
||||
remoteBranch: "develop",
|
||||
strategy: "merge",
|
||||
});
|
||||
|
||||
expect(result.hasConflicts).toBe(true);
|
||||
expect(result.conflicts[0].type).toBe("add");
|
||||
});
|
||||
|
||||
it("should use default values for remote and branch", async () => {
|
||||
// Mock successful fetch
|
||||
mockGit.fetch.mockResolvedValue(undefined);
|
||||
|
||||
// Mock current branch
|
||||
mockGit.revparse.mockResolvedValue("feature-branch");
|
||||
|
||||
// Mock merge test - no conflicts
|
||||
mockGit.raw.mockResolvedValue("");
|
||||
|
||||
// Mock status - no conflicted files
|
||||
mockGit.status.mockResolvedValue({
|
||||
conflicted: [],
|
||||
files: [],
|
||||
});
|
||||
|
||||
const result = await service.checkForConflicts("/test/repo");
|
||||
|
||||
expect(result.remoteBranch).toBe("develop");
|
||||
expect(mockGit.fetch).toHaveBeenCalledWith("origin", "develop");
|
||||
});
|
||||
|
||||
it("should clean up after conflict detection", async () => {
|
||||
// Mock successful fetch
|
||||
mockGit.fetch.mockResolvedValue(undefined);
|
||||
|
||||
// Mock current branch
|
||||
mockGit.revparse.mockResolvedValue("feature-branch");
|
||||
|
||||
// Mock merge test - conflicts
|
||||
mockGit.raw.mockRejectedValueOnce(new Error("CONFLICT"));
|
||||
|
||||
// Mock status
|
||||
mockGit.status.mockResolvedValue({
|
||||
conflicted: ["src/file.ts"],
|
||||
files: [],
|
||||
});
|
||||
|
||||
// Track raw calls
|
||||
const rawCalls: string[][] = [];
|
||||
mockGit.raw.mockImplementation((args: string[]) => {
|
||||
rawCalls.push(args);
|
||||
if (args[0] === "merge") {
|
||||
if (args[1] === "--abort") {
|
||||
return Promise.resolve("");
|
||||
}
|
||||
return Promise.reject(new Error("CONFLICT"));
|
||||
}
|
||||
return Promise.resolve("");
|
||||
});
|
||||
|
||||
await service.checkForConflicts("/test/repo", {
|
||||
strategy: "merge",
|
||||
});
|
||||
|
||||
// Verify abort was called
|
||||
expect(rawCalls).toContainEqual(["merge", "--abort"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchRemote", () => {
|
||||
it("should fetch from remote successfully", async () => {
|
||||
mockGit.fetch.mockResolvedValue(undefined);
|
||||
|
||||
await service.fetchRemote("/test/repo", "origin", "develop");
|
||||
|
||||
expect(mockGit.fetch).toHaveBeenCalledWith("origin", "develop");
|
||||
});
|
||||
|
||||
it("should throw ConflictDetectionError on fetch failure", async () => {
|
||||
mockGit.fetch.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
await expect(
|
||||
service.fetchRemote("/test/repo", "origin", "develop"),
|
||||
).rejects.toThrow(ConflictDetectionError);
|
||||
});
|
||||
|
||||
it("should use default remote", async () => {
|
||||
mockGit.fetch.mockResolvedValue(undefined);
|
||||
|
||||
await service.fetchRemote("/test/repo");
|
||||
|
||||
expect(mockGit.fetch).toHaveBeenCalledWith("origin", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectConflicts", () => {
|
||||
it("should return empty array when no conflicts", async () => {
|
||||
mockGit.status.mockResolvedValue({
|
||||
conflicted: [],
|
||||
files: [],
|
||||
});
|
||||
|
||||
const conflicts = await service.detectConflicts("/test/repo");
|
||||
|
||||
expect(conflicts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should detect conflicted files", async () => {
|
||||
mockGit.status.mockResolvedValue({
|
||||
conflicted: ["src/file1.ts", "src/file2.ts"],
|
||||
files: [
|
||||
{
|
||||
path: "src/file1.ts",
|
||||
index: "U",
|
||||
working_dir: "U",
|
||||
},
|
||||
{
|
||||
path: "src/file2.ts",
|
||||
index: "U",
|
||||
working_dir: "U",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const conflicts = await service.detectConflicts("/test/repo");
|
||||
|
||||
expect(conflicts).toHaveLength(2);
|
||||
expect(conflicts[0].file).toBe("src/file1.ts");
|
||||
expect(conflicts[1].file).toBe("src/file2.ts");
|
||||
});
|
||||
|
||||
it("should determine conflict type from git status", async () => {
|
||||
mockGit.status.mockResolvedValue({
|
||||
conflicted: ["deleted.ts", "added.ts", "modified.ts"],
|
||||
files: [
|
||||
{
|
||||
path: "deleted.ts",
|
||||
index: "D",
|
||||
working_dir: "U",
|
||||
},
|
||||
{
|
||||
path: "added.ts",
|
||||
index: "A",
|
||||
working_dir: "A",
|
||||
},
|
||||
{
|
||||
path: "modified.ts",
|
||||
index: "U",
|
||||
working_dir: "U",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const conflicts = await service.detectConflicts("/test/repo");
|
||||
|
||||
expect(conflicts[0].type).toBe("delete");
|
||||
expect(conflicts[1].type).toBe("add");
|
||||
expect(conflicts[2].type).toBe("content");
|
||||
});
|
||||
|
||||
it("should throw ConflictDetectionError on git status failure", async () => {
|
||||
mockGit.status.mockRejectedValue(new Error("Git error"));
|
||||
|
||||
await expect(service.detectConflicts("/test/repo")).rejects.toThrow(
|
||||
ConflictDetectionError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCurrentBranch", () => {
|
||||
it("should return current branch name", async () => {
|
||||
mockGit.revparse.mockResolvedValue("feature-branch");
|
||||
|
||||
const branch = await service.getCurrentBranch("/test/repo");
|
||||
|
||||
expect(branch).toBe("feature-branch");
|
||||
expect(mockGit.revparse).toHaveBeenCalledWith([
|
||||
"--abbrev-ref",
|
||||
"HEAD",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should throw ConflictDetectionError on failure", async () => {
|
||||
mockGit.revparse.mockRejectedValue(new Error("Not a git repository"));
|
||||
|
||||
await expect(service.getCurrentBranch("/test/repo")).rejects.toThrow(
|
||||
ConflictDetectionError,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
240
apps/orchestrator/src/git/conflict-detection.service.ts
Normal file
240
apps/orchestrator/src/git/conflict-detection.service.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { simpleGit, SimpleGit, StatusResult } from "simple-git";
|
||||
import {
|
||||
ConflictCheckResult,
|
||||
ConflictInfo,
|
||||
ConflictCheckOptions,
|
||||
ConflictDetectionError,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Service for detecting merge conflicts before pushing
|
||||
*/
|
||||
@Injectable()
|
||||
export class ConflictDetectionService {
|
||||
private readonly logger = new Logger(ConflictDetectionService.name);
|
||||
|
||||
/**
|
||||
* Get a simple-git instance for a local path
|
||||
*/
|
||||
private getGit(localPath: string): SimpleGit {
|
||||
return simpleGit(localPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for conflicts before pushing
|
||||
* Fetches latest from remote and attempts a test merge/rebase
|
||||
*/
|
||||
async checkForConflicts(
|
||||
localPath: string,
|
||||
options?: ConflictCheckOptions,
|
||||
): Promise<ConflictCheckResult> {
|
||||
const remote = options?.remote ?? "origin";
|
||||
const remoteBranch = options?.remoteBranch ?? "develop";
|
||||
const strategy = options?.strategy ?? "merge";
|
||||
|
||||
try {
|
||||
this.logger.log(
|
||||
`Checking for conflicts in ${localPath} with ${remote}/${remoteBranch} using ${strategy}`,
|
||||
);
|
||||
|
||||
// Get current branch
|
||||
const localBranch = await this.getCurrentBranch(localPath);
|
||||
|
||||
// Fetch latest from remote
|
||||
await this.fetchRemote(localPath, remote, remoteBranch);
|
||||
|
||||
// Attempt test merge/rebase
|
||||
const hasConflicts = await this.attemptMerge(
|
||||
localPath,
|
||||
remote,
|
||||
remoteBranch,
|
||||
strategy,
|
||||
);
|
||||
|
||||
if (!hasConflicts) {
|
||||
this.logger.log("No conflicts detected");
|
||||
return {
|
||||
hasConflicts: false,
|
||||
conflicts: [],
|
||||
strategy,
|
||||
canRetry: false,
|
||||
remoteBranch,
|
||||
localBranch,
|
||||
};
|
||||
}
|
||||
|
||||
// Detect conflicts
|
||||
const conflicts = await this.detectConflicts(localPath);
|
||||
|
||||
// Cleanup - abort the merge/rebase
|
||||
await this.cleanupMerge(localPath, strategy);
|
||||
|
||||
this.logger.log(`Detected ${conflicts.length} conflicts`);
|
||||
|
||||
return {
|
||||
hasConflicts: true,
|
||||
conflicts,
|
||||
strategy,
|
||||
canRetry: true,
|
||||
remoteBranch,
|
||||
localBranch,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to check for conflicts: ${error}`);
|
||||
throw new ConflictDetectionError(
|
||||
`Failed to check for conflicts in ${localPath}`,
|
||||
"checkForConflicts",
|
||||
error as Error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch latest from remote
|
||||
*/
|
||||
async fetchRemote(
|
||||
localPath: string,
|
||||
remote: string = "origin",
|
||||
branch?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
this.logger.log(`Fetching from ${remote}${branch ? `/${branch}` : ""}`);
|
||||
const git = this.getGit(localPath);
|
||||
await git.fetch(remote, branch);
|
||||
this.logger.log("Successfully fetched from remote");
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch from remote: ${error}`);
|
||||
throw new ConflictDetectionError(
|
||||
`Failed to fetch from ${remote}`,
|
||||
"fetchRemote",
|
||||
error as Error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect conflicts in current state
|
||||
*/
|
||||
async detectConflicts(localPath: string): Promise<ConflictInfo[]> {
|
||||
try {
|
||||
const git = this.getGit(localPath);
|
||||
const status: StatusResult = await git.status();
|
||||
|
||||
const conflicts: ConflictInfo[] = [];
|
||||
|
||||
// Process conflicted files
|
||||
for (const file of status.conflicted) {
|
||||
// Find the file in status.files to get more details
|
||||
const fileStatus = status.files.find((f) => f.path === file);
|
||||
|
||||
// Determine conflict type
|
||||
let type: ConflictInfo["type"] = "content";
|
||||
if (fileStatus) {
|
||||
if (fileStatus.index === "D" || fileStatus.working_dir === "D") {
|
||||
type = "delete";
|
||||
} else if (fileStatus.index === "A" && fileStatus.working_dir === "A") {
|
||||
type = "add";
|
||||
} else if (fileStatus.index === "R" || fileStatus.working_dir === "R") {
|
||||
type = "rename";
|
||||
}
|
||||
}
|
||||
|
||||
conflicts.push({
|
||||
file,
|
||||
type,
|
||||
});
|
||||
}
|
||||
|
||||
return conflicts;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to detect conflicts: ${error}`);
|
||||
throw new ConflictDetectionError(
|
||||
`Failed to detect conflicts in ${localPath}`,
|
||||
"detectConflicts",
|
||||
error as Error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current branch name
|
||||
*/
|
||||
async getCurrentBranch(localPath: string): Promise<string> {
|
||||
try {
|
||||
const git = this.getGit(localPath);
|
||||
const branch = await git.revparse(["--abbrev-ref", "HEAD"]);
|
||||
return branch.trim();
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get current branch: ${error}`);
|
||||
throw new ConflictDetectionError(
|
||||
`Failed to get current branch in ${localPath}`,
|
||||
"getCurrentBranch",
|
||||
error as Error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt a test merge/rebase to detect conflicts
|
||||
* Returns true if conflicts detected, false if clean
|
||||
*/
|
||||
private async attemptMerge(
|
||||
localPath: string,
|
||||
remote: string,
|
||||
remoteBranch: string,
|
||||
strategy: "merge" | "rebase",
|
||||
): Promise<boolean> {
|
||||
const git = this.getGit(localPath);
|
||||
const remoteRef = `${remote}/${remoteBranch}`;
|
||||
|
||||
try {
|
||||
if (strategy === "merge") {
|
||||
// Attempt test merge with --no-commit and --no-ff
|
||||
await git.raw(["merge", "--no-commit", "--no-ff", remoteRef]);
|
||||
} else {
|
||||
// Attempt test rebase
|
||||
await git.raw(["rebase", remoteRef]);
|
||||
}
|
||||
|
||||
// If we get here, no conflicts
|
||||
return false;
|
||||
} catch (error) {
|
||||
// Check if error is due to conflicts
|
||||
const errorMessage = (error as Error).message || String(error);
|
||||
if (
|
||||
errorMessage.includes("CONFLICT") ||
|
||||
errorMessage.includes("conflict")
|
||||
) {
|
||||
// Conflicts detected
|
||||
return true;
|
||||
}
|
||||
|
||||
// Other error - rethrow
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup after test merge/rebase
|
||||
*/
|
||||
private async cleanupMerge(
|
||||
localPath: string,
|
||||
strategy: "merge" | "rebase",
|
||||
): Promise<void> {
|
||||
try {
|
||||
const git = this.getGit(localPath);
|
||||
|
||||
if (strategy === "merge") {
|
||||
await git.raw(["merge", "--abort"]);
|
||||
} else {
|
||||
await git.raw(["rebase", "--abort"]);
|
||||
}
|
||||
|
||||
this.logger.log(`Cleaned up ${strategy} operation`);
|
||||
} catch (error) {
|
||||
// Log warning but don't throw - cleanup is best-effort
|
||||
this.logger.warn(`Failed to cleanup ${strategy}: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
229
apps/orchestrator/src/git/git-operations.service.spec.ts
Normal file
229
apps/orchestrator/src/git/git-operations.service.spec.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { GitOperationsService } from "./git-operations.service";
|
||||
import { GitOperationError } from "./types";
|
||||
|
||||
// Mock simple-git
|
||||
const mockGit = {
|
||||
clone: vi.fn(),
|
||||
checkoutLocalBranch: vi.fn(),
|
||||
add: vi.fn(),
|
||||
commit: vi.fn(),
|
||||
push: vi.fn(),
|
||||
addConfig: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("simple-git", () => ({
|
||||
simpleGit: vi.fn(() => mockGit),
|
||||
}));
|
||||
|
||||
describe("GitOperationsService", () => {
|
||||
let service: GitOperationsService;
|
||||
let mockConfigService: ConfigService;
|
||||
|
||||
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 service with mock
|
||||
service = new GitOperationsService(mockConfigService);
|
||||
});
|
||||
|
||||
describe("cloneRepository", () => {
|
||||
it("should clone a repository successfully", async () => {
|
||||
mockGit.clone.mockResolvedValue(undefined);
|
||||
|
||||
await service.cloneRepository("https://github.com/test/repo.git", "/tmp/repo");
|
||||
|
||||
expect(mockGit.clone).toHaveBeenCalledWith(
|
||||
"https://github.com/test/repo.git",
|
||||
"/tmp/repo",
|
||||
);
|
||||
});
|
||||
|
||||
it("should clone a repository with specific branch", async () => {
|
||||
mockGit.clone.mockResolvedValue(undefined);
|
||||
|
||||
await service.cloneRepository(
|
||||
"https://github.com/test/repo.git",
|
||||
"/tmp/repo",
|
||||
"develop",
|
||||
);
|
||||
|
||||
expect(mockGit.clone).toHaveBeenCalledWith(
|
||||
"https://github.com/test/repo.git",
|
||||
"/tmp/repo",
|
||||
["--branch", "develop"],
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw GitOperationError on clone failure", async () => {
|
||||
const error = new Error("Clone failed");
|
||||
mockGit.clone.mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
service.cloneRepository("https://github.com/test/repo.git", "/tmp/repo"),
|
||||
).rejects.toThrow(GitOperationError);
|
||||
|
||||
try {
|
||||
await service.cloneRepository(
|
||||
"https://github.com/test/repo.git",
|
||||
"/tmp/repo",
|
||||
);
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(GitOperationError);
|
||||
expect((e as GitOperationError).operation).toBe("clone");
|
||||
expect((e as GitOperationError).cause).toBe(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createBranch", () => {
|
||||
it("should create and checkout a new branch", async () => {
|
||||
mockGit.checkoutLocalBranch.mockResolvedValue(undefined);
|
||||
|
||||
await service.createBranch("/tmp/repo", "feature/new-branch");
|
||||
|
||||
expect(mockGit.checkoutLocalBranch).toHaveBeenCalledWith(
|
||||
"feature/new-branch",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw GitOperationError on branch creation failure", async () => {
|
||||
const error = new Error("Branch already exists");
|
||||
mockGit.checkoutLocalBranch.mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
service.createBranch("/tmp/repo", "feature/new-branch"),
|
||||
).rejects.toThrow(GitOperationError);
|
||||
|
||||
try {
|
||||
await service.createBranch("/tmp/repo", "feature/new-branch");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(GitOperationError);
|
||||
expect((e as GitOperationError).operation).toBe("createBranch");
|
||||
expect((e as GitOperationError).cause).toBe(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("commit", () => {
|
||||
it("should stage all changes and commit with message", async () => {
|
||||
mockGit.add.mockResolvedValue(undefined);
|
||||
mockGit.commit.mockResolvedValue({ commit: "abc123" });
|
||||
|
||||
await service.commit("/tmp/repo", "feat: add new feature");
|
||||
|
||||
expect(mockGit.add).toHaveBeenCalledWith(".");
|
||||
expect(mockGit.commit).toHaveBeenCalledWith("feat: add new feature");
|
||||
});
|
||||
|
||||
it("should stage specific files when provided", async () => {
|
||||
mockGit.add.mockResolvedValue(undefined);
|
||||
mockGit.commit.mockResolvedValue({ commit: "abc123" });
|
||||
|
||||
await service.commit("/tmp/repo", "fix: update files", [
|
||||
"file1.ts",
|
||||
"file2.ts",
|
||||
]);
|
||||
|
||||
expect(mockGit.add).toHaveBeenCalledWith(["file1.ts", "file2.ts"]);
|
||||
expect(mockGit.commit).toHaveBeenCalledWith("fix: update files");
|
||||
});
|
||||
|
||||
it("should configure git user before committing", async () => {
|
||||
mockGit.add.mockResolvedValue(undefined);
|
||||
mockGit.commit.mockResolvedValue({ commit: "abc123" });
|
||||
mockGit.addConfig.mockResolvedValue(undefined);
|
||||
|
||||
await service.commit("/tmp/repo", "test commit");
|
||||
|
||||
expect(mockGit.addConfig).toHaveBeenCalledWith("user.name", "Test User");
|
||||
expect(mockGit.addConfig).toHaveBeenCalledWith(
|
||||
"user.email",
|
||||
"test@example.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw GitOperationError on commit failure", async () => {
|
||||
mockGit.add.mockResolvedValue(undefined);
|
||||
const error = new Error("Nothing to commit");
|
||||
mockGit.commit.mockRejectedValue(error);
|
||||
|
||||
await expect(service.commit("/tmp/repo", "test commit")).rejects.toThrow(
|
||||
GitOperationError,
|
||||
);
|
||||
|
||||
try {
|
||||
await service.commit("/tmp/repo", "test commit");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(GitOperationError);
|
||||
expect((e as GitOperationError).operation).toBe("commit");
|
||||
expect((e as GitOperationError).cause).toBe(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("push", () => {
|
||||
it("should push to origin and current branch by default", async () => {
|
||||
mockGit.push.mockResolvedValue(undefined);
|
||||
|
||||
await service.push("/tmp/repo");
|
||||
|
||||
expect(mockGit.push).toHaveBeenCalledWith("origin", undefined);
|
||||
});
|
||||
|
||||
it("should push to specified remote and branch", async () => {
|
||||
mockGit.push.mockResolvedValue(undefined);
|
||||
|
||||
await service.push("/tmp/repo", "upstream", "main");
|
||||
|
||||
expect(mockGit.push).toHaveBeenCalledWith("upstream", "main");
|
||||
});
|
||||
|
||||
it("should support force push", async () => {
|
||||
mockGit.push.mockResolvedValue(undefined);
|
||||
|
||||
await service.push("/tmp/repo", "origin", "develop", true);
|
||||
|
||||
expect(mockGit.push).toHaveBeenCalledWith("origin", "develop", {
|
||||
"--force": null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw GitOperationError on push failure", async () => {
|
||||
const error = new Error("Push rejected");
|
||||
mockGit.push.mockRejectedValue(error);
|
||||
|
||||
await expect(service.push("/tmp/repo")).rejects.toThrow(GitOperationError);
|
||||
|
||||
try {
|
||||
await service.push("/tmp/repo");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(GitOperationError);
|
||||
expect((e as GitOperationError).operation).toBe("push");
|
||||
expect((e as GitOperationError).cause).toBe(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("git config", () => {
|
||||
it("should read git config from ConfigService", () => {
|
||||
expect(mockConfigService.get("orchestrator.git.userName")).toBe(
|
||||
"Test User",
|
||||
);
|
||||
expect(mockConfigService.get("orchestrator.git.userEmail")).toBe(
|
||||
"test@example.com",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
147
apps/orchestrator/src/git/git-operations.service.ts
Normal file
147
apps/orchestrator/src/git/git-operations.service.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { simpleGit, SimpleGit } from "simple-git";
|
||||
import { GitOperationError } from "./types";
|
||||
|
||||
/**
|
||||
* Service for managing git operations
|
||||
*/
|
||||
@Injectable()
|
||||
export class GitOperationsService {
|
||||
private readonly logger = new Logger(GitOperationsService.name);
|
||||
private readonly gitUserName: string;
|
||||
private readonly gitUserEmail: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.gitUserName =
|
||||
this.configService.get<string>("orchestrator.git.userName") ??
|
||||
"Mosaic Orchestrator";
|
||||
this.gitUserEmail =
|
||||
this.configService.get<string>("orchestrator.git.userEmail") ??
|
||||
"orchestrator@mosaicstack.dev";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a simple-git instance for a local path
|
||||
*/
|
||||
private getGit(localPath: string): SimpleGit {
|
||||
return simpleGit(localPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone a repository
|
||||
*/
|
||||
async cloneRepository(
|
||||
url: string,
|
||||
localPath: string,
|
||||
branch?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
this.logger.log(`Cloning repository ${url} to ${localPath}`);
|
||||
const git = simpleGit();
|
||||
|
||||
if (branch) {
|
||||
await git.clone(url, localPath, ["--branch", branch]);
|
||||
} else {
|
||||
await git.clone(url, localPath);
|
||||
}
|
||||
|
||||
this.logger.log(`Successfully cloned repository to ${localPath}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to clone repository: ${error}`);
|
||||
throw new GitOperationError(
|
||||
`Failed to clone repository from ${url}`,
|
||||
"clone",
|
||||
error as Error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new branch
|
||||
*/
|
||||
async createBranch(localPath: string, branchName: string): Promise<void> {
|
||||
try {
|
||||
this.logger.log(`Creating branch ${branchName} at ${localPath}`);
|
||||
const git = this.getGit(localPath);
|
||||
|
||||
await git.checkoutLocalBranch(branchName);
|
||||
|
||||
this.logger.log(`Successfully created branch ${branchName}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to create branch: ${error}`);
|
||||
throw new GitOperationError(
|
||||
`Failed to create branch ${branchName}`,
|
||||
"createBranch",
|
||||
error as Error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit changes
|
||||
*/
|
||||
async commit(
|
||||
localPath: string,
|
||||
message: string,
|
||||
files?: string[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
this.logger.log(`Committing changes at ${localPath}`);
|
||||
const git = this.getGit(localPath);
|
||||
|
||||
// Configure git user
|
||||
await git.addConfig("user.name", this.gitUserName);
|
||||
await git.addConfig("user.email", this.gitUserEmail);
|
||||
|
||||
// Stage files
|
||||
if (files && files.length > 0) {
|
||||
await git.add(files);
|
||||
} else {
|
||||
await git.add(".");
|
||||
}
|
||||
|
||||
// Commit
|
||||
await git.commit(message);
|
||||
|
||||
this.logger.log(`Successfully committed changes: ${message}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to commit: ${error}`);
|
||||
throw new GitOperationError(
|
||||
`Failed to commit changes`,
|
||||
"commit",
|
||||
error as Error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push changes to remote
|
||||
*/
|
||||
async push(
|
||||
localPath: string,
|
||||
remote: string = "origin",
|
||||
branch?: string,
|
||||
force: boolean = false,
|
||||
): Promise<void> {
|
||||
try {
|
||||
this.logger.log(`Pushing changes from ${localPath} to ${remote}`);
|
||||
const git = this.getGit(localPath);
|
||||
|
||||
if (force) {
|
||||
await git.push(remote, branch, { "--force": null });
|
||||
} else {
|
||||
await git.push(remote, branch);
|
||||
}
|
||||
|
||||
this.logger.log(`Successfully pushed changes to ${remote}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to push: ${error}`);
|
||||
throw new GitOperationError(
|
||||
`Failed to push changes to ${remote}`,
|
||||
"push",
|
||||
error as Error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,20 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { GitOperationsService } from "./git-operations.service";
|
||||
import { WorktreeManagerService } from "./worktree-manager.service";
|
||||
import { ConflictDetectionService } from "./conflict-detection.service";
|
||||
|
||||
@Module({})
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [
|
||||
GitOperationsService,
|
||||
WorktreeManagerService,
|
||||
ConflictDetectionService,
|
||||
],
|
||||
exports: [
|
||||
GitOperationsService,
|
||||
WorktreeManagerService,
|
||||
ConflictDetectionService,
|
||||
],
|
||||
})
|
||||
export class GitModule {}
|
||||
|
||||
5
apps/orchestrator/src/git/index.ts
Normal file
5
apps/orchestrator/src/git/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from "./git.module";
|
||||
export * from "./git-operations.service";
|
||||
export * from "./worktree-manager.service";
|
||||
export * from "./conflict-detection.service";
|
||||
export * from "./types";
|
||||
45
apps/orchestrator/src/git/types/conflict-detection.types.ts
Normal file
45
apps/orchestrator/src/git/types/conflict-detection.types.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Result of conflict check operation
|
||||
*/
|
||||
export interface ConflictCheckResult {
|
||||
hasConflicts: boolean;
|
||||
conflicts: ConflictInfo[];
|
||||
strategy: "merge" | "rebase";
|
||||
canRetry: boolean;
|
||||
remoteBranch: string;
|
||||
localBranch: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about a single conflict
|
||||
*/
|
||||
export interface ConflictInfo {
|
||||
file: string;
|
||||
type: "content" | "delete" | "add" | "rename";
|
||||
ours?: string;
|
||||
theirs?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for checking conflicts
|
||||
*/
|
||||
export interface ConflictCheckOptions {
|
||||
localPath: string;
|
||||
remote?: string;
|
||||
remoteBranch?: string;
|
||||
strategy?: "merge" | "rebase";
|
||||
}
|
||||
|
||||
/**
|
||||
* Conflict detection error types
|
||||
*/
|
||||
export class ConflictDetectionError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly operation: string,
|
||||
public readonly cause?: Error,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ConflictDetectionError";
|
||||
}
|
||||
}
|
||||
58
apps/orchestrator/src/git/types/git-operations.types.ts
Normal file
58
apps/orchestrator/src/git/types/git-operations.types.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Git operation error types
|
||||
*/
|
||||
export class GitOperationError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly operation: string,
|
||||
public readonly cause?: Error,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "GitOperationError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for cloning a repository
|
||||
*/
|
||||
export interface CloneOptions {
|
||||
url: string;
|
||||
localPath: string;
|
||||
branch?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating a branch
|
||||
*/
|
||||
export interface CreateBranchOptions {
|
||||
localPath: string;
|
||||
branchName: string;
|
||||
checkout?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for committing changes
|
||||
*/
|
||||
export interface CommitOptions {
|
||||
localPath: string;
|
||||
message: string;
|
||||
files?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for pushing changes
|
||||
*/
|
||||
export interface PushOptions {
|
||||
localPath: string;
|
||||
remote?: string;
|
||||
branch?: string;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Git configuration
|
||||
*/
|
||||
export interface GitConfig {
|
||||
userName: string;
|
||||
userEmail: string;
|
||||
}
|
||||
3
apps/orchestrator/src/git/types/index.ts
Normal file
3
apps/orchestrator/src/git/types/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./git-operations.types";
|
||||
export * from "./worktree-manager.types";
|
||||
export * from "./conflict-detection.types";
|
||||
32
apps/orchestrator/src/git/types/worktree-manager.types.ts
Normal file
32
apps/orchestrator/src/git/types/worktree-manager.types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Worktree information
|
||||
*/
|
||||
export interface WorktreeInfo {
|
||||
path: string;
|
||||
branch: string;
|
||||
commit: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating a worktree
|
||||
*/
|
||||
export interface CreateWorktreeOptions {
|
||||
repoPath: string;
|
||||
agentId: string;
|
||||
taskId: string;
|
||||
baseBranch?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Worktree error types
|
||||
*/
|
||||
export class WorktreeError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly operation: string,
|
||||
public readonly cause?: Error,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "WorktreeError";
|
||||
}
|
||||
}
|
||||
346
apps/orchestrator/src/git/worktree-manager.service.spec.ts
Normal file
346
apps/orchestrator/src/git/worktree-manager.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
245
apps/orchestrator/src/queue/README.md
Normal file
245
apps/orchestrator/src/queue/README.md
Normal file
@@ -0,0 +1,245 @@
|
||||
# Queue Module
|
||||
|
||||
BullMQ-based task queue with priority ordering and retry logic.
|
||||
|
||||
## Overview
|
||||
|
||||
The Queue module provides a robust task queuing system for the orchestrator service using BullMQ and Valkey (Redis-compatible). It supports priority-based task ordering, exponential backoff retry logic, and real-time queue monitoring.
|
||||
|
||||
## Features
|
||||
|
||||
- **Priority-based ordering** (1-10): Higher priority tasks processed first
|
||||
- **Retry logic**: Exponential backoff on failures
|
||||
- **Queue monitoring**: Real-time statistics (pending, active, completed, failed)
|
||||
- **Queue control**: Pause/resume processing
|
||||
- **Event pub/sub**: Task lifecycle events published to Valkey
|
||||
- **Task removal**: Remove tasks from queue
|
||||
|
||||
## Usage
|
||||
|
||||
### Adding Tasks
|
||||
|
||||
```typescript
|
||||
import { QueueService } from './queue/queue.service';
|
||||
|
||||
@Injectable()
|
||||
export class MyService {
|
||||
constructor(private readonly queueService: QueueService) {}
|
||||
|
||||
async createTask() {
|
||||
const context = {
|
||||
repository: 'my-org/my-repo',
|
||||
branch: 'main',
|
||||
workItems: ['task-1', 'task-2'],
|
||||
};
|
||||
|
||||
// Add task with default options (priority 5, maxRetries 3)
|
||||
await this.queueService.addTask('task-123', context);
|
||||
|
||||
// Add high-priority task with custom retries
|
||||
await this.queueService.addTask('urgent-task', context, {
|
||||
priority: 10, // Highest priority
|
||||
maxRetries: 5,
|
||||
});
|
||||
|
||||
// Add delayed task (5 second delay)
|
||||
await this.queueService.addTask('delayed-task', context, {
|
||||
delay: 5000,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Monitoring Queue
|
||||
|
||||
```typescript
|
||||
async function monitorQueue() {
|
||||
const stats = await this.queueService.getStats();
|
||||
console.log(stats);
|
||||
// {
|
||||
// pending: 5,
|
||||
// active: 2,
|
||||
// completed: 10,
|
||||
// failed: 1,
|
||||
// delayed: 0
|
||||
// }
|
||||
}
|
||||
```
|
||||
|
||||
### Queue Control
|
||||
|
||||
```typescript
|
||||
// Pause queue processing
|
||||
await this.queueService.pause();
|
||||
|
||||
// Resume queue processing
|
||||
await this.queueService.resume();
|
||||
|
||||
// Remove task from queue
|
||||
await this.queueService.removeTask('task-123');
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Configure via environment variables:
|
||||
|
||||
```bash
|
||||
# Valkey connection
|
||||
ORCHESTRATOR_VALKEY_HOST=localhost
|
||||
ORCHESTRATOR_VALKEY_PORT=6379
|
||||
ORCHESTRATOR_VALKEY_PASSWORD=secret
|
||||
|
||||
# Queue configuration
|
||||
ORCHESTRATOR_QUEUE_NAME=orchestrator-tasks
|
||||
ORCHESTRATOR_QUEUE_MAX_RETRIES=3
|
||||
ORCHESTRATOR_QUEUE_BASE_DELAY=1000 # 1 second
|
||||
ORCHESTRATOR_QUEUE_MAX_DELAY=60000 # 1 minute
|
||||
ORCHESTRATOR_QUEUE_CONCURRENCY=5 # 5 concurrent workers
|
||||
```
|
||||
|
||||
## Priority
|
||||
|
||||
Priority range: 1-10
|
||||
|
||||
- **10**: Highest priority (processed first)
|
||||
- **5**: Default priority
|
||||
- **1**: Lowest priority (processed last)
|
||||
|
||||
Internally, priorities are inverted for BullMQ (which uses lower numbers for higher priority).
|
||||
|
||||
## Retry Logic
|
||||
|
||||
Failed tasks are automatically retried with exponential backoff:
|
||||
|
||||
- **Attempt 1**: Wait 2 seconds (baseDelay * 2^1)
|
||||
- **Attempt 2**: Wait 4 seconds (baseDelay * 2^2)
|
||||
- **Attempt 3**: Wait 8 seconds (baseDelay * 2^3)
|
||||
- **Attempt 4+**: Capped at maxDelay (default 60 seconds)
|
||||
|
||||
Configure retry behavior:
|
||||
- `maxRetries`: Number of retry attempts (default: 3)
|
||||
- `baseDelay`: Base delay in milliseconds (default: 1000)
|
||||
- `maxDelay`: Maximum delay cap (default: 60000)
|
||||
|
||||
## Events
|
||||
|
||||
The queue publishes events to Valkey pub/sub:
|
||||
|
||||
- `task.queued`: Task added to queue
|
||||
- `task.processing`: Task started processing
|
||||
- `task.retry`: Task retrying after failure
|
||||
- `task.completed`: Task completed successfully
|
||||
- `task.failed`: Task failed permanently
|
||||
|
||||
Subscribe to events:
|
||||
|
||||
```typescript
|
||||
await valkeyService.subscribeToEvents((event) => {
|
||||
if (event.type === 'task.completed') {
|
||||
console.log('Task completed:', event.data.taskId);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ QueueService│
|
||||
└──────┬──────┘
|
||||
│
|
||||
├──────────> BullMQ Queue (adds tasks)
|
||||
│
|
||||
├──────────> BullMQ Worker (processes tasks)
|
||||
│
|
||||
└──────────> ValkeyService (state + events)
|
||||
```
|
||||
|
||||
### Components
|
||||
|
||||
1. **QueueService**: Main service for queue operations
|
||||
2. **BullMQ Queue**: Task queue with priority and retry
|
||||
3. **BullMQ Worker**: Processes tasks from queue
|
||||
4. **ValkeyService**: State management and pub/sub
|
||||
|
||||
## Types
|
||||
|
||||
### QueuedTask
|
||||
|
||||
```typescript
|
||||
interface QueuedTask {
|
||||
taskId: string;
|
||||
priority: number; // 1-10
|
||||
retries: number;
|
||||
maxRetries: number;
|
||||
context: TaskContext;
|
||||
}
|
||||
```
|
||||
|
||||
### AddTaskOptions
|
||||
|
||||
```typescript
|
||||
interface AddTaskOptions {
|
||||
priority?: number; // 1-10, default 5
|
||||
maxRetries?: number; // default 3
|
||||
delay?: number; // delay in milliseconds
|
||||
}
|
||||
```
|
||||
|
||||
### QueueStats
|
||||
|
||||
```typescript
|
||||
interface QueueStats {
|
||||
pending: number;
|
||||
active: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
delayed: number;
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Validation errors:
|
||||
- `Priority must be between 1 and 10`: Invalid priority value
|
||||
- `maxRetries must be non-negative`: Negative retry count
|
||||
|
||||
Task processing errors:
|
||||
- Automatically retried up to `maxRetries`
|
||||
- Published as `task.failed` event after final failure
|
||||
- Error details stored in Valkey state
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```bash
|
||||
pnpm test queue.service.spec.ts
|
||||
```
|
||||
|
||||
Tests pure functions (calculateBackoffDelay, configuration).
|
||||
|
||||
### Integration Tests
|
||||
|
||||
Integration tests require a running Valkey instance:
|
||||
|
||||
```bash
|
||||
# Start Valkey
|
||||
docker run -p 6379:6379 valkey/valkey:latest
|
||||
|
||||
# Run integration tests
|
||||
pnpm test queue.integration.spec.ts
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `bullmq`: Task queue
|
||||
- `ioredis`: Redis/Valkey client (via ValkeyService)
|
||||
- `@nestjs/common`: NestJS dependency injection
|
||||
- `@nestjs/config`: Configuration management
|
||||
|
||||
## Related
|
||||
|
||||
- `ValkeyModule`: State management and pub/sub
|
||||
- `ORCH-107`: Valkey client implementation
|
||||
- `ORCH-109`: Agent lifecycle management (uses queue)
|
||||
7
apps/orchestrator/src/queue/index.ts
Normal file
7
apps/orchestrator/src/queue/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Queue module exports
|
||||
*/
|
||||
|
||||
export * from './queue.service';
|
||||
export * from './queue.module';
|
||||
export * from './types';
|
||||
@@ -1,4 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { QueueService } from './queue.service';
|
||||
import { ValkeyModule } from '../valkey/valkey.module';
|
||||
|
||||
@Module({})
|
||||
@Module({
|
||||
imports: [ConfigModule, ValkeyModule],
|
||||
providers: [QueueService],
|
||||
exports: [QueueService],
|
||||
})
|
||||
export class QueueModule {}
|
||||
|
||||
185
apps/orchestrator/src/queue/queue.service.spec.ts
Normal file
185
apps/orchestrator/src/queue/queue.service.spec.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { QueueService } from './queue.service';
|
||||
|
||||
describe('QueueService', () => {
|
||||
describe('calculateBackoffDelay', () => {
|
||||
let service: QueueService;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a minimal instance for testing pure functions
|
||||
const mockValkeyService: any = {
|
||||
updateTaskStatus: vi.fn(),
|
||||
publishEvent: vi.fn(),
|
||||
};
|
||||
const mockConfigService: any = {
|
||||
get: vi.fn((key: string, defaultValue?: unknown) => defaultValue),
|
||||
};
|
||||
service = new QueueService(mockValkeyService, mockConfigService);
|
||||
});
|
||||
|
||||
it('should calculate exponential backoff delay', () => {
|
||||
const baseDelay = 1000;
|
||||
const maxDelay = 60000;
|
||||
|
||||
// Attempt 1: 2000ms (1000 * 2^1)
|
||||
const delay1 = service.calculateBackoffDelay(1, baseDelay, maxDelay);
|
||||
expect(delay1).toBe(2000);
|
||||
|
||||
// Attempt 2: 4000ms (1000 * 2^2)
|
||||
const delay2 = service.calculateBackoffDelay(2, baseDelay, maxDelay);
|
||||
expect(delay2).toBe(4000);
|
||||
|
||||
// Attempt 3: 8000ms (1000 * 2^3)
|
||||
const delay3 = service.calculateBackoffDelay(3, baseDelay, maxDelay);
|
||||
expect(delay3).toBe(8000);
|
||||
|
||||
// Attempt 4: 16000ms (1000 * 2^4)
|
||||
const delay4 = service.calculateBackoffDelay(4, baseDelay, maxDelay);
|
||||
expect(delay4).toBe(16000);
|
||||
});
|
||||
|
||||
it('should cap delay at maxDelay', () => {
|
||||
const baseDelay = 1000;
|
||||
const maxDelay = 60000;
|
||||
|
||||
// Attempt 10 would be 1024000ms, but should be capped at 60000ms
|
||||
const delay10 = service.calculateBackoffDelay(10, baseDelay, maxDelay);
|
||||
expect(delay10).toBe(maxDelay);
|
||||
|
||||
// Attempt 7 would be 128000ms, should be capped at 60000ms
|
||||
const delay7 = service.calculateBackoffDelay(7, baseDelay, maxDelay);
|
||||
expect(delay7).toBe(maxDelay);
|
||||
});
|
||||
|
||||
it('should handle zero baseDelay', () => {
|
||||
const delay = service.calculateBackoffDelay(3, 0, 60000);
|
||||
expect(delay).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle attempt 0', () => {
|
||||
const delay = service.calculateBackoffDelay(0, 1000, 60000);
|
||||
expect(delay).toBe(1000); // 1000 * 2^0 = 1000
|
||||
});
|
||||
|
||||
it('should handle large attempt numbers', () => {
|
||||
const baseDelay = 1000;
|
||||
const maxDelay = 100000;
|
||||
|
||||
const delay = service.calculateBackoffDelay(20, baseDelay, maxDelay);
|
||||
expect(delay).toBe(maxDelay);
|
||||
});
|
||||
|
||||
it('should work with different base delays', () => {
|
||||
const maxDelay = 100000;
|
||||
|
||||
// 500ms base
|
||||
const delay1 = service.calculateBackoffDelay(2, 500, maxDelay);
|
||||
expect(delay1).toBe(2000); // 500 * 2^2
|
||||
|
||||
// 2000ms base
|
||||
const delay2 = service.calculateBackoffDelay(2, 2000, maxDelay);
|
||||
expect(delay2).toBe(8000); // 2000 * 2^2
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation logic', () => {
|
||||
let service: QueueService;
|
||||
let mockValkeyService: any;
|
||||
let mockConfigService: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockValkeyService = {
|
||||
updateTaskStatus: vi.fn().mockResolvedValue(undefined),
|
||||
publishEvent: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
mockConfigService = {
|
||||
get: vi.fn((key: string, defaultValue?: unknown) => {
|
||||
const config: Record<string, unknown> = {
|
||||
'orchestrator.valkey.host': 'localhost',
|
||||
'orchestrator.valkey.port': 6379,
|
||||
'orchestrator.queue.name': 'orchestrator-tasks',
|
||||
'orchestrator.queue.maxRetries': 3,
|
||||
'orchestrator.queue.baseDelay': 1000,
|
||||
'orchestrator.queue.maxDelay': 60000,
|
||||
'orchestrator.queue.concurrency': 5,
|
||||
};
|
||||
return config[key] ?? defaultValue;
|
||||
}),
|
||||
};
|
||||
service = new QueueService(mockValkeyService, mockConfigService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
expect(service.calculateBackoffDelay).toBeDefined();
|
||||
});
|
||||
|
||||
it('should load configuration from ConfigService', () => {
|
||||
expect(mockConfigService.get).toHaveBeenCalledWith(
|
||||
'orchestrator.queue.name',
|
||||
'orchestrator-tasks'
|
||||
);
|
||||
expect(mockConfigService.get).toHaveBeenCalledWith(
|
||||
'orchestrator.queue.maxRetries',
|
||||
3
|
||||
);
|
||||
expect(mockConfigService.get).toHaveBeenCalledWith(
|
||||
'orchestrator.queue.baseDelay',
|
||||
1000
|
||||
);
|
||||
expect(mockConfigService.get).toHaveBeenCalledWith(
|
||||
'orchestrator.queue.maxDelay',
|
||||
60000
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retry configuration', () => {
|
||||
it('should use default retry configuration', () => {
|
||||
const mockValkeyService: any = {
|
||||
updateTaskStatus: vi.fn(),
|
||||
publishEvent: vi.fn(),
|
||||
};
|
||||
const mockConfigService: any = {
|
||||
get: vi.fn((key: string, defaultValue?: unknown) => defaultValue),
|
||||
};
|
||||
|
||||
const service = new QueueService(mockValkeyService, mockConfigService);
|
||||
|
||||
// Verify defaults were requested
|
||||
expect(mockConfigService.get).toHaveBeenCalledWith(
|
||||
'orchestrator.queue.maxRetries',
|
||||
3
|
||||
);
|
||||
expect(mockConfigService.get).toHaveBeenCalledWith(
|
||||
'orchestrator.queue.baseDelay',
|
||||
1000
|
||||
);
|
||||
expect(mockConfigService.get).toHaveBeenCalledWith(
|
||||
'orchestrator.queue.maxDelay',
|
||||
60000
|
||||
);
|
||||
});
|
||||
|
||||
it('should use custom retry configuration from env', () => {
|
||||
const mockValkeyService: any = {
|
||||
updateTaskStatus: vi.fn(),
|
||||
publishEvent: vi.fn(),
|
||||
};
|
||||
const mockConfigService: any = {
|
||||
get: vi.fn((key: string, defaultValue?: unknown) => {
|
||||
if (key === 'orchestrator.queue.maxRetries') return 5;
|
||||
if (key === 'orchestrator.queue.baseDelay') return 2000;
|
||||
if (key === 'orchestrator.queue.maxDelay') return 120000;
|
||||
return defaultValue;
|
||||
}),
|
||||
};
|
||||
|
||||
const service = new QueueService(mockValkeyService, mockConfigService);
|
||||
|
||||
// Verify custom values were used
|
||||
const delay1 = service.calculateBackoffDelay(1, 2000, 120000);
|
||||
expect(delay1).toBe(4000); // 2000 * 2^1
|
||||
});
|
||||
});
|
||||
});
|
||||
301
apps/orchestrator/src/queue/queue.service.ts
Normal file
301
apps/orchestrator/src/queue/queue.service.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Queue, Worker, Job } from 'bullmq';
|
||||
import { ValkeyService } from '../valkey/valkey.service';
|
||||
import type { TaskContext } from '../valkey/types';
|
||||
import type {
|
||||
QueuedTask,
|
||||
QueueStats,
|
||||
AddTaskOptions,
|
||||
RetryConfig,
|
||||
TaskProcessingResult,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Queue service for managing task queue with priority and retry logic
|
||||
*/
|
||||
@Injectable()
|
||||
export class QueueService implements OnModuleInit, OnModuleDestroy {
|
||||
private queue!: Queue<QueuedTask>;
|
||||
private worker!: Worker<QueuedTask, TaskProcessingResult>;
|
||||
private readonly queueName: string;
|
||||
private readonly retryConfig: RetryConfig;
|
||||
|
||||
constructor(
|
||||
private readonly valkeyService: ValkeyService,
|
||||
private readonly configService: ConfigService
|
||||
) {
|
||||
this.queueName = this.configService.get<string>(
|
||||
'orchestrator.queue.name',
|
||||
'orchestrator-tasks'
|
||||
);
|
||||
|
||||
this.retryConfig = {
|
||||
maxRetries: this.configService.get<number>(
|
||||
'orchestrator.queue.maxRetries',
|
||||
3
|
||||
),
|
||||
baseDelay: this.configService.get<number>(
|
||||
'orchestrator.queue.baseDelay',
|
||||
1000
|
||||
),
|
||||
maxDelay: this.configService.get<number>(
|
||||
'orchestrator.queue.maxDelay',
|
||||
60000
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
// Initialize BullMQ with Valkey connection
|
||||
const connection = {
|
||||
host: this.configService.get<string>('orchestrator.valkey.host', 'localhost'),
|
||||
port: this.configService.get<number>('orchestrator.valkey.port', 6379),
|
||||
password: this.configService.get<string>('orchestrator.valkey.password'),
|
||||
};
|
||||
|
||||
// Create queue
|
||||
this.queue = new Queue<QueuedTask>(this.queueName, {
|
||||
connection,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: {
|
||||
age: 3600, // Keep completed jobs for 1 hour
|
||||
count: 100, // Keep last 100 completed jobs
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 86400, // Keep failed jobs for 24 hours
|
||||
count: 1000, // Keep last 1000 failed jobs
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create worker
|
||||
this.worker = new Worker<QueuedTask, TaskProcessingResult>(
|
||||
this.queueName,
|
||||
async (job: Job<QueuedTask>) => {
|
||||
return this.processTask(job);
|
||||
},
|
||||
{
|
||||
connection,
|
||||
concurrency: this.configService.get<number>(
|
||||
'orchestrator.queue.concurrency',
|
||||
5
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
// Setup error handlers
|
||||
this.worker.on('failed', async (job, err) => {
|
||||
if (job) {
|
||||
await this.handleTaskFailure(job.data.taskId, err);
|
||||
}
|
||||
});
|
||||
|
||||
this.worker.on('completed', async (job) => {
|
||||
if (job) {
|
||||
await this.handleTaskCompletion(job.data.taskId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.worker.close();
|
||||
await this.queue.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add task to queue
|
||||
*/
|
||||
async addTask(
|
||||
taskId: string,
|
||||
context: TaskContext,
|
||||
options?: AddTaskOptions
|
||||
): Promise<void> {
|
||||
// Validate options
|
||||
const priority = options?.priority ?? 5;
|
||||
const maxRetries = options?.maxRetries ?? this.retryConfig.maxRetries;
|
||||
const delay = options?.delay ?? 0;
|
||||
|
||||
if (priority < 1 || priority > 10) {
|
||||
throw new Error('Priority must be between 1 and 10');
|
||||
}
|
||||
|
||||
if (maxRetries < 0) {
|
||||
throw new Error('maxRetries must be non-negative');
|
||||
}
|
||||
|
||||
const queuedTask: QueuedTask = {
|
||||
taskId,
|
||||
priority,
|
||||
retries: 0,
|
||||
maxRetries,
|
||||
context,
|
||||
};
|
||||
|
||||
// Add to BullMQ queue
|
||||
await this.queue.add(taskId, queuedTask, {
|
||||
priority: 10 - priority + 1, // BullMQ: lower number = higher priority, so invert
|
||||
attempts: maxRetries + 1, // +1 for initial attempt
|
||||
backoff: {
|
||||
type: 'custom',
|
||||
},
|
||||
delay,
|
||||
});
|
||||
|
||||
// Update task state in Valkey
|
||||
await this.valkeyService.updateTaskStatus(taskId, 'pending');
|
||||
|
||||
// Publish event
|
||||
await this.valkeyService.publishEvent({
|
||||
type: 'task.queued',
|
||||
timestamp: new Date().toISOString(),
|
||||
taskId,
|
||||
data: { priority },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue statistics
|
||||
*/
|
||||
async getStats(): Promise<QueueStats> {
|
||||
const counts = await this.queue.getJobCounts(
|
||||
'waiting',
|
||||
'active',
|
||||
'completed',
|
||||
'failed',
|
||||
'delayed'
|
||||
);
|
||||
|
||||
return {
|
||||
pending: counts.waiting || 0,
|
||||
active: counts.active || 0,
|
||||
completed: counts.completed || 0,
|
||||
failed: counts.failed || 0,
|
||||
delayed: counts.delayed || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate exponential backoff delay
|
||||
*/
|
||||
calculateBackoffDelay(
|
||||
attemptNumber: number,
|
||||
baseDelay: number,
|
||||
maxDelay: number
|
||||
): number {
|
||||
const delay = baseDelay * Math.pow(2, attemptNumber);
|
||||
return Math.min(delay, maxDelay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause queue processing
|
||||
*/
|
||||
async pause(): Promise<void> {
|
||||
await this.queue.pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume queue processing
|
||||
*/
|
||||
async resume(): Promise<void> {
|
||||
await this.queue.resume();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove task from queue
|
||||
*/
|
||||
async removeTask(taskId: string): Promise<void> {
|
||||
const job = await this.queue.getJob(taskId);
|
||||
if (job) {
|
||||
await job.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process task (called by worker)
|
||||
*/
|
||||
private async processTask(
|
||||
job: Job<QueuedTask>
|
||||
): Promise<TaskProcessingResult> {
|
||||
const { taskId } = job.data;
|
||||
|
||||
try {
|
||||
// Update task state to executing
|
||||
await this.valkeyService.updateTaskStatus(taskId, 'executing');
|
||||
|
||||
// Publish event
|
||||
await this.valkeyService.publishEvent({
|
||||
type: 'task.processing',
|
||||
timestamp: new Date().toISOString(),
|
||||
taskId,
|
||||
data: { attempt: job.attemptsMade + 1 },
|
||||
});
|
||||
|
||||
// Task processing will be handled by agent spawner
|
||||
// For now, just mark as processing
|
||||
return {
|
||||
success: true,
|
||||
metadata: {
|
||||
attempt: job.attemptsMade + 1,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
// Handle retry logic
|
||||
const shouldRetry = job.attemptsMade < job.data.maxRetries;
|
||||
|
||||
if (shouldRetry) {
|
||||
// Calculate backoff delay for next retry
|
||||
const delay = this.calculateBackoffDelay(
|
||||
job.attemptsMade + 1,
|
||||
this.retryConfig.baseDelay,
|
||||
this.retryConfig.maxDelay
|
||||
);
|
||||
|
||||
// BullMQ will automatically retry with the backoff
|
||||
await job.updateData({
|
||||
...job.data,
|
||||
retries: job.attemptsMade + 1,
|
||||
});
|
||||
|
||||
await this.valkeyService.publishEvent({
|
||||
type: 'task.retry',
|
||||
timestamp: new Date().toISOString(),
|
||||
taskId,
|
||||
data: {
|
||||
attempt: job.attemptsMade + 1,
|
||||
nextDelay: delay,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle task failure
|
||||
*/
|
||||
private async handleTaskFailure(taskId: string, error: Error): Promise<void> {
|
||||
await this.valkeyService.updateTaskStatus(taskId, 'failed', undefined, error.message);
|
||||
|
||||
await this.valkeyService.publishEvent({
|
||||
type: 'task.failed',
|
||||
timestamp: new Date().toISOString(),
|
||||
taskId,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle task completion
|
||||
*/
|
||||
private async handleTaskCompletion(taskId: string): Promise<void> {
|
||||
await this.valkeyService.updateTaskStatus(taskId, 'completed');
|
||||
|
||||
await this.valkeyService.publishEvent({
|
||||
type: 'task.completed',
|
||||
timestamp: new Date().toISOString(),
|
||||
taskId,
|
||||
});
|
||||
}
|
||||
}
|
||||
5
apps/orchestrator/src/queue/types/index.ts
Normal file
5
apps/orchestrator/src/queue/types/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Queue module type exports
|
||||
*/
|
||||
|
||||
export * from './queue.types';
|
||||
55
apps/orchestrator/src/queue/types/queue.types.ts
Normal file
55
apps/orchestrator/src/queue/types/queue.types.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Queue task types
|
||||
*/
|
||||
|
||||
import type { TaskContext } from '../../valkey/types';
|
||||
|
||||
/**
|
||||
* Queued task interface
|
||||
* Priority: 1-10 (higher = more important)
|
||||
*/
|
||||
export interface QueuedTask {
|
||||
taskId: string;
|
||||
priority: number; // 1-10
|
||||
retries: number;
|
||||
maxRetries: number;
|
||||
context: TaskContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue monitoring statistics
|
||||
*/
|
||||
export interface QueueStats {
|
||||
pending: number;
|
||||
active: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
delayed: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue options for adding tasks
|
||||
*/
|
||||
export interface AddTaskOptions {
|
||||
priority?: number; // 1-10, default 5
|
||||
maxRetries?: number; // default 3
|
||||
delay?: number; // delay in milliseconds before processing
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry configuration
|
||||
*/
|
||||
export interface RetryConfig {
|
||||
maxRetries: number;
|
||||
baseDelay: number; // base delay in milliseconds
|
||||
maxDelay: number; // maximum delay cap
|
||||
}
|
||||
|
||||
/**
|
||||
* Task processing result
|
||||
*/
|
||||
export interface TaskProcessingResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
615
apps/orchestrator/src/spawner/agent-lifecycle.service.spec.ts
Normal file
615
apps/orchestrator/src/spawner/agent-lifecycle.service.spec.ts
Normal file
@@ -0,0 +1,615 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { AgentLifecycleService } from './agent-lifecycle.service';
|
||||
import { ValkeyService } from '../valkey/valkey.service';
|
||||
import type { AgentState } from '../valkey/types';
|
||||
|
||||
describe('AgentLifecycleService', () => {
|
||||
let service: AgentLifecycleService;
|
||||
let mockValkeyService: {
|
||||
getAgentState: ReturnType<typeof vi.fn>;
|
||||
setAgentState: ReturnType<typeof vi.fn>;
|
||||
updateAgentStatus: ReturnType<typeof vi.fn>;
|
||||
publishEvent: ReturnType<typeof vi.fn>;
|
||||
listAgents: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const mockAgentId = 'test-agent-123';
|
||||
const mockTaskId = 'test-task-456';
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mocks
|
||||
mockValkeyService = {
|
||||
getAgentState: vi.fn(),
|
||||
setAgentState: vi.fn(),
|
||||
updateAgentStatus: vi.fn(),
|
||||
publishEvent: vi.fn(),
|
||||
listAgents: vi.fn(),
|
||||
};
|
||||
|
||||
// Create service with mock
|
||||
service = new AgentLifecycleService(mockValkeyService as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('transitionToRunning', () => {
|
||||
it('should transition from spawning to running', async () => {
|
||||
const mockState: AgentState = {
|
||||
agentId: mockAgentId,
|
||||
status: 'spawning',
|
||||
taskId: mockTaskId,
|
||||
};
|
||||
|
||||
mockValkeyService.getAgentState.mockResolvedValue(mockState);
|
||||
mockValkeyService.updateAgentStatus.mockResolvedValue({
|
||||
...mockState,
|
||||
status: 'running',
|
||||
startedAt: '2026-02-02T10:00:00Z',
|
||||
});
|
||||
|
||||
const result = await service.transitionToRunning(mockAgentId);
|
||||
|
||||
expect(result.status).toBe('running');
|
||||
expect(result.startedAt).toBeDefined();
|
||||
expect(mockValkeyService.updateAgentStatus).toHaveBeenCalledWith(
|
||||
mockAgentId,
|
||||
'running',
|
||||
undefined,
|
||||
);
|
||||
expect(mockValkeyService.publishEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'agent.running',
|
||||
agentId: mockAgentId,
|
||||
taskId: mockTaskId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error if agent not found', async () => {
|
||||
mockValkeyService.getAgentState.mockResolvedValue(null);
|
||||
|
||||
await expect(service.transitionToRunning(mockAgentId)).rejects.toThrow(
|
||||
`Agent ${mockAgentId} not found`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for invalid transition from running', async () => {
|
||||
const mockState: AgentState = {
|
||||
agentId: mockAgentId,
|
||||
status: 'running',
|
||||
taskId: mockTaskId,
|
||||
};
|
||||
|
||||
mockValkeyService.getAgentState.mockResolvedValue(mockState);
|
||||
|
||||
await expect(service.transitionToRunning(mockAgentId)).rejects.toThrow(
|
||||
'Invalid state transition from running to running',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for invalid transition from completed', async () => {
|
||||
const mockState: AgentState = {
|
||||
agentId: mockAgentId,
|
||||
status: 'completed',
|
||||
taskId: mockTaskId,
|
||||
};
|
||||
|
||||
mockValkeyService.getAgentState.mockResolvedValue(mockState);
|
||||
|
||||
await expect(service.transitionToRunning(mockAgentId)).rejects.toThrow(
|
||||
'Invalid state transition from completed to running',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transitionToCompleted', () => {
|
||||
it('should transition from running to completed', async () => {
|
||||
const mockState: AgentState = {
|
||||
agentId: mockAgentId,
|
||||
status: 'running',
|
||||
taskId: mockTaskId,
|
||||
startedAt: '2026-02-02T10:00:00Z',
|
||||
};
|
||||
|
||||
mockValkeyService.getAgentState.mockResolvedValue(mockState);
|
||||
mockValkeyService.updateAgentStatus.mockResolvedValue({
|
||||
...mockState,
|
||||
status: 'completed',
|
||||
completedAt: expect.any(String),
|
||||
});
|
||||
|
||||
const result = await service.transitionToCompleted(mockAgentId);
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.completedAt).toBeDefined();
|
||||
expect(mockValkeyService.updateAgentStatus).toHaveBeenCalledWith(
|
||||
mockAgentId,
|
||||
'completed',
|
||||
undefined,
|
||||
);
|
||||
expect(mockValkeyService.publishEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'agent.completed',
|
||||
agentId: mockAgentId,
|
||||
taskId: mockTaskId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error if agent not found', async () => {
|
||||
mockValkeyService.getAgentState.mockResolvedValue(null);
|
||||
|
||||
await expect(service.transitionToCompleted(mockAgentId)).rejects.toThrow(
|
||||
`Agent ${mockAgentId} not found`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for invalid transition from spawning', async () => {
|
||||
const mockState: AgentState = {
|
||||
agentId: mockAgentId,
|
||||
status: 'spawning',
|
||||
taskId: mockTaskId,
|
||||
};
|
||||
|
||||
mockValkeyService.getAgentState.mockResolvedValue(mockState);
|
||||
|
||||
await expect(service.transitionToCompleted(mockAgentId)).rejects.toThrow(
|
||||
'Invalid state transition from spawning to completed',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transitionToFailed', () => {
|
||||
it('should transition from spawning to failed with error', async () => {
|
||||
const mockState: AgentState = {
|
||||
agentId: mockAgentId,
|
||||
status: 'spawning',
|
||||
taskId: mockTaskId,
|
||||
};
|
||||
const errorMessage = 'Failed to spawn agent';
|
||||
|
||||
mockValkeyService.getAgentState.mockResolvedValue(mockState);
|
||||
mockValkeyService.updateAgentStatus.mockResolvedValue({
|
||||
...mockState,
|
||||
status: 'failed',
|
||||
error: errorMessage,
|
||||
completedAt: expect.any(String),
|
||||
});
|
||||
|
||||
const result = await service.transitionToFailed(mockAgentId, errorMessage);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.error).toBe(errorMessage);
|
||||
expect(result.completedAt).toBeDefined();
|
||||
expect(mockValkeyService.updateAgentStatus).toHaveBeenCalledWith(
|
||||
mockAgentId,
|
||||
'failed',
|
||||
errorMessage,
|
||||
);
|
||||
expect(mockValkeyService.publishEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'agent.failed',
|
||||
agentId: mockAgentId,
|
||||
taskId: mockTaskId,
|
||||
error: errorMessage,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should transition from running to failed with error', async () => {
|
||||
const mockState: AgentState = {
|
||||
agentId: mockAgentId,
|
||||
status: 'running',
|
||||
taskId: mockTaskId,
|
||||
startedAt: '2026-02-02T10:00:00Z',
|
||||
};
|
||||
const errorMessage = 'Runtime error occurred';
|
||||
|
||||
mockValkeyService.getAgentState.mockResolvedValue(mockState);
|
||||
mockValkeyService.updateAgentStatus.mockResolvedValue({
|
||||
...mockState,
|
||||
status: 'failed',
|
||||
error: errorMessage,
|
||||
completedAt: expect.any(String),
|
||||
});
|
||||
|
||||
const result = await service.transitionToFailed(mockAgentId, errorMessage);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.error).toBe(errorMessage);
|
||||
});
|
||||
|
||||
it('should throw error if agent not found', async () => {
|
||||
mockValkeyService.getAgentState.mockResolvedValue(null);
|
||||
|
||||
await expect(service.transitionToFailed(mockAgentId, 'Error')).rejects.toThrow(
|
||||
`Agent ${mockAgentId} not found`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for invalid transition from completed', async () => {
|
||||
const mockState: AgentState = {
|
||||
agentId: mockAgentId,
|
||||
status: 'completed',
|
||||
taskId: mockTaskId,
|
||||
};
|
||||
|
||||
mockValkeyService.getAgentState.mockResolvedValue(mockState);
|
||||
|
||||
await expect(service.transitionToFailed(mockAgentId, 'Error')).rejects.toThrow(
|
||||
'Invalid state transition from completed to failed',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transitionToKilled', () => {
|
||||
it('should transition from spawning to killed', async () => {
|
||||
const mockState: AgentState = {
|
||||
agentId: mockAgentId,
|
||||
status: 'spawning',
|
||||
taskId: mockTaskId,
|
||||
};
|
||||
|
||||
mockValkeyService.getAgentState.mockResolvedValue(mockState);
|
||||
mockValkeyService.updateAgentStatus.mockResolvedValue({
|
||||
...mockState,
|
||||
status: 'killed',
|
||||
completedAt: expect.any(String),
|
||||
});
|
||||
|
||||
const result = await service.transitionToKilled(mockAgentId);
|
||||
|
||||
expect(result.status).toBe('killed');
|
||||
expect(result.completedAt).toBeDefined();
|
||||
expect(mockValkeyService.updateAgentStatus).toHaveBeenCalledWith(
|
||||
mockAgentId,
|
||||
'killed',
|
||||
undefined,
|
||||
);
|
||||
expect(mockValkeyService.publishEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'agent.killed',
|
||||
agentId: mockAgentId,
|
||||
taskId: mockTaskId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should transition from running to killed', async () => {
|
||||
const mockState: AgentState = {
|
||||
agentId: mockAgentId,
|
||||
status: 'running',
|
||||
taskId: mockTaskId,
|
||||
startedAt: '2026-02-02T10:00:00Z',
|
||||
};
|
||||
|
||||
mockValkeyService.getAgentState.mockResolvedValue(mockState);
|
||||
mockValkeyService.updateAgentStatus.mockResolvedValue({
|
||||
...mockState,
|
||||
status: 'killed',
|
||||
completedAt: expect.any(String),
|
||||
});
|
||||
|
||||
const result = await service.transitionToKilled(mockAgentId);
|
||||
|
||||
expect(result.status).toBe('killed');
|
||||
});
|
||||
|
||||
it('should throw error if agent not found', async () => {
|
||||
mockValkeyService.getAgentState.mockResolvedValue(null);
|
||||
|
||||
await expect(service.transitionToKilled(mockAgentId)).rejects.toThrow(
|
||||
`Agent ${mockAgentId} not found`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for invalid transition from completed', async () => {
|
||||
const mockState: AgentState = {
|
||||
agentId: mockAgentId,
|
||||
status: 'completed',
|
||||
taskId: mockTaskId,
|
||||
};
|
||||
|
||||
mockValkeyService.getAgentState.mockResolvedValue(mockState);
|
||||
|
||||
await expect(service.transitionToKilled(mockAgentId)).rejects.toThrow(
|
||||
'Invalid state transition from completed to killed',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgentLifecycleState', () => {
|
||||
it('should return agent state from Valkey', async () => {
|
||||
const mockState: AgentState = {
|
||||
agentId: mockAgentId,
|
||||
status: 'running',
|
||||
taskId: mockTaskId,
|
||||
startedAt: '2026-02-02T10:00:00Z',
|
||||
};
|
||||
|
||||
mockValkeyService.getAgentState.mockResolvedValue(mockState);
|
||||
|
||||
const result = await service.getAgentLifecycleState(mockAgentId);
|
||||
|
||||
expect(result).toEqual(mockState);
|
||||
expect(mockValkeyService.getAgentState).toHaveBeenCalledWith(mockAgentId);
|
||||
});
|
||||
|
||||
it('should return null if agent not found', async () => {
|
||||
mockValkeyService.getAgentState.mockResolvedValue(null);
|
||||
|
||||
const result = await service.getAgentLifecycleState(mockAgentId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listAgentLifecycleStates', () => {
|
||||
it('should return all agent states from Valkey', async () => {
|
||||
const mockStates: AgentState[] = [
|
||||
{
|
||||
agentId: 'agent-1',
|
||||
status: 'running',
|
||||
taskId: 'task-1',
|
||||
startedAt: '2026-02-02T10:00:00Z',
|
||||
},
|
||||
{
|
||||
agentId: 'agent-2',
|
||||
status: 'completed',
|
||||
taskId: 'task-2',
|
||||
startedAt: '2026-02-02T09:00:00Z',
|
||||
completedAt: '2026-02-02T10:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
mockValkeyService.listAgents.mockResolvedValue(mockStates);
|
||||
|
||||
const result = await service.listAgentLifecycleStates();
|
||||
|
||||
expect(result).toEqual(mockStates);
|
||||
expect(mockValkeyService.listAgents).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return empty array if no agents', async () => {
|
||||
mockValkeyService.listAgents.mockResolvedValue([]);
|
||||
|
||||
const result = await service.listAgentLifecycleStates();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('state persistence', () => {
|
||||
it('should update completedAt timestamp on terminal states', async () => {
|
||||
const mockState: AgentState = {
|
||||
agentId: mockAgentId,
|
||||
status: 'running',
|
||||
taskId: mockTaskId,
|
||||
startedAt: '2026-02-02T10:00:00Z',
|
||||
};
|
||||
|
||||
mockValkeyService.getAgentState.mockResolvedValue(mockState);
|
||||
|
||||
let capturedState: AgentState | undefined;
|
||||
mockValkeyService.updateAgentStatus.mockImplementation(async (agentId, status, error) => {
|
||||
capturedState = {
|
||||
...mockState,
|
||||
status,
|
||||
error,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
return capturedState;
|
||||
});
|
||||
|
||||
await service.transitionToCompleted(mockAgentId);
|
||||
|
||||
expect(capturedState?.completedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should preserve startedAt timestamp through transitions', async () => {
|
||||
const startedAt = '2026-02-02T10:00:00Z';
|
||||
const mockState: AgentState = {
|
||||
agentId: mockAgentId,
|
||||
status: 'running',
|
||||
taskId: mockTaskId,
|
||||
startedAt,
|
||||
};
|
||||
|
||||
mockValkeyService.getAgentState.mockResolvedValue(mockState);
|
||||
mockValkeyService.updateAgentStatus.mockResolvedValue({
|
||||
...mockState,
|
||||
status: 'completed',
|
||||
completedAt: '2026-02-02T11:00:00Z',
|
||||
});
|
||||
|
||||
const result = await service.transitionToCompleted(mockAgentId);
|
||||
|
||||
expect(result.startedAt).toBe(startedAt);
|
||||
});
|
||||
|
||||
it('should set startedAt if not already set when transitioning to running', async () => {
|
||||
const mockState: AgentState = {
|
||||
agentId: mockAgentId,
|
||||
status: 'spawning',
|
||||
taskId: mockTaskId,
|
||||
};
|
||||
|
||||
mockValkeyService.getAgentState.mockResolvedValue(mockState);
|
||||
mockValkeyService.updateAgentStatus.mockResolvedValue({
|
||||
...mockState,
|
||||
status: 'running',
|
||||
// No startedAt in response
|
||||
});
|
||||
mockValkeyService.setAgentState.mockResolvedValue(undefined);
|
||||
|
||||
await service.transitionToRunning(mockAgentId);
|
||||
|
||||
expect(mockValkeyService.setAgentState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: mockAgentId,
|
||||
status: 'running',
|
||||
startedAt: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not set startedAt if already present in response', async () => {
|
||||
const mockState: AgentState = {
|
||||
agentId: mockAgentId,
|
||||
status: 'spawning',
|
||||
taskId: mockTaskId,
|
||||
};
|
||||
|
||||
mockValkeyService.getAgentState.mockResolvedValue(mockState);
|
||||
mockValkeyService.updateAgentStatus.mockResolvedValue({
|
||||
...mockState,
|
||||
status: 'running',
|
||||
startedAt: '2026-02-02T10:00:00Z',
|
||||
});
|
||||
|
||||
await service.transitionToRunning(mockAgentId);
|
||||
|
||||
// Should not call setAgentState since startedAt is already present
|
||||
expect(mockValkeyService.setAgentState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set completedAt if not already set when transitioning to completed', async () => {
|
||||
const mockState: AgentState = {
|
||||
agentId: mockAgentId,
|
||||
status: 'running',
|
||||
taskId: mockTaskId,
|
||||
startedAt: '2026-02-02T10:00:00Z',
|
||||
};
|
||||
|
||||
mockValkeyService.getAgentState.mockResolvedValue(mockState);
|
||||
mockValkeyService.updateAgentStatus.mockResolvedValue({
|
||||
...mockState,
|
||||
status: 'completed',
|
||||
// No completedAt in response
|
||||
});
|
||||
mockValkeyService.setAgentState.mockResolvedValue(undefined);
|
||||
|
||||
await service.transitionToCompleted(mockAgentId);
|
||||
|
||||
expect(mockValkeyService.setAgentState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: mockAgentId,
|
||||
status: 'completed',
|
||||
completedAt: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should set completedAt if not already set when transitioning to failed', async () => {
|
||||
const mockState: AgentState = {
|
||||
agentId: mockAgentId,
|
||||
status: 'running',
|
||||
taskId: mockTaskId,
|
||||
startedAt: '2026-02-02T10:00:00Z',
|
||||
};
|
||||
|
||||
mockValkeyService.getAgentState.mockResolvedValue(mockState);
|
||||
mockValkeyService.updateAgentStatus.mockResolvedValue({
|
||||
...mockState,
|
||||
status: 'failed',
|
||||
error: 'Test error',
|
||||
// No completedAt in response
|
||||
});
|
||||
mockValkeyService.setAgentState.mockResolvedValue(undefined);
|
||||
|
||||
await service.transitionToFailed(mockAgentId, 'Test error');
|
||||
|
||||
expect(mockValkeyService.setAgentState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: mockAgentId,
|
||||
status: 'failed',
|
||||
completedAt: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should set completedAt if not already set when transitioning to killed', async () => {
|
||||
const mockState: AgentState = {
|
||||
agentId: mockAgentId,
|
||||
status: 'running',
|
||||
taskId: mockTaskId,
|
||||
startedAt: '2026-02-02T10:00:00Z',
|
||||
};
|
||||
|
||||
mockValkeyService.getAgentState.mockResolvedValue(mockState);
|
||||
mockValkeyService.updateAgentStatus.mockResolvedValue({
|
||||
...mockState,
|
||||
status: 'killed',
|
||||
// No completedAt in response
|
||||
});
|
||||
mockValkeyService.setAgentState.mockResolvedValue(undefined);
|
||||
|
||||
await service.transitionToKilled(mockAgentId);
|
||||
|
||||
expect(mockValkeyService.setAgentState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: mockAgentId,
|
||||
status: 'killed',
|
||||
completedAt: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('event emission', () => {
|
||||
it('should emit events with correct structure', async () => {
|
||||
const mockState: AgentState = {
|
||||
agentId: mockAgentId,
|
||||
status: 'spawning',
|
||||
taskId: mockTaskId,
|
||||
};
|
||||
|
||||
mockValkeyService.getAgentState.mockResolvedValue(mockState);
|
||||
mockValkeyService.updateAgentStatus.mockResolvedValue({
|
||||
...mockState,
|
||||
status: 'running',
|
||||
startedAt: '2026-02-02T10:00:00Z',
|
||||
});
|
||||
|
||||
await service.transitionToRunning(mockAgentId);
|
||||
|
||||
expect(mockValkeyService.publishEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'agent.running',
|
||||
agentId: mockAgentId,
|
||||
taskId: mockTaskId,
|
||||
timestamp: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should include error in failed event', async () => {
|
||||
const mockState: AgentState = {
|
||||
agentId: mockAgentId,
|
||||
status: 'running',
|
||||
taskId: mockTaskId,
|
||||
};
|
||||
const errorMessage = 'Test error';
|
||||
|
||||
mockValkeyService.getAgentState.mockResolvedValue(mockState);
|
||||
mockValkeyService.updateAgentStatus.mockResolvedValue({
|
||||
...mockState,
|
||||
status: 'failed',
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
await service.transitionToFailed(mockAgentId, errorMessage);
|
||||
|
||||
expect(mockValkeyService.publishEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'agent.failed',
|
||||
agentId: mockAgentId,
|
||||
taskId: mockTaskId,
|
||||
error: errorMessage,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
232
apps/orchestrator/src/spawner/agent-lifecycle.service.ts
Normal file
232
apps/orchestrator/src/spawner/agent-lifecycle.service.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ValkeyService } from '../valkey/valkey.service';
|
||||
import type { AgentState, AgentStatus, AgentEvent } from '../valkey/types';
|
||||
import { isValidAgentTransition } from '../valkey/types/state.types';
|
||||
|
||||
/**
|
||||
* Service responsible for managing agent lifecycle state transitions
|
||||
*
|
||||
* Manages state transitions through the agent lifecycle:
|
||||
* spawning → running → completed/failed/killed
|
||||
*
|
||||
* - Enforces valid state transitions using state machine
|
||||
* - Persists agent state changes to Valkey
|
||||
* - Emits pub/sub events on state changes
|
||||
* - Tracks agent metadata (startedAt, completedAt, error)
|
||||
*/
|
||||
@Injectable()
|
||||
export class AgentLifecycleService {
|
||||
private readonly logger = new Logger(AgentLifecycleService.name);
|
||||
|
||||
constructor(private readonly valkeyService: ValkeyService) {
|
||||
this.logger.log('AgentLifecycleService initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition agent from spawning to running state
|
||||
* @param agentId Unique agent identifier
|
||||
* @returns Updated agent state
|
||||
* @throws Error if agent not found or invalid transition
|
||||
*/
|
||||
async transitionToRunning(agentId: string): Promise<AgentState> {
|
||||
this.logger.log(`Transitioning agent ${agentId} to running`);
|
||||
|
||||
const currentState = await this.getAgentState(agentId);
|
||||
this.validateTransition(currentState.status, 'running');
|
||||
|
||||
// Set startedAt timestamp if not already set
|
||||
const startedAt = currentState.startedAt || new Date().toISOString();
|
||||
|
||||
// Update state in Valkey
|
||||
const updatedState = await this.valkeyService.updateAgentStatus(
|
||||
agentId,
|
||||
'running',
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Ensure startedAt is set
|
||||
if (!updatedState.startedAt) {
|
||||
updatedState.startedAt = startedAt;
|
||||
await this.valkeyService.setAgentState(updatedState);
|
||||
}
|
||||
|
||||
// Emit event
|
||||
await this.publishStateChangeEvent('agent.running', updatedState);
|
||||
|
||||
this.logger.log(`Agent ${agentId} transitioned to running`);
|
||||
return updatedState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition agent to completed state
|
||||
* @param agentId Unique agent identifier
|
||||
* @returns Updated agent state
|
||||
* @throws Error if agent not found or invalid transition
|
||||
*/
|
||||
async transitionToCompleted(agentId: string): Promise<AgentState> {
|
||||
this.logger.log(`Transitioning agent ${agentId} to completed`);
|
||||
|
||||
const currentState = await this.getAgentState(agentId);
|
||||
this.validateTransition(currentState.status, 'completed');
|
||||
|
||||
// Set completedAt timestamp
|
||||
const completedAt = new Date().toISOString();
|
||||
|
||||
// Update state in Valkey
|
||||
const updatedState = await this.valkeyService.updateAgentStatus(
|
||||
agentId,
|
||||
'completed',
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Ensure completedAt is set
|
||||
if (!updatedState.completedAt) {
|
||||
updatedState.completedAt = completedAt;
|
||||
await this.valkeyService.setAgentState(updatedState);
|
||||
}
|
||||
|
||||
// Emit event
|
||||
await this.publishStateChangeEvent('agent.completed', updatedState);
|
||||
|
||||
this.logger.log(`Agent ${agentId} transitioned to completed`);
|
||||
return updatedState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition agent to failed state with error
|
||||
* @param agentId Unique agent identifier
|
||||
* @param error Error message
|
||||
* @returns Updated agent state
|
||||
* @throws Error if agent not found or invalid transition
|
||||
*/
|
||||
async transitionToFailed(agentId: string, error: string): Promise<AgentState> {
|
||||
this.logger.log(`Transitioning agent ${agentId} to failed: ${error}`);
|
||||
|
||||
const currentState = await this.getAgentState(agentId);
|
||||
this.validateTransition(currentState.status, 'failed');
|
||||
|
||||
// Set completedAt timestamp
|
||||
const completedAt = new Date().toISOString();
|
||||
|
||||
// Update state in Valkey
|
||||
const updatedState = await this.valkeyService.updateAgentStatus(
|
||||
agentId,
|
||||
'failed',
|
||||
error,
|
||||
);
|
||||
|
||||
// Ensure completedAt is set
|
||||
if (!updatedState.completedAt) {
|
||||
updatedState.completedAt = completedAt;
|
||||
await this.valkeyService.setAgentState(updatedState);
|
||||
}
|
||||
|
||||
// Emit event
|
||||
await this.publishStateChangeEvent('agent.failed', updatedState, error);
|
||||
|
||||
this.logger.error(`Agent ${agentId} transitioned to failed: ${error}`);
|
||||
return updatedState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition agent to killed state
|
||||
* @param agentId Unique agent identifier
|
||||
* @returns Updated agent state
|
||||
* @throws Error if agent not found or invalid transition
|
||||
*/
|
||||
async transitionToKilled(agentId: string): Promise<AgentState> {
|
||||
this.logger.log(`Transitioning agent ${agentId} to killed`);
|
||||
|
||||
const currentState = await this.getAgentState(agentId);
|
||||
this.validateTransition(currentState.status, 'killed');
|
||||
|
||||
// Set completedAt timestamp
|
||||
const completedAt = new Date().toISOString();
|
||||
|
||||
// Update state in Valkey
|
||||
const updatedState = await this.valkeyService.updateAgentStatus(
|
||||
agentId,
|
||||
'killed',
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Ensure completedAt is set
|
||||
if (!updatedState.completedAt) {
|
||||
updatedState.completedAt = completedAt;
|
||||
await this.valkeyService.setAgentState(updatedState);
|
||||
}
|
||||
|
||||
// Emit event
|
||||
await this.publishStateChangeEvent('agent.killed', updatedState);
|
||||
|
||||
this.logger.warn(`Agent ${agentId} transitioned to killed`);
|
||||
return updatedState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current agent lifecycle state
|
||||
* @param agentId Unique agent identifier
|
||||
* @returns Agent state or null if not found
|
||||
*/
|
||||
async getAgentLifecycleState(agentId: string): Promise<AgentState | null> {
|
||||
return this.valkeyService.getAgentState(agentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all agent lifecycle states
|
||||
* @returns Array of all agent states
|
||||
*/
|
||||
async listAgentLifecycleStates(): Promise<AgentState[]> {
|
||||
return this.valkeyService.listAgents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent state and throw if not found
|
||||
* @param agentId Unique agent identifier
|
||||
* @returns Agent state
|
||||
* @throws Error if agent not found
|
||||
*/
|
||||
private async getAgentState(agentId: string): Promise<AgentState> {
|
||||
const state = await this.valkeyService.getAgentState(agentId);
|
||||
|
||||
if (!state) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate state transition is allowed
|
||||
* @param from Current state
|
||||
* @param to Target state
|
||||
* @throws Error if transition is invalid
|
||||
*/
|
||||
private validateTransition(from: AgentStatus, to: AgentStatus): void {
|
||||
if (!isValidAgentTransition(from, to)) {
|
||||
throw new Error(`Invalid state transition from ${from} to ${to}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish state change event
|
||||
* @param eventType Type of event
|
||||
* @param state Updated agent state
|
||||
* @param error Optional error message
|
||||
*/
|
||||
private async publishStateChangeEvent(
|
||||
eventType: 'agent.running' | 'agent.completed' | 'agent.failed' | 'agent.killed',
|
||||
state: AgentState,
|
||||
error?: string,
|
||||
): Promise<void> {
|
||||
const event: AgentEvent = {
|
||||
type: eventType,
|
||||
agentId: state.agentId,
|
||||
taskId: state.taskId,
|
||||
timestamp: new Date().toISOString(),
|
||||
error,
|
||||
};
|
||||
|
||||
await this.valkeyService.publishEvent(event);
|
||||
}
|
||||
}
|
||||
341
apps/orchestrator/src/spawner/docker-sandbox.service.spec.ts
Normal file
341
apps/orchestrator/src/spawner/docker-sandbox.service.spec.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { DockerSandboxService } from "./docker-sandbox.service";
|
||||
import Docker from "dockerode";
|
||||
|
||||
describe("DockerSandboxService", () => {
|
||||
let service: DockerSandboxService;
|
||||
let mockConfigService: ConfigService;
|
||||
let mockDocker: Docker;
|
||||
let mockContainer: Docker.Container;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock Docker container
|
||||
mockContainer = {
|
||||
id: "container-123",
|
||||
start: vi.fn().mockResolvedValue(undefined),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
remove: vi.fn().mockResolvedValue(undefined),
|
||||
inspect: vi.fn().mockResolvedValue({
|
||||
State: { Status: "running" },
|
||||
}),
|
||||
} as unknown as Docker.Container;
|
||||
|
||||
// Create mock Docker instance
|
||||
mockDocker = {
|
||||
createContainer: vi.fn().mockResolvedValue(mockContainer),
|
||||
getContainer: vi.fn().mockReturnValue(mockContainer),
|
||||
} as unknown as Docker;
|
||||
|
||||
// Create mock ConfigService
|
||||
mockConfigService = {
|
||||
get: vi.fn((key: string, defaultValue?: unknown) => {
|
||||
const config: Record<string, unknown> = {
|
||||
"orchestrator.docker.socketPath": "/var/run/docker.sock",
|
||||
"orchestrator.sandbox.enabled": true,
|
||||
"orchestrator.sandbox.defaultImage": "node:20-alpine",
|
||||
"orchestrator.sandbox.defaultMemoryMB": 512,
|
||||
"orchestrator.sandbox.defaultCpuLimit": 1.0,
|
||||
"orchestrator.sandbox.networkMode": "bridge",
|
||||
};
|
||||
return config[key] !== undefined ? config[key] : defaultValue;
|
||||
}),
|
||||
} as unknown as ConfigService;
|
||||
|
||||
// Create service with mock Docker instance
|
||||
service = new DockerSandboxService(mockConfigService, mockDocker);
|
||||
});
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should be defined", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it("should use provided Docker instance", () => {
|
||||
expect(service).toBeDefined();
|
||||
// Service should use the mockDocker instance we provided
|
||||
});
|
||||
});
|
||||
|
||||
describe("createContainer", () => {
|
||||
it("should create a container with default configuration", async () => {
|
||||
const agentId = "agent-123";
|
||||
const taskId = "task-456";
|
||||
const workspacePath = "/workspace/agent-123";
|
||||
|
||||
const result = await service.createContainer(
|
||||
agentId,
|
||||
taskId,
|
||||
workspacePath
|
||||
);
|
||||
|
||||
expect(result.containerId).toBe("container-123");
|
||||
expect(result.agentId).toBe(agentId);
|
||||
expect(result.taskId).toBe(taskId);
|
||||
expect(result.createdAt).toBeInstanceOf(Date);
|
||||
expect(mockDocker.createContainer).toHaveBeenCalledWith({
|
||||
Image: "node:20-alpine",
|
||||
name: expect.stringContaining(`mosaic-agent-${agentId}`),
|
||||
User: "node:node",
|
||||
HostConfig: {
|
||||
Memory: 512 * 1024 * 1024, // 512MB in bytes
|
||||
NanoCpus: 1000000000, // 1.0 CPU
|
||||
NetworkMode: "bridge",
|
||||
Binds: [`${workspacePath}:/workspace`],
|
||||
AutoRemove: false,
|
||||
ReadonlyRootfs: false,
|
||||
},
|
||||
WorkingDir: "/workspace",
|
||||
Env: [`AGENT_ID=${agentId}`, `TASK_ID=${taskId}`],
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a container with custom resource limits", async () => {
|
||||
const agentId = "agent-123";
|
||||
const taskId = "task-456";
|
||||
const workspacePath = "/workspace/agent-123";
|
||||
const options = {
|
||||
memoryMB: 1024,
|
||||
cpuLimit: 2.0,
|
||||
};
|
||||
|
||||
await service.createContainer(agentId, taskId, workspacePath, options);
|
||||
|
||||
expect(mockDocker.createContainer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
HostConfig: expect.objectContaining({
|
||||
Memory: 1024 * 1024 * 1024, // 1024MB in bytes
|
||||
NanoCpus: 2000000000, // 2.0 CPU
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should create a container with network isolation", async () => {
|
||||
const agentId = "agent-123";
|
||||
const taskId = "task-456";
|
||||
const workspacePath = "/workspace/agent-123";
|
||||
const options = {
|
||||
networkMode: "none" as const,
|
||||
};
|
||||
|
||||
await service.createContainer(agentId, taskId, workspacePath, options);
|
||||
|
||||
expect(mockDocker.createContainer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
HostConfig: expect.objectContaining({
|
||||
NetworkMode: "none",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should create a container with custom environment variables", async () => {
|
||||
const agentId = "agent-123";
|
||||
const taskId = "task-456";
|
||||
const workspacePath = "/workspace/agent-123";
|
||||
const options = {
|
||||
env: {
|
||||
CUSTOM_VAR: "value123",
|
||||
ANOTHER_VAR: "value456",
|
||||
},
|
||||
};
|
||||
|
||||
await service.createContainer(agentId, taskId, workspacePath, options);
|
||||
|
||||
expect(mockDocker.createContainer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
Env: expect.arrayContaining([
|
||||
`AGENT_ID=${agentId}`,
|
||||
`TASK_ID=${taskId}`,
|
||||
"CUSTOM_VAR=value123",
|
||||
"ANOTHER_VAR=value456",
|
||||
]),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error if container creation fails", async () => {
|
||||
const agentId = "agent-123";
|
||||
const taskId = "task-456";
|
||||
const workspacePath = "/workspace/agent-123";
|
||||
|
||||
(mockDocker.createContainer as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("Docker daemon not available")
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.createContainer(agentId, taskId, workspacePath)
|
||||
).rejects.toThrow("Failed to create container for agent agent-123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("startContainer", () => {
|
||||
it("should start a container by ID", async () => {
|
||||
const containerId = "container-123";
|
||||
|
||||
await service.startContainer(containerId);
|
||||
|
||||
expect(mockDocker.getContainer).toHaveBeenCalledWith(containerId);
|
||||
expect(mockContainer.start).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw error if container start fails", async () => {
|
||||
const containerId = "container-123";
|
||||
|
||||
(mockContainer.start as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("Container not found")
|
||||
);
|
||||
|
||||
await expect(service.startContainer(containerId)).rejects.toThrow(
|
||||
"Failed to start container container-123"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopContainer", () => {
|
||||
it("should stop a container by ID", async () => {
|
||||
const containerId = "container-123";
|
||||
|
||||
await service.stopContainer(containerId);
|
||||
|
||||
expect(mockDocker.getContainer).toHaveBeenCalledWith(containerId);
|
||||
expect(mockContainer.stop).toHaveBeenCalledWith({ t: 10 });
|
||||
});
|
||||
|
||||
it("should stop a container with custom timeout", async () => {
|
||||
const containerId = "container-123";
|
||||
const timeout = 30;
|
||||
|
||||
await service.stopContainer(containerId, timeout);
|
||||
|
||||
expect(mockContainer.stop).toHaveBeenCalledWith({ t: timeout });
|
||||
});
|
||||
|
||||
it("should throw error if container stop fails", async () => {
|
||||
const containerId = "container-123";
|
||||
|
||||
(mockContainer.stop as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("Container already stopped")
|
||||
);
|
||||
|
||||
await expect(service.stopContainer(containerId)).rejects.toThrow(
|
||||
"Failed to stop container container-123"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeContainer", () => {
|
||||
it("should remove a container by ID", async () => {
|
||||
const containerId = "container-123";
|
||||
|
||||
await service.removeContainer(containerId);
|
||||
|
||||
expect(mockDocker.getContainer).toHaveBeenCalledWith(containerId);
|
||||
expect(mockContainer.remove).toHaveBeenCalledWith({ force: true });
|
||||
});
|
||||
|
||||
it("should throw error if container removal fails", async () => {
|
||||
const containerId = "container-123";
|
||||
|
||||
(mockContainer.remove as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("Container not found")
|
||||
);
|
||||
|
||||
await expect(service.removeContainer(containerId)).rejects.toThrow(
|
||||
"Failed to remove container container-123"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getContainerStatus", () => {
|
||||
it("should return container status", async () => {
|
||||
const containerId = "container-123";
|
||||
|
||||
const status = await service.getContainerStatus(containerId);
|
||||
|
||||
expect(status).toBe("running");
|
||||
expect(mockDocker.getContainer).toHaveBeenCalledWith(containerId);
|
||||
expect(mockContainer.inspect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw error if container inspect fails", async () => {
|
||||
const containerId = "container-123";
|
||||
|
||||
(mockContainer.inspect as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("Container not found")
|
||||
);
|
||||
|
||||
await expect(service.getContainerStatus(containerId)).rejects.toThrow(
|
||||
"Failed to get container status for container-123"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanup", () => {
|
||||
it("should stop and remove container", async () => {
|
||||
const containerId = "container-123";
|
||||
|
||||
await service.cleanup(containerId);
|
||||
|
||||
expect(mockContainer.stop).toHaveBeenCalledWith({ t: 10 });
|
||||
expect(mockContainer.remove).toHaveBeenCalledWith({ force: true });
|
||||
});
|
||||
|
||||
it("should remove container even if stop fails", async () => {
|
||||
const containerId = "container-123";
|
||||
|
||||
(mockContainer.stop as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("Container already stopped")
|
||||
);
|
||||
|
||||
await service.cleanup(containerId);
|
||||
|
||||
expect(mockContainer.remove).toHaveBeenCalledWith({ force: true });
|
||||
});
|
||||
|
||||
it("should throw error if both stop and remove fail", async () => {
|
||||
const containerId = "container-123";
|
||||
|
||||
(mockContainer.stop as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("Container not found")
|
||||
);
|
||||
(mockContainer.remove as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("Container not found")
|
||||
);
|
||||
|
||||
await expect(service.cleanup(containerId)).rejects.toThrow(
|
||||
"Failed to cleanup container container-123"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isEnabled", () => {
|
||||
it("should return true if sandbox is enabled in config", () => {
|
||||
expect(service.isEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if sandbox is disabled in config", () => {
|
||||
const disabledConfigService = {
|
||||
get: vi.fn((key: string, defaultValue?: unknown) => {
|
||||
const config: Record<string, unknown> = {
|
||||
"orchestrator.docker.socketPath": "/var/run/docker.sock",
|
||||
"orchestrator.sandbox.enabled": false,
|
||||
"orchestrator.sandbox.defaultImage": "node:20-alpine",
|
||||
"orchestrator.sandbox.defaultMemoryMB": 512,
|
||||
"orchestrator.sandbox.defaultCpuLimit": 1.0,
|
||||
"orchestrator.sandbox.networkMode": "bridge",
|
||||
};
|
||||
return config[key] !== undefined ? config[key] : defaultValue;
|
||||
}),
|
||||
} as unknown as ConfigService;
|
||||
|
||||
const disabledService = new DockerSandboxService(
|
||||
disabledConfigService,
|
||||
mockDocker
|
||||
);
|
||||
|
||||
expect(disabledService.isEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
254
apps/orchestrator/src/spawner/docker-sandbox.service.ts
Normal file
254
apps/orchestrator/src/spawner/docker-sandbox.service.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import Docker from "dockerode";
|
||||
import {
|
||||
DockerSandboxOptions,
|
||||
ContainerCreateResult,
|
||||
} from "./types/docker-sandbox.types";
|
||||
|
||||
/**
|
||||
* Service for managing Docker container isolation for agents
|
||||
* Provides secure sandboxing with resource limits and cleanup
|
||||
*/
|
||||
@Injectable()
|
||||
export class DockerSandboxService {
|
||||
private readonly logger = new Logger(DockerSandboxService.name);
|
||||
private readonly docker: Docker;
|
||||
private readonly sandboxEnabled: boolean;
|
||||
private readonly defaultImage: string;
|
||||
private readonly defaultMemoryMB: number;
|
||||
private readonly defaultCpuLimit: number;
|
||||
private readonly defaultNetworkMode: string;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
docker?: Docker
|
||||
) {
|
||||
const socketPath = this.configService.get<string>(
|
||||
"orchestrator.docker.socketPath",
|
||||
"/var/run/docker.sock"
|
||||
);
|
||||
|
||||
this.docker = docker ?? new Docker({ socketPath });
|
||||
|
||||
this.sandboxEnabled = this.configService.get<boolean>(
|
||||
"orchestrator.sandbox.enabled",
|
||||
false
|
||||
);
|
||||
|
||||
this.defaultImage = this.configService.get<string>(
|
||||
"orchestrator.sandbox.defaultImage",
|
||||
"node:20-alpine"
|
||||
);
|
||||
|
||||
this.defaultMemoryMB = this.configService.get<number>(
|
||||
"orchestrator.sandbox.defaultMemoryMB",
|
||||
512
|
||||
);
|
||||
|
||||
this.defaultCpuLimit = this.configService.get<number>(
|
||||
"orchestrator.sandbox.defaultCpuLimit",
|
||||
1.0
|
||||
);
|
||||
|
||||
this.defaultNetworkMode = this.configService.get<string>(
|
||||
"orchestrator.sandbox.networkMode",
|
||||
"bridge"
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`DockerSandboxService initialized (enabled: ${this.sandboxEnabled}, socket: ${socketPath})`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Docker container for agent isolation
|
||||
* @param agentId Unique agent identifier
|
||||
* @param taskId Task identifier
|
||||
* @param workspacePath Path to workspace directory to mount
|
||||
* @param options Optional container configuration
|
||||
* @returns Container creation result
|
||||
*/
|
||||
async createContainer(
|
||||
agentId: string,
|
||||
taskId: string,
|
||||
workspacePath: string,
|
||||
options?: DockerSandboxOptions
|
||||
): Promise<ContainerCreateResult> {
|
||||
try {
|
||||
const image = options?.image ?? this.defaultImage;
|
||||
const memoryMB = options?.memoryMB ?? this.defaultMemoryMB;
|
||||
const cpuLimit = options?.cpuLimit ?? this.defaultCpuLimit;
|
||||
const networkMode = options?.networkMode ?? this.defaultNetworkMode;
|
||||
|
||||
// Convert memory from MB to bytes
|
||||
const memoryBytes = memoryMB * 1024 * 1024;
|
||||
|
||||
// Convert CPU limit to NanoCPUs (1.0 = 1,000,000,000 nanocpus)
|
||||
const nanoCpus = Math.floor(cpuLimit * 1000000000);
|
||||
|
||||
// Build environment variables
|
||||
const env = [
|
||||
`AGENT_ID=${agentId}`,
|
||||
`TASK_ID=${taskId}`,
|
||||
];
|
||||
|
||||
if (options?.env) {
|
||||
Object.entries(options.env).forEach(([key, value]) => {
|
||||
env.push(`${key}=${value}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Container name with timestamp to ensure uniqueness
|
||||
const containerName = `mosaic-agent-${agentId}-${Date.now()}`;
|
||||
|
||||
this.logger.log(
|
||||
`Creating container for agent ${agentId} (image: ${image}, memory: ${memoryMB}MB, cpu: ${cpuLimit})`
|
||||
);
|
||||
|
||||
const container = await this.docker.createContainer({
|
||||
Image: image,
|
||||
name: containerName,
|
||||
User: "node:node", // Non-root user for security
|
||||
HostConfig: {
|
||||
Memory: memoryBytes,
|
||||
NanoCpus: nanoCpus,
|
||||
NetworkMode: networkMode,
|
||||
Binds: [`${workspacePath}:/workspace`],
|
||||
AutoRemove: false, // Manual cleanup for audit trail
|
||||
ReadonlyRootfs: false, // Allow writes within container
|
||||
},
|
||||
WorkingDir: "/workspace",
|
||||
Env: env,
|
||||
});
|
||||
|
||||
const createdAt = new Date();
|
||||
|
||||
this.logger.log(
|
||||
`Container created successfully: ${container.id} for agent ${agentId}`
|
||||
);
|
||||
|
||||
return {
|
||||
containerId: container.id,
|
||||
agentId,
|
||||
taskId,
|
||||
createdAt,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to create container for agent ${agentId}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
throw new Error(`Failed to create container for agent ${agentId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a Docker container
|
||||
* @param containerId Container ID to start
|
||||
*/
|
||||
async startContainer(containerId: string): Promise<void> {
|
||||
try {
|
||||
this.logger.log(`Starting container: ${containerId}`);
|
||||
const container = this.docker.getContainer(containerId);
|
||||
await container.start();
|
||||
this.logger.log(`Container started successfully: ${containerId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to start container ${containerId}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
throw new Error(`Failed to start container ${containerId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a Docker container
|
||||
* @param containerId Container ID to stop
|
||||
* @param timeout Timeout in seconds (default: 10)
|
||||
*/
|
||||
async stopContainer(containerId: string, timeout = 10): Promise<void> {
|
||||
try {
|
||||
this.logger.log(`Stopping container: ${containerId} (timeout: ${timeout}s)`);
|
||||
const container = this.docker.getContainer(containerId);
|
||||
await container.stop({ t: timeout });
|
||||
this.logger.log(`Container stopped successfully: ${containerId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to stop container ${containerId}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
throw new Error(`Failed to stop container ${containerId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a Docker container
|
||||
* @param containerId Container ID to remove
|
||||
*/
|
||||
async removeContainer(containerId: string): Promise<void> {
|
||||
try {
|
||||
this.logger.log(`Removing container: ${containerId}`);
|
||||
const container = this.docker.getContainer(containerId);
|
||||
await container.remove({ force: true });
|
||||
this.logger.log(`Container removed successfully: ${containerId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to remove container ${containerId}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
throw new Error(`Failed to remove container ${containerId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get container status
|
||||
* @param containerId Container ID to inspect
|
||||
* @returns Container status string
|
||||
*/
|
||||
async getContainerStatus(containerId: string): Promise<string> {
|
||||
try {
|
||||
const container = this.docker.getContainer(containerId);
|
||||
const info = await container.inspect();
|
||||
return info.State.Status;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to get container status for ${containerId}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
throw new Error(`Failed to get container status for ${containerId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup container (stop and remove)
|
||||
* @param containerId Container ID to cleanup
|
||||
*/
|
||||
async cleanup(containerId: string): Promise<void> {
|
||||
this.logger.log(`Cleaning up container: ${containerId}`);
|
||||
|
||||
try {
|
||||
// Try to stop first
|
||||
await this.stopContainer(containerId);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to stop container ${containerId} during cleanup (may already be stopped): ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Always try to remove
|
||||
await this.removeContainer(containerId);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to remove container ${containerId} during cleanup: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
throw new Error(`Failed to cleanup container ${containerId}`);
|
||||
}
|
||||
|
||||
this.logger.log(`Container cleanup completed: ${containerId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if sandbox mode is enabled
|
||||
* @returns True if sandbox is enabled
|
||||
*/
|
||||
isEnabled(): boolean {
|
||||
return this.sandboxEnabled;
|
||||
}
|
||||
}
|
||||
@@ -2,5 +2,8 @@
|
||||
* Spawner module exports
|
||||
*/
|
||||
export { AgentSpawnerService } from "./agent-spawner.service";
|
||||
export { AgentLifecycleService } from "./agent-lifecycle.service";
|
||||
export { DockerSandboxService } from "./docker-sandbox.service";
|
||||
export { SpawnerModule } from "./spawner.module";
|
||||
export * from "./types/agent-spawner.types";
|
||||
export * from "./types/docker-sandbox.types";
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AgentSpawnerService } from "./agent-spawner.service";
|
||||
import { AgentLifecycleService } from "./agent-lifecycle.service";
|
||||
import { DockerSandboxService } from "./docker-sandbox.service";
|
||||
import { ValkeyModule } from "../valkey/valkey.module";
|
||||
|
||||
@Module({
|
||||
providers: [AgentSpawnerService],
|
||||
exports: [AgentSpawnerService],
|
||||
imports: [ValkeyModule],
|
||||
providers: [AgentSpawnerService, AgentLifecycleService, DockerSandboxService],
|
||||
exports: [AgentSpawnerService, AgentLifecycleService, DockerSandboxService],
|
||||
})
|
||||
export class SpawnerModule {}
|
||||
|
||||
@@ -82,4 +82,6 @@ export interface AgentSession {
|
||||
completedAt?: Date;
|
||||
/** Error if failed */
|
||||
error?: string;
|
||||
/** Docker container ID if sandbox is enabled */
|
||||
containerId?: string;
|
||||
}
|
||||
|
||||
46
apps/orchestrator/src/spawner/types/docker-sandbox.types.ts
Normal file
46
apps/orchestrator/src/spawner/types/docker-sandbox.types.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Network mode options for Docker containers
|
||||
*/
|
||||
export type NetworkMode = "bridge" | "host" | "none";
|
||||
|
||||
/**
|
||||
* Options for creating a Docker sandbox container
|
||||
*/
|
||||
export interface DockerSandboxOptions {
|
||||
/** Memory limit in MB (default: 512) */
|
||||
memoryMB?: number;
|
||||
/** CPU limit (1.0 = 1 core, default: 1.0) */
|
||||
cpuLimit?: number;
|
||||
/** Network mode (default: bridge) */
|
||||
networkMode?: NetworkMode;
|
||||
/** Docker image to use (default: node:20-alpine) */
|
||||
image?: string;
|
||||
/** Additional environment variables */
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of creating a Docker container
|
||||
*/
|
||||
export interface ContainerCreateResult {
|
||||
/** Docker container ID */
|
||||
containerId: string;
|
||||
/** Agent ID associated with this container */
|
||||
agentId: string;
|
||||
/** Task ID associated with this container */
|
||||
taskId: string;
|
||||
/** Timestamp when container was created */
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Container status information
|
||||
*/
|
||||
export interface ContainerStatus {
|
||||
/** Container ID */
|
||||
containerId: string;
|
||||
/** Current status (running, stopped, etc.) */
|
||||
status: string;
|
||||
/** Additional state information */
|
||||
state?: Record<string, unknown>;
|
||||
}
|
||||
8
apps/orchestrator/src/valkey/index.ts
Normal file
8
apps/orchestrator/src/valkey/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Valkey module public API
|
||||
*/
|
||||
|
||||
export * from './types';
|
||||
export * from './valkey.client';
|
||||
export * from './valkey.service';
|
||||
export * from './valkey.module';
|
||||
44
apps/orchestrator/src/valkey/types/events.types.ts
Normal file
44
apps/orchestrator/src/valkey/types/events.types.ts
Normal 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>;
|
||||
6
apps/orchestrator/src/valkey/types/index.ts
Normal file
6
apps/orchestrator/src/valkey/types/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Valkey module type exports
|
||||
*/
|
||||
|
||||
export * from './state.types';
|
||||
export * from './events.types';
|
||||
69
apps/orchestrator/src/valkey/types/state.types.ts
Normal file
69
apps/orchestrator/src/valkey/types/state.types.ts
Normal 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);
|
||||
}
|
||||
411
apps/orchestrator/src/valkey/valkey.client.spec.ts
Normal file
411
apps/orchestrator/src/valkey/valkey.client.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
275
apps/orchestrator/src/valkey/valkey.service.spec.ts
Normal file
275
apps/orchestrator/src/valkey/valkey.service.spec.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
132
apps/orchestrator/src/valkey/valkey.service.ts
Normal file
132
apps/orchestrator/src/valkey/valkey.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/orchestrator/src/queue/queue.service.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 5
|
||||
**Generated:** 2026-02-02 15:01:40
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/escalated/home-localadmin-src-mosaic-stack-apps-orchestrator-src-queue-queue.service.ts_20260202-1501_5_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/app.module.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 14:56:06
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-app.module.ts_20260202-1456_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/app.module.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 2
|
||||
**Generated:** 2026-02-02 14:56:12
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-app.module.ts_20260202-1456_2_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/dto/graph-query.dto.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 15:17:41
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-dto-graph-query.dto.ts_20260202-1517_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/dto/index.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 15:20:00
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-dto-index.ts_20260202-1520_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/entities/graph.entity.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 15:17:52
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-entities-graph.entity.ts_20260202-1517_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/graph.controller.spec.ts
|
||||
**Tool Used:** Write
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 15:19:38
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-graph.controller.spec.ts_20260202-1519_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/graph.controller.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 15:20:48
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-graph.controller.spec.ts_20260202-1520_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/graph.controller.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 15:22:31
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-graph.controller.spec.ts_20260202-1522_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/graph.controller.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 2
|
||||
**Generated:** 2026-02-02 15:22:43
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-graph.controller.spec.ts_20260202-1522_2_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/graph.controller.ts
|
||||
**Tool Used:** Write
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 15:19:20
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-graph.controller.ts_20260202-1519_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/graph.controller.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 15:21:59
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-graph.controller.ts_20260202-1521_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/graph.controller.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 15:24:31
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-graph.controller.ts_20260202-1524_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/knowledge.module.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 14:55:37
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-knowledge.module.ts_20260202-1455_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/knowledge.module.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 15:19:44
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-knowledge.module.ts_20260202-1519_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/knowledge.module.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 2
|
||||
**Generated:** 2026-02-02 15:19:49
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-knowledge.module.ts_20260202-1519_2_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/knowledge.service.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 14:56:53
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-knowledge.service.ts_20260202-1456_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/knowledge.service.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 14:57:18
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-knowledge.service.ts_20260202-1457_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/knowledge.service.versions.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 15:13:47
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-knowledge.service.versions.spec.ts_20260202-1513_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/knowledge.service.versions.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 2
|
||||
**Generated:** 2026-02-02 15:13:57
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-knowledge.service.versions.spec.ts_20260202-1513_2_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/queues/embedding-queue.service.ts
|
||||
**Tool Used:** Write
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 14:54:47
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-queues-embedding-queue.service.ts_20260202-1454_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/queues/embedding-queue.service.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 14:57:52
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-queues-embedding-queue.service.ts_20260202-1457_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/queues/embedding-queue.spec.ts
|
||||
**Tool Used:** Write
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 14:54:32
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-queues-embedding-queue.spec.ts_20260202-1454_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/queues/embedding.processor.spec.ts
|
||||
**Tool Used:** Write
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 14:55:03
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-queues-embedding.processor.spec.ts_20260202-1455_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/queues/embedding.processor.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 14:59:04
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-queues-embedding.processor.spec.ts_20260202-1459_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/queues/embedding.processor.ts
|
||||
**Tool Used:** Write
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 14:55:17
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-queues-embedding.processor.ts_20260202-1455_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/queues/embedding.processor.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 14:57:58
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-queues-embedding.processor.ts_20260202-1457_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/queues/embedding.processor.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 15:03:20
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-queues-embedding.processor.ts_20260202-1503_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/queues/embedding.processor.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 2
|
||||
**Generated:** 2026-02-02 15:03:23
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-queues-embedding.processor.ts_20260202-1503_2_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/queues/embedding.processor.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 3
|
||||
**Generated:** 2026-02-02 15:03:26
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-queues-embedding.processor.ts_20260202-1503_3_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/queues/index.ts
|
||||
**Tool Used:** Write
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 15:01:33
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-queues-index.ts_20260202-1501_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/search.controller.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 15:11:03
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-search.controller.ts_20260202-1511_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/services/graph.service.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 15:18:33
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-services-graph.service.spec.ts_20260202-1518_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/services/graph.service.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 15:22:53
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-services-graph.service.spec.ts_20260202-1522_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/services/graph.service.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 15:18:43
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-services-graph.service.ts_20260202-1518_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/services/graph.service.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 15:19:06
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-services-graph.service.ts_20260202-1519_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/services/graph.service.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 15:22:07
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-services-graph.service.ts_20260202-1522_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/services/graph.service.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 2
|
||||
**Generated:** 2026-02-02 15:22:16
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-services-graph.service.ts_20260202-1522_2_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/services/graph.service.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 3
|
||||
**Generated:** 2026-02-02 15:22:25
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-services-graph.service.ts_20260202-1522_3_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/services/graph.service.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 15:24:39
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-services-graph.service.ts_20260202-1524_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/services/graph.service.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 2
|
||||
**Generated:** 2026-02-02 15:24:47
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-services-graph.service.ts_20260202-1524_2_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/services/ollama-embedding.service.spec.ts
|
||||
**Tool Used:** Write
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 14:53:47
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-services-ollama-embedding.service.spec.ts_20260202-1453_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/services/ollama-embedding.service.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 14:58:45
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-services-ollama-embedding.service.spec.ts_20260202-1458_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/services/ollama-embedding.service.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 2
|
||||
**Generated:** 2026-02-02 14:58:50
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-services-ollama-embedding.service.spec.ts_20260202-1458_2_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/services/ollama-embedding.service.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 3
|
||||
**Generated:** 2026-02-02 14:58:54
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-services-ollama-embedding.service.spec.ts_20260202-1458_3_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/services/ollama-embedding.service.ts
|
||||
**Tool Used:** Write
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 14:54:16
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-services-ollama-embedding.service.ts_20260202-1454_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/services/ollama-embedding.service.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 15:03:37
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-services-ollama-embedding.service.ts_20260202-1503_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/services/search.service.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 15:09:20
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-services-search.service.spec.ts_20260202-1509_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/services/search.service.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 2
|
||||
**Generated:** 2026-02-02 15:09:29
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-services-search.service.spec.ts_20260202-1509_2_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/services/search.service.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 3
|
||||
**Generated:** 2026-02-02 15:09:55
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-services-search.service.spec.ts_20260202-1509_3_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/services/search.service.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 15:11:55
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-services-search.service.spec.ts_20260202-1511_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/services/search.service.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 15:12:05
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-services-search.service.spec.ts_20260202-1512_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/services/search.service.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-02 15:10:13
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-services-search.service.ts_20260202-1510_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/services/search.service.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 2
|
||||
**Generated:** 2026-02-02 15:10:19
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-services-search.service.ts_20260202-1510_2_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/api/src/knowledge/services/search.service.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 3
|
||||
**Generated:** 2026-02-02 15:10:45
|
||||
|
||||
## Status
|
||||
Pending QA validation
|
||||
|
||||
## Next Steps
|
||||
This report was created by the QA automation hook.
|
||||
To process this report, run:
|
||||
```bash
|
||||
claude -p "Use Task tool to launch universal-qa-agent for report: /home/localadmin/src/mosaic-stack/docs/reports/qa-automation/pending/home-localadmin-src-mosaic-stack-apps-api-src-knowledge-services-search.service.ts_20260202-1510_3_remediation_needed.md"
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user