chore: upgrade Node.js runtime to v24 across codebase #419
57
README.md
57
README.md
@@ -110,6 +110,8 @@ docker compose down
|
||||
- Valkey (Redis-compatible cache)
|
||||
- Mosaic API (NestJS)
|
||||
- Mosaic Web (Next.js)
|
||||
- Mosaic Orchestrator (Agent lifecycle management)
|
||||
- Mosaic Coordinator (Task assignment & monitoring)
|
||||
- Authentik OIDC (optional, use `--profile authentik`)
|
||||
- Ollama AI (optional, use `--profile ollama`)
|
||||
|
||||
@@ -124,13 +126,29 @@ mosaic-stack/
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── auth/ # BetterAuth + Authentik OIDC
|
||||
│ │ │ ├── prisma/ # Database service
|
||||
│ │ │ ├── coordinator-integration/ # Coordinator API client
|
||||
│ │ │ └── app.module.ts # Main application module
|
||||
│ │ ├── prisma/
|
||||
│ │ │ └── schema.prisma # Database schema
|
||||
│ │ └── Dockerfile
|
||||
│ └── web/ # Next.js 16 frontend (planned)
|
||||
│ ├── app/
|
||||
│ ├── components/
|
||||
│ ├── web/ # Next.js 16 frontend
|
||||
│ │ ├── app/
|
||||
│ │ ├── components/
|
||||
│ │ │ └── widgets/ # HUD widgets (agent status, etc.)
|
||||
│ │ └── Dockerfile
|
||||
│ ├── orchestrator/ # Agent lifecycle & spawning (NestJS)
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── spawner/ # Agent spawning service
|
||||
│ │ │ ├── queue/ # Valkey-backed task queue
|
||||
│ │ │ ├── monitor/ # Health monitoring
|
||||
│ │ │ ├── git/ # Git worktree management
|
||||
│ │ │ └── killswitch/ # Emergency agent termination
|
||||
│ │ └── Dockerfile
|
||||
│ └── coordinator/ # Task assignment & monitoring (FastAPI)
|
||||
│ ├── src/
|
||||
│ │ ├── webhook.py # Gitea webhook receiver
|
||||
│ │ ├── parser.py # Issue metadata parser
|
||||
│ │ └── security.py # HMAC signature verification
|
||||
│ └── Dockerfile
|
||||
├── packages/
|
||||
│ ├── shared/ # Shared types & utilities
|
||||
@@ -159,23 +177,36 @@ mosaic-stack/
|
||||
└── pnpm-workspace.yaml # Workspace configuration
|
||||
```
|
||||
|
||||
## Agent Orchestration Layer (v0.0.6)
|
||||
|
||||
Mosaic Stack includes a sophisticated agent orchestration system for autonomous task execution:
|
||||
|
||||
- **Orchestrator Service** (NestJS) - Manages agent lifecycle, spawning, and health monitoring
|
||||
- **Coordinator Service** (FastAPI) - Receives Gitea webhooks, assigns tasks to agents
|
||||
- **Task Queue** - Valkey-backed queue for distributed task management
|
||||
- **Git Worktrees** - Isolated workspaces for parallel agent execution
|
||||
- **Killswitch** - Emergency stop mechanism for runaway agents
|
||||
- **Agent Dashboard** - Real-time monitoring UI with status widgets
|
||||
|
||||
See [Agent Orchestration Design](docs/design/agent-orchestration.md) for architecture details.
|
||||
|
||||
## Current Implementation Status
|
||||
|
||||
### ✅ Completed (v0.0.1)
|
||||
### ✅ Completed (v0.0.1-0.0.6)
|
||||
|
||||
- **Issue #1:** Project scaffold and monorepo setup
|
||||
- **Issue #2:** PostgreSQL 17 + pgvector database schema
|
||||
- **Issue #3:** Prisma ORM integration with tests and seed data
|
||||
- **Issue #4:** Authentik OIDC authentication with BetterAuth
|
||||
- **M1-Foundation:** Project scaffold, PostgreSQL 17 + pgvector, Prisma ORM
|
||||
- **M2-MultiTenant:** Workspace isolation with RLS, team management
|
||||
- **M3-Features:** Knowledge management, tasks, calendar, authentication
|
||||
- **M4-MoltBot:** Bot integration architecture (in progress)
|
||||
- **M6-AgentOrchestration:** Orchestrator service, coordinator, agent dashboard ✅
|
||||
|
||||
**Test Coverage:** 26/26 tests passing (100%)
|
||||
**Test Coverage:** 2168+ tests passing
|
||||
|
||||
### 🚧 In Progress (v0.0.x)
|
||||
|
||||
- **Issue #5:** Multi-tenant workspace isolation (planned)
|
||||
- **Issue #6:** Frontend authentication UI ✅ **COMPLETED**
|
||||
- **Issue #7:** Activity logging system (planned)
|
||||
- **Issue #8:** Docker compose setup ✅ **COMPLETED**
|
||||
- Agent orchestration E2E testing
|
||||
- Usage budget management
|
||||
- Performance optimization
|
||||
|
||||
### 📋 Planned Features (v0.1.0 MVP)
|
||||
|
||||
|
||||
@@ -6,59 +6,187 @@ Agent orchestration service for Mosaic Stack built with NestJS.
|
||||
|
||||
The Orchestrator is the execution plane of Mosaic Stack, responsible for:
|
||||
|
||||
- Spawning and managing Claude agents
|
||||
- Task queue management (Valkey-backed)
|
||||
- Agent health monitoring and recovery
|
||||
- Git workflow automation
|
||||
- Quality gate enforcement callbacks
|
||||
- Killswitch emergency stop
|
||||
- Spawning and managing Claude agents (worker, reviewer, tester)
|
||||
- Task queue management via BullMQ with Valkey backend
|
||||
- Agent lifecycle state machine (spawning → running → completed/failed/killed)
|
||||
- Git workflow automation with worktree isolation per agent
|
||||
- Quality gate enforcement via Coordinator integration
|
||||
- Killswitch emergency stop with cleanup
|
||||
- Docker sandbox isolation (optional)
|
||||
- Secret scanning on agent commits
|
||||
|
||||
## Architecture
|
||||
|
||||
Part of the Mosaic Stack monorepo at `apps/orchestrator/`.
|
||||
```
|
||||
AppModule
|
||||
├── HealthModule → GET /health, GET /health/ready
|
||||
├── AgentsModule → POST /agents/spawn, GET /agents/:id/status, kill endpoints
|
||||
│ ├── QueueModule → BullMQ task queue (priority 1-10, retry with backoff)
|
||||
│ ├── SpawnerModule → Agent session management, Docker sandbox, lifecycle FSM
|
||||
│ ├── KillswitchModule → Emergency kill + cleanup (Docker, worktree, Valkey state)
|
||||
│ └── ValkeyModule → Distributed state persistence and pub/sub events
|
||||
├── CoordinatorModule → Quality gate checks (typecheck, lint, tests, coverage, AI review)
|
||||
├── GitModule → Clone, branch, commit, push, conflict detection, secret scanning
|
||||
└── MonitorModule → Agent health monitoring (placeholder)
|
||||
```
|
||||
|
||||
Part of the Mosaic Stack monorepo at `apps/orchestrator/`.
|
||||
Controlled by `apps/coordinator/` (Quality Coordinator).
|
||||
Monitored via `apps/web/` (Agent Dashboard).
|
||||
|
||||
## API Reference
|
||||
|
||||
### Health
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | --------------- | ----------------- |
|
||||
| GET | `/health` | Uptime and status |
|
||||
| GET | `/health/ready` | Readiness check |
|
||||
|
||||
### Agents
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ------------------------- | ---------------------- |
|
||||
| POST | `/agents/spawn` | Spawn a new agent |
|
||||
| GET | `/agents/:agentId/status` | Get agent status |
|
||||
| POST | `/agents/:agentId/kill` | Kill a single agent |
|
||||
| POST | `/agents/kill-all` | Kill all active agents |
|
||||
|
||||
#### POST /agents/spawn
|
||||
|
||||
```json
|
||||
{
|
||||
"taskId": "string (required)",
|
||||
"agentType": "worker | reviewer | tester",
|
||||
"gateProfile": "strict | standard | minimal | custom (optional)",
|
||||
"context": {
|
||||
"repository": "https://git.example.com/repo.git",
|
||||
"branch": "main",
|
||||
"workItems": ["US-001"],
|
||||
"skills": ["typescript"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"agentId": "uuid",
|
||||
"status": "spawning"
|
||||
}
|
||||
```
|
||||
|
||||
#### GET /agents/:agentId/status
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"agentId": "uuid",
|
||||
"taskId": "string",
|
||||
"status": "spawning | running | completed | failed | killed",
|
||||
"spawnedAt": "ISO timestamp",
|
||||
"startedAt": "ISO timestamp (optional)",
|
||||
"completedAt": "ISO timestamp (optional)",
|
||||
"error": "string (optional)"
|
||||
}
|
||||
```
|
||||
|
||||
#### POST /agents/kill-all
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Kill all completed: 3 killed, 0 failed",
|
||||
"total": 3,
|
||||
"killed": 3,
|
||||
"failed": 0,
|
||||
"errors": []
|
||||
}
|
||||
```
|
||||
|
||||
## Services
|
||||
|
||||
| Service | Module | Responsibility |
|
||||
| ------------------------ | ----------- | ---------------------------------------------------- |
|
||||
| AgentSpawnerService | Spawner | Create agent sessions, generate UUIDs, track state |
|
||||
| AgentLifecycleService | Spawner | State machine transitions with Valkey pub/sub events |
|
||||
| DockerSandboxService | Spawner | Container creation with memory/CPU limits |
|
||||
| QueueService | Queue | BullMQ priority queue with exponential backoff retry |
|
||||
| KillswitchService | Killswitch | Emergency agent termination with audit logging |
|
||||
| CleanupService | Killswitch | Multi-step cleanup (Docker, worktree, Valkey state) |
|
||||
| GitOperationsService | Git | Clone, branch, commit, push operations |
|
||||
| WorktreeManagerService | Git | Per-agent worktree isolation |
|
||||
| ConflictDetectionService | Git | Merge conflict detection before push |
|
||||
| SecretScannerService | Git | Detect hardcoded secrets (AWS, API keys, JWTs, etc.) |
|
||||
| ValkeyService | Valkey | Distributed state and event pub/sub |
|
||||
| CoordinatorClientService | Coordinator | HTTP client for quality gate API with retry |
|
||||
| QualityGatesService | Coordinator | Pre-commit and post-commit gate evaluation |
|
||||
|
||||
## Valkey State Keys
|
||||
|
||||
```
|
||||
orchestrator:task:{taskId} → TaskState (status, agentId, context, timestamps)
|
||||
orchestrator:agent:{agentId} → AgentState (status, taskId, timestamps, error)
|
||||
orchestrator:events → Pub/sub channel for lifecycle events
|
||||
```
|
||||
|
||||
## Quality Gate Profiles
|
||||
|
||||
| Profile | Default For | Gates |
|
||||
| -------- | ----------- | --------------------------------------------------------------------- |
|
||||
| strict | reviewer | typecheck, lint, tests, coverage (85%), build, integration, AI review |
|
||||
| standard | worker | typecheck, lint, tests, coverage (85%) |
|
||||
| minimal | tester | tests only |
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dependencies (from monorepo root)
|
||||
pnpm install
|
||||
|
||||
# Run in dev mode (watch mode)
|
||||
# Run in dev mode
|
||||
pnpm --filter @mosaic/orchestrator dev
|
||||
|
||||
# Build
|
||||
pnpm --filter @mosaic/orchestrator build
|
||||
|
||||
# Start production
|
||||
pnpm --filter @mosaic/orchestrator start:prod
|
||||
|
||||
# Test
|
||||
# Run unit tests
|
||||
pnpm --filter @mosaic/orchestrator test
|
||||
|
||||
# Generate module (NestJS CLI)
|
||||
cd apps/orchestrator
|
||||
nest generate module <name>
|
||||
nest generate controller <name>
|
||||
nest generate service <name>
|
||||
# Run E2E/integration tests
|
||||
pnpm --filter @mosaic/orchestrator test:e2e
|
||||
|
||||
# Type check
|
||||
pnpm --filter @mosaic/orchestrator typecheck
|
||||
|
||||
# Lint
|
||||
pnpm --filter @mosaic/orchestrator lint
|
||||
```
|
||||
|
||||
## NestJS Architecture
|
||||
## Testing
|
||||
|
||||
- **Modules:** Feature-based organization (spawner, queue, monitor, etc.)
|
||||
- **Controllers:** HTTP endpoints (health, agents, tasks)
|
||||
- **Services:** Business logic
|
||||
- **Providers:** Dependency injection
|
||||
- **Unit tests:** Co-located `*.spec.ts` files (19 test files, 447+ tests)
|
||||
- **Integration tests:** `tests/integration/*.e2e-spec.ts` (17 E2E tests)
|
||||
- **Coverage threshold:** 85% (lines, functions, branches, statements)
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables loaded via @nestjs/config.
|
||||
See `.env.example` for required vars.
|
||||
Environment variables loaded via `@nestjs/config`. Key variables:
|
||||
|
||||
## Documentation
|
||||
| Variable | Description |
|
||||
| ------------------- | -------------------------------------- |
|
||||
| `ORCHESTRATOR_PORT` | HTTP port (default: 3001) |
|
||||
| `CLAUDE_API_KEY` | Claude API key for agents |
|
||||
| `VALKEY_HOST` | Valkey/Redis host (default: localhost) |
|
||||
| `VALKEY_PORT` | Valkey/Redis port (default: 6379) |
|
||||
| `COORDINATOR_URL` | Quality Coordinator base URL |
|
||||
| `SANDBOX_ENABLED` | Enable Docker sandbox (true/false) |
|
||||
|
||||
- Architecture: `/docs/ORCHESTRATOR-MONOREPO-SETUP.md`
|
||||
- API Contracts: `/docs/M6-ISSUE-AUDIT.md`
|
||||
## Related Documentation
|
||||
|
||||
- Design: `docs/design/agent-orchestration.md`
|
||||
- Setup: `docs/ORCHESTRATOR-MONOREPO-SETUP.md`
|
||||
- Milestone: M6-AgentOrchestration (0.0.6)
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"test": "vitest",
|
||||
"test:watch": "vitest watch",
|
||||
"test:e2e": "vitest run --config tests/integration/vitest.config.ts",
|
||||
"test:perf": "vitest run --config tests/performance/vitest.config.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src/",
|
||||
"lint:fix": "eslint src/ --fix"
|
||||
|
||||
@@ -13,6 +13,8 @@ describe("AgentsController", () => {
|
||||
};
|
||||
let spawnerService: {
|
||||
spawnAgent: ReturnType<typeof vi.fn>;
|
||||
listAgentSessions: ReturnType<typeof vi.fn>;
|
||||
getAgentSession: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let lifecycleService: {
|
||||
getAgentLifecycleState: ReturnType<typeof vi.fn>;
|
||||
@@ -30,6 +32,8 @@ describe("AgentsController", () => {
|
||||
|
||||
spawnerService = {
|
||||
spawnAgent: vi.fn(),
|
||||
listAgentSessions: vi.fn(),
|
||||
getAgentSession: vi.fn(),
|
||||
};
|
||||
|
||||
lifecycleService = {
|
||||
@@ -58,6 +62,109 @@ describe("AgentsController", () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe("listAgents", () => {
|
||||
it("should return empty array when no agents exist", () => {
|
||||
// Arrange
|
||||
spawnerService.listAgentSessions.mockReturnValue([]);
|
||||
|
||||
// Act
|
||||
const result = controller.listAgents();
|
||||
|
||||
// Assert
|
||||
expect(spawnerService.listAgentSessions).toHaveBeenCalled();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return all agent sessions with mapped status", () => {
|
||||
// Arrange
|
||||
const sessions = [
|
||||
{
|
||||
agentId: "agent-1",
|
||||
taskId: "task-1",
|
||||
agentType: "worker" as const,
|
||||
state: "running" as const,
|
||||
context: {
|
||||
repository: "repo",
|
||||
branch: "main",
|
||||
workItems: [],
|
||||
},
|
||||
spawnedAt: new Date("2026-02-05T12:00:00Z"),
|
||||
},
|
||||
{
|
||||
agentId: "agent-2",
|
||||
taskId: "task-2",
|
||||
agentType: "reviewer" as const,
|
||||
state: "completed" as const,
|
||||
context: {
|
||||
repository: "repo",
|
||||
branch: "main",
|
||||
workItems: [],
|
||||
},
|
||||
spawnedAt: new Date("2026-02-05T11:00:00Z"),
|
||||
completedAt: new Date("2026-02-05T11:30:00Z"),
|
||||
},
|
||||
{
|
||||
agentId: "agent-3",
|
||||
taskId: "task-3",
|
||||
agentType: "tester" as const,
|
||||
state: "failed" as const,
|
||||
context: {
|
||||
repository: "repo",
|
||||
branch: "main",
|
||||
workItems: [],
|
||||
},
|
||||
spawnedAt: new Date("2026-02-05T10:00:00Z"),
|
||||
error: "Test execution failed",
|
||||
},
|
||||
];
|
||||
spawnerService.listAgentSessions.mockReturnValue(sessions);
|
||||
|
||||
// Act
|
||||
const result = controller.listAgents();
|
||||
|
||||
// Assert
|
||||
expect(spawnerService.listAgentSessions).toHaveBeenCalled();
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]).toEqual({
|
||||
agentId: "agent-1",
|
||||
taskId: "task-1",
|
||||
status: "running",
|
||||
agentType: "worker",
|
||||
spawnedAt: "2026-02-05T12:00:00.000Z",
|
||||
completedAt: undefined,
|
||||
error: undefined,
|
||||
});
|
||||
expect(result[1]).toEqual({
|
||||
agentId: "agent-2",
|
||||
taskId: "task-2",
|
||||
status: "completed",
|
||||
agentType: "reviewer",
|
||||
spawnedAt: "2026-02-05T11:00:00.000Z",
|
||||
completedAt: "2026-02-05T11:30:00.000Z",
|
||||
error: undefined,
|
||||
});
|
||||
expect(result[2]).toEqual({
|
||||
agentId: "agent-3",
|
||||
taskId: "task-3",
|
||||
status: "failed",
|
||||
agentType: "tester",
|
||||
spawnedAt: "2026-02-05T10:00:00.000Z",
|
||||
completedAt: undefined,
|
||||
error: "Test execution failed",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle errors gracefully", () => {
|
||||
// Arrange
|
||||
spawnerService.listAgentSessions.mockImplementation(() => {
|
||||
throw new Error("Service unavailable");
|
||||
});
|
||||
|
||||
// Act & Assert
|
||||
expect(() => controller.listAgents()).toThrow("Failed to list agents: Service unavailable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("spawn", () => {
|
||||
const validRequest = {
|
||||
taskId: "task-123",
|
||||
|
||||
@@ -70,6 +70,47 @@ export class AgentsController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all agents
|
||||
* @returns Array of all agent sessions with their status
|
||||
*/
|
||||
@Get()
|
||||
listAgents(): {
|
||||
agentId: string;
|
||||
taskId: string;
|
||||
status: string;
|
||||
agentType: string;
|
||||
spawnedAt: string;
|
||||
completedAt?: string;
|
||||
error?: string;
|
||||
}[] {
|
||||
this.logger.log("Received request to list all agents");
|
||||
|
||||
try {
|
||||
// Get all sessions from spawner service
|
||||
const sessions = this.spawnerService.listAgentSessions();
|
||||
|
||||
// Map to response format
|
||||
const agents = sessions.map((session) => ({
|
||||
agentId: session.agentId,
|
||||
taskId: session.taskId,
|
||||
status: session.state,
|
||||
agentType: session.agentType,
|
||||
spawnedAt: session.spawnedAt.toISOString(),
|
||||
completedAt: session.completedAt?.toISOString(),
|
||||
error: session.error,
|
||||
}));
|
||||
|
||||
this.logger.log(`Found ${agents.length.toString()} agents`);
|
||||
|
||||
return agents;
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error(`Failed to list agents: ${errorMessage}`);
|
||||
throw new Error(`Failed to list agents: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent status
|
||||
* @param agentId Agent ID to query
|
||||
|
||||
242
apps/orchestrator/tests/integration/agent-lifecycle.e2e-spec.ts
Normal file
242
apps/orchestrator/tests/integration/agent-lifecycle.e2e-spec.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* E2E Test: Full Agent Lifecycle
|
||||
*
|
||||
* Tests the complete lifecycle of an agent from spawn to completion/failure.
|
||||
* Uses mocked services to simulate the full flow without external dependencies.
|
||||
*
|
||||
* Lifecycle: spawn → running → completed/failed/killed
|
||||
*
|
||||
* Covers issue #226 (ORCH-125)
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { AgentSpawnerService } from "../../src/spawner/agent-spawner.service";
|
||||
import { AgentLifecycleService } from "../../src/spawner/agent-lifecycle.service";
|
||||
import { QueueService } from "../../src/queue/queue.service";
|
||||
import { KillswitchService } from "../../src/killswitch/killswitch.service";
|
||||
import { AgentsController } from "../../src/api/agents/agents.controller";
|
||||
import type { AgentState } from "../../src/valkey/types";
|
||||
|
||||
describe("E2E: Full Agent Lifecycle", () => {
|
||||
let controller: AgentsController;
|
||||
let spawnerService: AgentSpawnerService;
|
||||
let lifecycleService: AgentLifecycleService;
|
||||
let queueService: QueueService;
|
||||
|
||||
const mockConfigService = {
|
||||
get: vi.fn((key: string, defaultValue?: unknown) => {
|
||||
const config: Record<string, unknown> = {
|
||||
"orchestrator.claude.apiKey": "test-api-key",
|
||||
"orchestrator.queue.name": "test-queue",
|
||||
"orchestrator.queue.maxRetries": 3,
|
||||
"orchestrator.queue.baseDelay": 100,
|
||||
"orchestrator.queue.maxDelay": 1000,
|
||||
"orchestrator.valkey.host": "localhost",
|
||||
"orchestrator.valkey.port": 6379,
|
||||
};
|
||||
return config[key] ?? defaultValue;
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Create real spawner service with mock config
|
||||
spawnerService = new AgentSpawnerService(mockConfigService as unknown as ConfigService);
|
||||
|
||||
// Create mock lifecycle service
|
||||
lifecycleService = {
|
||||
transitionToRunning: vi.fn(),
|
||||
transitionToCompleted: vi.fn(),
|
||||
transitionToFailed: vi.fn(),
|
||||
getAgentLifecycleState: vi.fn(),
|
||||
} as unknown as AgentLifecycleService;
|
||||
|
||||
// Create mock queue service
|
||||
queueService = {
|
||||
addTask: vi.fn().mockResolvedValue(undefined),
|
||||
getStats: vi.fn(),
|
||||
} as unknown as QueueService;
|
||||
|
||||
const killswitchService = {
|
||||
killAgent: vi.fn(),
|
||||
killAllAgents: vi.fn(),
|
||||
} as unknown as KillswitchService;
|
||||
|
||||
controller = new AgentsController(
|
||||
queueService,
|
||||
spawnerService,
|
||||
lifecycleService,
|
||||
killswitchService
|
||||
);
|
||||
});
|
||||
|
||||
describe("Happy path: spawn → queue → track", () => {
|
||||
it("should spawn an agent, register it, and queue the task", async () => {
|
||||
// Step 1: Spawn agent
|
||||
const spawnResult = await controller.spawn({
|
||||
taskId: "e2e-task-001",
|
||||
agentType: "worker",
|
||||
context: {
|
||||
repository: "https://git.example.com/repo.git",
|
||||
branch: "main",
|
||||
workItems: ["US-001"],
|
||||
skills: ["typescript"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(spawnResult.agentId).toBeDefined();
|
||||
expect(spawnResult.status).toBe("spawning");
|
||||
|
||||
// Step 2: Verify agent appears in list
|
||||
const agents = spawnerService.listAgentSessions();
|
||||
expect(agents).toHaveLength(1);
|
||||
expect(agents[0].state).toBe("spawning");
|
||||
expect(agents[0].taskId).toBe("e2e-task-001");
|
||||
|
||||
// Step 3: Verify agent status
|
||||
const session = spawnerService.getAgentSession(spawnResult.agentId);
|
||||
expect(session).toBeDefined();
|
||||
expect(session?.state).toBe("spawning");
|
||||
expect(session?.agentType).toBe("worker");
|
||||
|
||||
// Step 4: Verify task was queued
|
||||
expect(queueService.addTask).toHaveBeenCalledWith(
|
||||
"e2e-task-001",
|
||||
expect.objectContaining({
|
||||
repository: "https://git.example.com/repo.git",
|
||||
branch: "main",
|
||||
}),
|
||||
{ priority: 5 }
|
||||
);
|
||||
});
|
||||
|
||||
it("should track multiple agents spawned sequentially", async () => {
|
||||
// Spawn 3 agents
|
||||
const agents = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const result = await controller.spawn({
|
||||
taskId: `e2e-task-${String(i).padStart(3, "0")}`,
|
||||
agentType: "worker",
|
||||
context: {
|
||||
repository: "https://git.example.com/repo.git",
|
||||
branch: "main",
|
||||
workItems: [`US-${String(i).padStart(3, "0")}`],
|
||||
},
|
||||
});
|
||||
agents.push(result);
|
||||
}
|
||||
|
||||
// Verify all 3 agents are listed
|
||||
const listedAgents = spawnerService.listAgentSessions();
|
||||
expect(listedAgents).toHaveLength(3);
|
||||
|
||||
// Verify each agent has unique ID
|
||||
const agentIds = listedAgents.map((a) => a.agentId);
|
||||
const uniqueIds = new Set(agentIds);
|
||||
expect(uniqueIds.size).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Failure path: spawn → running → failed", () => {
|
||||
it("should handle agent spawn with invalid parameters", async () => {
|
||||
await expect(
|
||||
controller.spawn({
|
||||
taskId: "",
|
||||
agentType: "worker",
|
||||
context: {
|
||||
repository: "https://git.example.com/repo.git",
|
||||
branch: "main",
|
||||
workItems: ["US-001"],
|
||||
},
|
||||
})
|
||||
).rejects.toThrow("taskId is required");
|
||||
});
|
||||
|
||||
it("should reject invalid agent types", async () => {
|
||||
await expect(
|
||||
controller.spawn({
|
||||
taskId: "e2e-task-001",
|
||||
agentType: "invalid" as "worker",
|
||||
context: {
|
||||
repository: "https://git.example.com/repo.git",
|
||||
branch: "main",
|
||||
workItems: ["US-001"],
|
||||
},
|
||||
})
|
||||
).rejects.toThrow("agentType must be one of");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Multi-type agents", () => {
|
||||
it("should support worker, reviewer, and tester agent types", async () => {
|
||||
const types = ["worker", "reviewer", "tester"] as const;
|
||||
|
||||
for (const agentType of types) {
|
||||
const result = await controller.spawn({
|
||||
taskId: `e2e-task-${agentType}`,
|
||||
agentType,
|
||||
context: {
|
||||
repository: "https://git.example.com/repo.git",
|
||||
branch: "main",
|
||||
workItems: ["US-001"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.agentId).toBeDefined();
|
||||
expect(result.status).toBe("spawning");
|
||||
}
|
||||
|
||||
const agents = spawnerService.listAgentSessions();
|
||||
expect(agents).toHaveLength(3);
|
||||
|
||||
const agentTypes = agents.map((a) => a.agentType);
|
||||
expect(agentTypes).toContain("worker");
|
||||
expect(agentTypes).toContain("reviewer");
|
||||
expect(agentTypes).toContain("tester");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Agent status tracking", () => {
|
||||
it("should track spawn timestamp", async () => {
|
||||
const before = new Date();
|
||||
|
||||
const result = await controller.spawn({
|
||||
taskId: "e2e-task-time",
|
||||
agentType: "worker",
|
||||
context: {
|
||||
repository: "https://git.example.com/repo.git",
|
||||
branch: "main",
|
||||
workItems: ["US-001"],
|
||||
},
|
||||
});
|
||||
|
||||
const after = new Date();
|
||||
const agents = spawnerService.listAgentSessions();
|
||||
const agent = agents.find((a) => a.agentId === result.agentId);
|
||||
expect(agent).toBeDefined();
|
||||
|
||||
const spawnedAt = new Date(agent!.spawnedAt);
|
||||
expect(spawnedAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
|
||||
expect(spawnedAt.getTime()).toBeLessThanOrEqual(after.getTime());
|
||||
});
|
||||
|
||||
it("should return correct status for each agent", async () => {
|
||||
// Mock lifecycle to return specific states
|
||||
const mockState: AgentState = {
|
||||
agentId: "mock-agent-1",
|
||||
taskId: "e2e-task-001",
|
||||
status: "running",
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
(lifecycleService.getAgentLifecycleState as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
mockState
|
||||
);
|
||||
|
||||
const status = await controller.getAgentStatus("mock-agent-1");
|
||||
expect(status.status).toBe("running");
|
||||
expect(status.taskId).toBe("e2e-task-001");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* E2E Test: Concurrent Agents
|
||||
*
|
||||
* Tests multiple agents running concurrently with proper isolation.
|
||||
* Verifies agent-level isolation, queue management, and concurrent operations.
|
||||
*
|
||||
* Covers issue #228 (ORCH-127)
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { AgentSpawnerService } from "../../src/spawner/agent-spawner.service";
|
||||
import { AgentsController } from "../../src/api/agents/agents.controller";
|
||||
import { QueueService } from "../../src/queue/queue.service";
|
||||
import { AgentLifecycleService } from "../../src/spawner/agent-lifecycle.service";
|
||||
import { KillswitchService } from "../../src/killswitch/killswitch.service";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
describe("E2E: Concurrent Agents", () => {
|
||||
let controller: AgentsController;
|
||||
let spawnerService: AgentSpawnerService;
|
||||
|
||||
const mockConfigService = {
|
||||
get: vi.fn((key: string, defaultValue?: unknown) => {
|
||||
const config: Record<string, unknown> = {
|
||||
"orchestrator.claude.apiKey": "test-api-key",
|
||||
};
|
||||
return config[key] ?? defaultValue;
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
spawnerService = new AgentSpawnerService(mockConfigService as unknown as ConfigService);
|
||||
|
||||
const queueService = {
|
||||
addTask: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as QueueService;
|
||||
|
||||
const lifecycleService = {
|
||||
getAgentLifecycleState: vi.fn(),
|
||||
} as unknown as AgentLifecycleService;
|
||||
|
||||
const killswitchService = {
|
||||
killAgent: vi.fn(),
|
||||
killAllAgents: vi.fn(),
|
||||
} as unknown as KillswitchService;
|
||||
|
||||
controller = new AgentsController(
|
||||
queueService,
|
||||
spawnerService,
|
||||
lifecycleService,
|
||||
killswitchService
|
||||
);
|
||||
});
|
||||
|
||||
describe("Concurrent spawning", () => {
|
||||
it("should spawn multiple agents simultaneously without conflicts", async () => {
|
||||
// Spawn 5 agents in parallel
|
||||
const spawnPromises = Array.from({ length: 5 }, (_, i) =>
|
||||
controller.spawn({
|
||||
taskId: `concurrent-task-${String(i)}`,
|
||||
agentType: "worker",
|
||||
context: {
|
||||
repository: "https://git.example.com/repo.git",
|
||||
branch: `feature/task-${String(i)}`,
|
||||
workItems: [`US-${String(i).padStart(3, "0")}`],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(spawnPromises);
|
||||
|
||||
// All should succeed
|
||||
expect(results).toHaveLength(5);
|
||||
results.forEach((result) => {
|
||||
expect(result.agentId).toBeDefined();
|
||||
expect(result.status).toBe("spawning");
|
||||
});
|
||||
|
||||
// All IDs should be unique
|
||||
const ids = new Set(results.map((r) => r.agentId));
|
||||
expect(ids.size).toBe(5);
|
||||
|
||||
// All should appear in the list
|
||||
const agents = spawnerService.listAgentSessions();
|
||||
expect(agents).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("should assign unique IDs to every agent even under concurrent load", async () => {
|
||||
const allIds = new Set<string>();
|
||||
const batchSize = 10;
|
||||
|
||||
// Spawn agents in batches
|
||||
for (let batch = 0; batch < 3; batch++) {
|
||||
const promises = Array.from({ length: batchSize }, (_, i) =>
|
||||
controller.spawn({
|
||||
taskId: `batch-${String(batch)}-task-${String(i)}`,
|
||||
agentType: "worker",
|
||||
context: {
|
||||
repository: "https://git.example.com/repo.git",
|
||||
branch: "main",
|
||||
workItems: [`US-${String(batch * batchSize + i)}`],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
results.forEach((r) => allIds.add(r.agentId));
|
||||
}
|
||||
|
||||
// All 30 IDs should be unique
|
||||
expect(allIds.size).toBe(30);
|
||||
|
||||
// All 30 should be listed
|
||||
const agents = spawnerService.listAgentSessions();
|
||||
expect(agents).toHaveLength(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mixed agent types concurrently", () => {
|
||||
it("should handle mixed worker/reviewer/tester agents concurrently", async () => {
|
||||
const types = ["worker", "reviewer", "tester"] as const;
|
||||
|
||||
const promises = types.flatMap((agentType, typeIndex) =>
|
||||
Array.from({ length: 3 }, (_, i) =>
|
||||
controller.spawn({
|
||||
taskId: `mixed-${agentType}-${String(i)}`,
|
||||
agentType,
|
||||
context: {
|
||||
repository: "https://git.example.com/repo.git",
|
||||
branch: `branch-${String(typeIndex * 3 + i)}`,
|
||||
workItems: [`US-${String(typeIndex * 3 + i)}`],
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
expect(results).toHaveLength(9);
|
||||
|
||||
const agents = spawnerService.listAgentSessions();
|
||||
expect(agents).toHaveLength(9);
|
||||
|
||||
// Verify type distribution
|
||||
const typeCounts = agents.reduce(
|
||||
(acc, a) => {
|
||||
acc[a.agentType] = (acc[a.agentType] ?? 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
|
||||
expect(typeCounts["worker"]).toBe(3);
|
||||
expect(typeCounts["reviewer"]).toBe(3);
|
||||
expect(typeCounts["tester"]).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Agent isolation", () => {
|
||||
it("should isolate agent contexts from each other", async () => {
|
||||
const agent1 = await controller.spawn({
|
||||
taskId: "isolated-task-1",
|
||||
agentType: "worker",
|
||||
context: {
|
||||
repository: "https://git.example.com/repo-a.git",
|
||||
branch: "main",
|
||||
workItems: ["US-001"],
|
||||
skills: ["typescript"],
|
||||
},
|
||||
});
|
||||
|
||||
const agent2 = await controller.spawn({
|
||||
taskId: "isolated-task-2",
|
||||
agentType: "reviewer",
|
||||
context: {
|
||||
repository: "https://git.example.com/repo-b.git",
|
||||
branch: "develop",
|
||||
workItems: ["US-002"],
|
||||
skills: ["python"],
|
||||
},
|
||||
});
|
||||
|
||||
// Verify sessions are independent
|
||||
const session1 = spawnerService.getAgentSession(agent1.agentId);
|
||||
const session2 = spawnerService.getAgentSession(agent2.agentId);
|
||||
|
||||
expect(session1?.context.repository).toBe("https://git.example.com/repo-a.git");
|
||||
expect(session2?.context.repository).toBe("https://git.example.com/repo-b.git");
|
||||
expect(session1?.context.branch).toBe("main");
|
||||
expect(session2?.context.branch).toBe("develop");
|
||||
});
|
||||
|
||||
it("should not leak state between concurrent agent operations", async () => {
|
||||
// Spawn agents with different task contexts
|
||||
const spawnPromises = Array.from({ length: 5 }, (_, i) =>
|
||||
controller.spawn({
|
||||
taskId: `leak-test-${String(i)}`,
|
||||
agentType: "worker",
|
||||
context: {
|
||||
repository: `https://git.example.com/repo-${String(i)}.git`,
|
||||
branch: `branch-${String(i)}`,
|
||||
workItems: [`US-${String(i).padStart(3, "0")}`],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const results = await Promise.all(spawnPromises);
|
||||
|
||||
// Verify each agent has its own isolated context
|
||||
results.forEach((result, i) => {
|
||||
const session = spawnerService.getAgentSession(result.agentId);
|
||||
expect(session?.taskId).toBe(`leak-test-${String(i)}`);
|
||||
expect(session?.context.repository).toBe(`https://git.example.com/repo-${String(i)}.git`);
|
||||
expect(session?.context.branch).toBe(`branch-${String(i)}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
158
apps/orchestrator/tests/integration/killswitch.e2e-spec.ts
Normal file
158
apps/orchestrator/tests/integration/killswitch.e2e-spec.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* E2E Test: Killswitch
|
||||
*
|
||||
* Tests the emergency stop mechanism for terminating agents.
|
||||
* Verifies single agent kill, kill-all, and cleanup operations.
|
||||
*
|
||||
* Covers issue #227 (ORCH-126)
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { KillswitchService } from "../../src/killswitch/killswitch.service";
|
||||
import { AgentSpawnerService } from "../../src/spawner/agent-spawner.service";
|
||||
import { AgentsController } from "../../src/api/agents/agents.controller";
|
||||
import { QueueService } from "../../src/queue/queue.service";
|
||||
import { AgentLifecycleService } from "../../src/spawner/agent-lifecycle.service";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
describe("E2E: Killswitch", () => {
|
||||
let controller: AgentsController;
|
||||
let spawnerService: AgentSpawnerService;
|
||||
let killswitchService: KillswitchService;
|
||||
|
||||
const mockConfigService = {
|
||||
get: vi.fn((key: string, defaultValue?: unknown) => {
|
||||
const config: Record<string, unknown> = {
|
||||
"orchestrator.claude.apiKey": "test-api-key",
|
||||
};
|
||||
return config[key] ?? defaultValue;
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
spawnerService = new AgentSpawnerService(mockConfigService as unknown as ConfigService);
|
||||
|
||||
killswitchService = {
|
||||
killAgent: vi.fn().mockResolvedValue(undefined),
|
||||
killAllAgents: vi.fn().mockResolvedValue({
|
||||
total: 3,
|
||||
killed: 3,
|
||||
failed: 0,
|
||||
}),
|
||||
} as unknown as KillswitchService;
|
||||
|
||||
const queueService = {
|
||||
addTask: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as QueueService;
|
||||
|
||||
const lifecycleService = {
|
||||
getAgentLifecycleState: vi.fn(),
|
||||
} as unknown as AgentLifecycleService;
|
||||
|
||||
controller = new AgentsController(
|
||||
queueService,
|
||||
spawnerService,
|
||||
lifecycleService,
|
||||
killswitchService
|
||||
);
|
||||
});
|
||||
|
||||
describe("Single agent kill", () => {
|
||||
it("should kill a single agent by ID", async () => {
|
||||
// Spawn an agent first
|
||||
const spawnResult = await controller.spawn({
|
||||
taskId: "kill-test-001",
|
||||
agentType: "worker",
|
||||
context: {
|
||||
repository: "https://git.example.com/repo.git",
|
||||
branch: "main",
|
||||
workItems: ["US-001"],
|
||||
},
|
||||
});
|
||||
|
||||
// Kill the agent
|
||||
const result = await controller.killAgent(spawnResult.agentId);
|
||||
|
||||
expect(result.message).toContain("killed successfully");
|
||||
expect(killswitchService.killAgent).toHaveBeenCalledWith(spawnResult.agentId);
|
||||
});
|
||||
|
||||
it("should handle kill of non-existent agent gracefully", async () => {
|
||||
(killswitchService.killAgent as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("Agent not found")
|
||||
);
|
||||
|
||||
await expect(controller.killAgent("non-existent")).rejects.toThrow("Agent not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Kill all agents", () => {
|
||||
it("should kill all active agents", async () => {
|
||||
// Spawn multiple agents to verify they exist before kill-all
|
||||
const spawned = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const result = await controller.spawn({
|
||||
taskId: `kill-all-test-${String(i)}`,
|
||||
agentType: "worker",
|
||||
context: {
|
||||
repository: "https://git.example.com/repo.git",
|
||||
branch: "main",
|
||||
workItems: [`US-${String(i)}`],
|
||||
},
|
||||
});
|
||||
spawned.push(result);
|
||||
}
|
||||
|
||||
// Verify agents were spawned
|
||||
expect(spawnerService.listAgentSessions()).toHaveLength(3);
|
||||
|
||||
// Kill all (mock returns hardcoded result matching spawn count)
|
||||
const result = await controller.killAllAgents();
|
||||
|
||||
expect(result.total).toBe(3);
|
||||
expect(result.killed).toBe(3);
|
||||
expect(result.failed).toBe(0);
|
||||
expect(killswitchService.killAllAgents).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should report partial failures in kill-all", async () => {
|
||||
(killswitchService.killAllAgents as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
total: 3,
|
||||
killed: 2,
|
||||
failed: 1,
|
||||
errors: ["Agent abc123 unresponsive"],
|
||||
});
|
||||
|
||||
const result = await controller.killAllAgents();
|
||||
|
||||
expect(result.total).toBe(3);
|
||||
expect(result.killed).toBe(2);
|
||||
expect(result.failed).toBe(1);
|
||||
expect(result.errors).toContain("Agent abc123 unresponsive");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Kill during lifecycle states", () => {
|
||||
it("should be able to kill agent in spawning state", async () => {
|
||||
const spawnResult = await controller.spawn({
|
||||
taskId: "kill-spawning-test",
|
||||
agentType: "worker",
|
||||
context: {
|
||||
repository: "https://git.example.com/repo.git",
|
||||
branch: "main",
|
||||
workItems: ["US-001"],
|
||||
},
|
||||
});
|
||||
|
||||
// Verify agent is spawning
|
||||
const agents = spawnerService.listAgentSessions();
|
||||
const agent = agents.find((a) => a.agentId === spawnResult.agentId);
|
||||
expect(agent?.state).toBe("spawning");
|
||||
|
||||
// Kill should succeed even in spawning state
|
||||
const result = await controller.killAgent(spawnResult.agentId);
|
||||
expect(result.message).toContain("killed successfully");
|
||||
});
|
||||
});
|
||||
});
|
||||
10
apps/orchestrator/tests/integration/vitest.config.ts
Normal file
10
apps/orchestrator/tests/integration/vitest.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["**/*.e2e-spec.ts"],
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Performance Test: Queue Service Throughput
|
||||
*
|
||||
* Benchmarks the queue service's pure functions under load
|
||||
* to verify performance characteristics.
|
||||
*
|
||||
* Covers issue #229 (ORCH-128)
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { QueueService } from "../../src/queue/queue.service";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
describe("Performance: Queue Service", () => {
|
||||
let service: QueueService;
|
||||
|
||||
const mockValkeyService = {
|
||||
getConnection: vi.fn().mockReturnValue({
|
||||
host: "localhost",
|
||||
port: 6379,
|
||||
}),
|
||||
updateTaskStatus: vi.fn().mockResolvedValue(undefined),
|
||||
publishEvent: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const mockConfigService = {
|
||||
get: vi.fn((key: string, defaultValue?: unknown) => {
|
||||
const config: Record<string, unknown> = {
|
||||
"orchestrator.queue.name": "perf-test-queue",
|
||||
"orchestrator.queue.maxRetries": 3,
|
||||
"orchestrator.queue.baseDelay": 1000,
|
||||
"orchestrator.queue.maxDelay": 60000,
|
||||
};
|
||||
return config[key] ?? defaultValue;
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new QueueService(
|
||||
mockValkeyService as never,
|
||||
mockConfigService as unknown as ConfigService
|
||||
);
|
||||
});
|
||||
|
||||
describe("Backoff calculation performance", () => {
|
||||
it("should calculate 10,000 backoff delays in under 10ms", () => {
|
||||
// Warmup
|
||||
for (let i = 0; i < 100; i++) {
|
||||
service.calculateBackoffDelay(i % 20, 1000, 60000);
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
service.calculateBackoffDelay(i % 20, 1000, 60000);
|
||||
}
|
||||
|
||||
const duration = performance.now() - start;
|
||||
expect(duration).toBeLessThan(10);
|
||||
});
|
||||
|
||||
it("should produce consistent results under rapid invocation", () => {
|
||||
const results: number[] = [];
|
||||
|
||||
for (let attempt = 0; attempt <= 10; attempt++) {
|
||||
const delay = service.calculateBackoffDelay(attempt, 1000, 60000);
|
||||
results.push(delay);
|
||||
}
|
||||
|
||||
// Verify expected exponential pattern
|
||||
expect(results[0]).toBe(1000); // 1000 * 2^0
|
||||
expect(results[1]).toBe(2000); // 1000 * 2^1
|
||||
expect(results[2]).toBe(4000); // 1000 * 2^2
|
||||
expect(results[3]).toBe(8000); // 1000 * 2^3
|
||||
|
||||
// After attempt 6 (64000), should be capped at 60000
|
||||
expect(results[6]).toBe(60000);
|
||||
expect(results[10]).toBe(60000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Backoff calculation at scale", () => {
|
||||
it("should handle all retry levels from 0 to 100 consistently", () => {
|
||||
// Warmup
|
||||
for (let i = 0; i < 50; i++) {
|
||||
service.calculateBackoffDelay(i, 1000, 60000);
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
const results = new Map<number, number>();
|
||||
|
||||
for (let attempt = 0; attempt <= 100; attempt++) {
|
||||
const delay = service.calculateBackoffDelay(attempt, 1000, 60000);
|
||||
results.set(attempt, delay);
|
||||
}
|
||||
|
||||
const duration = performance.now() - start;
|
||||
expect(duration).toBeLessThan(10);
|
||||
|
||||
// Verify monotonic increase up to cap
|
||||
for (let attempt = 1; attempt <= 100; attempt++) {
|
||||
const current = results.get(attempt) ?? 0;
|
||||
const previous = results.get(attempt - 1) ?? 0;
|
||||
expect(current).toBeGreaterThanOrEqual(previous);
|
||||
expect(current).toBeLessThanOrEqual(60000);
|
||||
}
|
||||
});
|
||||
|
||||
it("should calculate backoffs with varying base delays rapidly", () => {
|
||||
const baseDelays = [100, 500, 1000, 2000, 5000];
|
||||
const maxDelays = [10000, 30000, 60000, 120000];
|
||||
|
||||
// Warmup
|
||||
service.calculateBackoffDelay(0, 1000, 60000);
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
for (const base of baseDelays) {
|
||||
for (const max of maxDelays) {
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
const delay = service.calculateBackoffDelay(attempt, base, max);
|
||||
expect(delay).toBeLessThanOrEqual(max);
|
||||
expect(delay).toBeGreaterThanOrEqual(base);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const duration = performance.now() - start;
|
||||
// 5 * 4 * 20 = 400 calculations should complete quickly
|
||||
expect(duration).toBeLessThan(50);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Performance Test: Secret Scanner Throughput
|
||||
*
|
||||
* Benchmarks the secret scanner's ability to scan content
|
||||
* at scale without degrading performance.
|
||||
*
|
||||
* Covers issue #229 (ORCH-128)
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { SecretScannerService } from "../../src/git/secret-scanner.service";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
describe("Performance: Secret Scanner", () => {
|
||||
let scanner: SecretScannerService;
|
||||
|
||||
const mockConfigService = {
|
||||
get: vi.fn((_key: string, defaultValue?: unknown) => defaultValue),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
scanner = new SecretScannerService(mockConfigService as unknown as ConfigService);
|
||||
});
|
||||
|
||||
describe("Content scanning throughput", () => {
|
||||
it("should scan 1000 lines of clean code in under 50ms", () => {
|
||||
const lines = Array.from(
|
||||
{ length: 1000 },
|
||||
(_, i) => `const value${String(i)} = computeResult(${String(i)}, "param-${String(i)}");`
|
||||
);
|
||||
const content = lines.join("\n");
|
||||
|
||||
const start = performance.now();
|
||||
const result = scanner.scanContent(content, "test-file.ts");
|
||||
const duration = performance.now() - start;
|
||||
|
||||
expect(duration).toBeLessThan(50);
|
||||
expect(result.matches).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should scan 100 files worth of content in under 500ms", () => {
|
||||
const fileContent = Array.from(
|
||||
{ length: 100 },
|
||||
(_, i) => `export function handler${String(i)}(): string { return "result-${String(i)}"; }`
|
||||
).join("\n");
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
scanner.scanContent(fileContent, `file-${String(i)}.ts`);
|
||||
}
|
||||
|
||||
const duration = performance.now() - start;
|
||||
expect(duration).toBeLessThan(500);
|
||||
});
|
||||
|
||||
it("should detect secrets in large content without performance regression", () => {
|
||||
// Mix clean code with embedded secrets
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < 500; i++) {
|
||||
lines.push(`const config${String(i)} = { host: "localhost", port: ${String(3000 + i)} };`);
|
||||
}
|
||||
// Insert a secret at line 250
|
||||
lines[250] = 'const apiKey = "AKIA1234567890ABCDEF"; // AWS access key';
|
||||
|
||||
const content = lines.join("\n");
|
||||
|
||||
const start = performance.now();
|
||||
const result = scanner.scanContent(content, "config.ts");
|
||||
const duration = performance.now() - start;
|
||||
|
||||
expect(duration).toBeLessThan(100);
|
||||
expect(result.matches.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should handle content with many false-positive patterns efficiently", () => {
|
||||
// Content with many patterns that look like secrets but are placeholders
|
||||
const lines = Array.from(
|
||||
{ length: 200 },
|
||||
(_, i) => `const example_key_${String(i)} = "test-xxxx-example-${String(i)}";`
|
||||
);
|
||||
const content = lines.join("\n");
|
||||
|
||||
const start = performance.now();
|
||||
const result = scanner.scanContent(content, "examples.ts");
|
||||
const duration = performance.now() - start;
|
||||
|
||||
expect(duration).toBeLessThan(100);
|
||||
// Placeholders should be whitelisted
|
||||
expect(result.matches).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Pattern matching scalability", () => {
|
||||
it("should maintain consistent scan time regardless of content position", () => {
|
||||
const baseContent = Array.from(
|
||||
{ length: 1000 },
|
||||
(_, i) => `const x${String(i)} = ${String(i)};`
|
||||
);
|
||||
|
||||
// Secret at start
|
||||
const contentStart = ['const key = "AKIA1234567890ABCDEF";', ...baseContent].join("\n");
|
||||
|
||||
// Secret at end
|
||||
const contentEnd = [...baseContent, 'const key = "AKIA1234567890ABCDEF";'].join("\n");
|
||||
|
||||
const startTime1 = performance.now();
|
||||
scanner.scanContent(contentStart, "start.ts");
|
||||
const duration1 = performance.now() - startTime1;
|
||||
|
||||
const startTime2 = performance.now();
|
||||
scanner.scanContent(contentEnd, "end.ts");
|
||||
const duration2 = performance.now() - startTime2;
|
||||
|
||||
// Both should complete quickly
|
||||
expect(duration1).toBeLessThan(100);
|
||||
expect(duration2).toBeLessThan(100);
|
||||
|
||||
// Both should complete within a reasonable ratio (allowing for sub-ms noise)
|
||||
const ratio = Math.max(duration1, duration2) / Math.max(0.1, Math.min(duration1, duration2));
|
||||
expect(ratio).toBeLessThan(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Performance Test: Agent Spawner Throughput
|
||||
*
|
||||
* Benchmarks the spawner service under concurrent load to verify
|
||||
* it meets performance requirements for agent orchestration.
|
||||
*
|
||||
* Covers issue #229 (ORCH-128)
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { AgentSpawnerService } from "../../src/spawner/agent-spawner.service";
|
||||
import { AgentsController } from "../../src/api/agents/agents.controller";
|
||||
import { QueueService } from "../../src/queue/queue.service";
|
||||
import { AgentLifecycleService } from "../../src/spawner/agent-lifecycle.service";
|
||||
import { KillswitchService } from "../../src/killswitch/killswitch.service";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
function createSpawnRequest(taskId: string): {
|
||||
taskId: string;
|
||||
agentType: string;
|
||||
context: { repository: string; branch: string; workItems: string[] };
|
||||
} {
|
||||
return {
|
||||
taskId,
|
||||
agentType: "worker",
|
||||
context: {
|
||||
repository: "https://git.example.com/repo.git",
|
||||
branch: "main",
|
||||
workItems: [`US-${taskId}`],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("Performance: Agent Spawner Throughput", () => {
|
||||
let controller: AgentsController;
|
||||
let spawnerService: AgentSpawnerService;
|
||||
|
||||
const mockConfigService = {
|
||||
get: vi.fn((key: string, defaultValue?: unknown) => {
|
||||
const config: Record<string, unknown> = {
|
||||
"orchestrator.claude.apiKey": "test-api-key",
|
||||
};
|
||||
return config[key] ?? defaultValue;
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
spawnerService = new AgentSpawnerService(mockConfigService as unknown as ConfigService);
|
||||
|
||||
const queueService = {
|
||||
addTask: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as QueueService;
|
||||
|
||||
const lifecycleService = {
|
||||
getAgentLifecycleState: vi.fn(),
|
||||
} as unknown as AgentLifecycleService;
|
||||
|
||||
const killswitchService = {
|
||||
killAgent: vi.fn(),
|
||||
killAllAgents: vi.fn(),
|
||||
} as unknown as KillswitchService;
|
||||
|
||||
controller = new AgentsController(
|
||||
queueService,
|
||||
spawnerService,
|
||||
lifecycleService,
|
||||
killswitchService
|
||||
);
|
||||
});
|
||||
|
||||
describe("Spawn latency", () => {
|
||||
it("should spawn a single agent in under 50ms", async () => {
|
||||
// Warmup
|
||||
await controller.spawn(createSpawnRequest("warmup-1"));
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
await controller.spawn(createSpawnRequest("perf-single-001"));
|
||||
|
||||
const duration = performance.now() - start;
|
||||
expect(duration).toBeLessThan(50);
|
||||
});
|
||||
|
||||
it("should spawn 100 agents sequentially in under 500ms", async () => {
|
||||
// Warmup
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await controller.spawn(createSpawnRequest(`warmup-seq-${String(i)}`));
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await controller.spawn(createSpawnRequest(`perf-seq-${String(i)}`));
|
||||
}
|
||||
|
||||
const duration = performance.now() - start;
|
||||
expect(duration).toBeLessThan(500);
|
||||
|
||||
// 100 sequential + 5 warmup
|
||||
const agents = spawnerService.listAgentSessions();
|
||||
expect(agents.length).toBeGreaterThanOrEqual(100);
|
||||
});
|
||||
|
||||
it("should spawn 100 agents concurrently in under 200ms", async () => {
|
||||
// Warmup
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await controller.spawn(createSpawnRequest(`warmup-conc-${String(i)}`));
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
const promises = Array.from({ length: 100 }, (_, i) =>
|
||||
controller.spawn(createSpawnRequest(`perf-concurrent-${String(i)}`))
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
const duration = performance.now() - start;
|
||||
|
||||
expect(duration).toBeLessThan(200);
|
||||
expect(results).toHaveLength(100);
|
||||
|
||||
// Verify all IDs are unique
|
||||
const ids = new Set(results.map((r) => r.agentId));
|
||||
expect(ids.size).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Session lookup performance", () => {
|
||||
it("should look up agents by ID in under 10ms with 1000 sessions", async () => {
|
||||
// Pre-populate 1000 sessions
|
||||
const agentIds: string[] = [];
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const result = await controller.spawn(createSpawnRequest(`perf-lookup-${String(i)}`));
|
||||
agentIds.push(result.agentId);
|
||||
}
|
||||
|
||||
// Measure lookup time for random agents
|
||||
const lookupStart = performance.now();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const randomIdx = Math.floor(Math.random() * agentIds.length);
|
||||
const session = spawnerService.getAgentSession(agentIds[randomIdx] ?? "");
|
||||
expect(session).toBeDefined();
|
||||
}
|
||||
const lookupDuration = performance.now() - lookupStart;
|
||||
|
||||
// 100 lookups should complete in under 10ms
|
||||
expect(lookupDuration).toBeLessThan(10);
|
||||
});
|
||||
|
||||
it("should list all sessions in under 5ms with 1000 sessions", async () => {
|
||||
// Pre-populate 1000 sessions
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
await controller.spawn(createSpawnRequest(`perf-list-${String(i)}`));
|
||||
}
|
||||
|
||||
const listStart = performance.now();
|
||||
const sessions = spawnerService.listAgentSessions();
|
||||
const listDuration = performance.now() - listStart;
|
||||
|
||||
expect(sessions).toHaveLength(1000);
|
||||
expect(listDuration).toBeLessThan(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Memory efficiency", () => {
|
||||
it("should not have excessive memory growth after 1000 spawns", async () => {
|
||||
// Force GC if available, then settle
|
||||
if (global.gc) global.gc();
|
||||
|
||||
const memBefore = process.memoryUsage().heapUsed;
|
||||
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
await controller.spawn(createSpawnRequest(`perf-mem-${String(i)}`));
|
||||
}
|
||||
|
||||
const memAfter = process.memoryUsage().heapUsed;
|
||||
const memGrowthMB = (memAfter - memBefore) / 1024 / 1024;
|
||||
|
||||
// 1000 agent sessions should use less than 50MB
|
||||
expect(memGrowthMB).toBeLessThan(50);
|
||||
});
|
||||
});
|
||||
});
|
||||
10
apps/orchestrator/tests/performance/vitest.config.ts
Normal file
10
apps/orchestrator/tests/performance/vitest.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["**/*.perf-spec.ts"],
|
||||
testTimeout: 60000,
|
||||
},
|
||||
});
|
||||
@@ -7,76 +7,103 @@ import { Bot, Activity, AlertCircle, CheckCircle, Clock } from "lucide-react";
|
||||
import type { WidgetProps } from "@mosaic/shared";
|
||||
|
||||
interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "IDLE" | "WORKING" | "WAITING" | "ERROR" | "TERMINATED";
|
||||
currentTask?: string;
|
||||
lastHeartbeat: string;
|
||||
taskCount: number;
|
||||
agentId: string;
|
||||
taskId: string;
|
||||
status: string;
|
||||
agentType: string;
|
||||
spawnedAt: string;
|
||||
completedAt?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Mock data for now - will fetch from API later
|
||||
// Fetch agents from orchestrator API
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
setTimeout(() => {
|
||||
setAgents([
|
||||
{
|
||||
id: "1",
|
||||
name: "Code Review Agent",
|
||||
status: "WORKING",
|
||||
currentTask: "Reviewing PR #123",
|
||||
lastHeartbeat: new Date().toISOString(),
|
||||
taskCount: 42,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Documentation Agent",
|
||||
status: "IDLE",
|
||||
lastHeartbeat: new Date().toISOString(),
|
||||
taskCount: 15,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "Test Runner Agent",
|
||||
status: "ERROR",
|
||||
currentTask: "Failed to run tests",
|
||||
lastHeartbeat: new Date(Date.now() - 300000).toISOString(),
|
||||
taskCount: 28,
|
||||
},
|
||||
]);
|
||||
setIsLoading(false);
|
||||
}, 500);
|
||||
const fetchAgents = async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Get orchestrator URL from environment or default to localhost
|
||||
const orchestratorUrl = process.env.NEXT_PUBLIC_ORCHESTRATOR_URL ?? "http://localhost:8001";
|
||||
|
||||
const response = await fetch(`${orchestratorUrl}/agents`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch agents: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Agent[];
|
||||
setAgents(data);
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
||||
console.error("Failed to fetch agents:", errorMessage);
|
||||
setError(errorMessage);
|
||||
setAgents([]); // Clear agents on error
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void fetchAgents();
|
||||
|
||||
// Refresh every 30 seconds
|
||||
const interval = setInterval(() => {
|
||||
void fetchAgents();
|
||||
}, 30000);
|
||||
|
||||
return (): void => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getStatusIcon = (status: Agent["status"]): React.JSX.Element => {
|
||||
switch (status) {
|
||||
case "WORKING":
|
||||
const getStatusIcon = (status: string): React.JSX.Element => {
|
||||
const statusLower = status.toLowerCase();
|
||||
switch (statusLower) {
|
||||
case "running":
|
||||
case "working":
|
||||
return <Activity className="w-4 h-4 text-blue-500 animate-pulse" />;
|
||||
case "IDLE":
|
||||
return <Clock className="w-4 h-4 text-gray-400" />;
|
||||
case "WAITING":
|
||||
case "spawning":
|
||||
case "queued":
|
||||
return <Clock className="w-4 h-4 text-yellow-500" />;
|
||||
case "ERROR":
|
||||
case "completed":
|
||||
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||||
case "failed":
|
||||
case "error":
|
||||
return <AlertCircle className="w-4 h-4 text-red-500" />;
|
||||
case "TERMINATED":
|
||||
case "terminated":
|
||||
case "killed":
|
||||
return <CheckCircle className="w-4 h-4 text-gray-500" />;
|
||||
default:
|
||||
return <Clock className="w-4 h-4 text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: Agent["status"]): string => {
|
||||
const getStatusText = (status: string): string => {
|
||||
return status.charAt(0).toUpperCase() + status.slice(1).toLowerCase();
|
||||
};
|
||||
|
||||
const getTimeSinceLastHeartbeat = (timestamp: string): string => {
|
||||
const getAgentName = (agent: Agent): string => {
|
||||
const typeMap: Record<string, string> = {
|
||||
worker: "Worker Agent",
|
||||
reviewer: "Code Review Agent",
|
||||
tester: "Test Runner Agent",
|
||||
};
|
||||
return typeMap[agent.agentType] ?? `${getStatusText(agent.agentType)} Agent`;
|
||||
};
|
||||
|
||||
const getTimeSinceSpawn = (timestamp: string): string => {
|
||||
const now = new Date();
|
||||
const last = new Date(timestamp);
|
||||
const diffMs = now.getTime() - last.getTime();
|
||||
const spawned = new Date(timestamp);
|
||||
const diffMs = now.getTime() - spawned.getTime();
|
||||
|
||||
if (diffMs < 60000) return "Just now";
|
||||
if (diffMs < 3600000) return `${String(Math.floor(diffMs / 60000))}m ago`;
|
||||
@@ -86,9 +113,9 @@ export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): Re
|
||||
|
||||
const stats = {
|
||||
total: agents.length,
|
||||
working: agents.filter((a) => a.status === "WORKING").length,
|
||||
idle: agents.filter((a) => a.status === "IDLE").length,
|
||||
error: agents.filter((a) => a.status === "ERROR").length,
|
||||
working: agents.filter((a) => a.status.toLowerCase() === "running").length,
|
||||
idle: agents.filter((a) => a.status.toLowerCase() === "spawning").length,
|
||||
error: agents.filter((a) => a.status.toLowerCase() === "failed").length,
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
@@ -99,6 +126,17 @@ export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): Re
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-red-500 text-sm">
|
||||
<AlertCircle className="w-4 h-4 inline mr-1" />
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full space-y-3">
|
||||
{/* Summary stats */}
|
||||
@@ -124,15 +162,15 @@ export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): Re
|
||||
{/* Agent list */}
|
||||
<div className="flex-1 overflow-auto space-y-2">
|
||||
{agents.length === 0 ? (
|
||||
<div className="text-center text-gray-500 text-sm py-4">No agents configured</div>
|
||||
<div className="text-center text-gray-500 text-sm py-4">No agents running</div>
|
||||
) : (
|
||||
agents.map((agent) => (
|
||||
<div
|
||||
key={agent.id}
|
||||
key={agent.agentId}
|
||||
className={`p-3 rounded-lg border ${
|
||||
agent.status === "ERROR"
|
||||
agent.status.toLowerCase() === "failed"
|
||||
? "bg-red-50 border-red-200"
|
||||
: agent.status === "WORKING"
|
||||
: agent.status.toLowerCase() === "running"
|
||||
? "bg-blue-50 border-blue-200"
|
||||
: "bg-gray-50 border-gray-200"
|
||||
}`}
|
||||
@@ -140,7 +178,7 @@ export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): Re
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="w-4 h-4 text-gray-600" />
|
||||
<span className="text-sm font-medium text-gray-900">{agent.name}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{getAgentName(agent)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-gray-500">
|
||||
{getStatusIcon(agent.status)}
|
||||
@@ -148,13 +186,13 @@ export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): Re
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{agent.currentTask && (
|
||||
<div className="text-xs text-gray-600 mb-1">{agent.currentTask}</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-600 mb-1">Task: {agent.taskId}</div>
|
||||
|
||||
{agent.error && <div className="text-xs text-red-600 mb-1">{agent.error}</div>}
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-gray-400">
|
||||
<span>{agent.taskCount} tasks completed</span>
|
||||
<span>{getTimeSinceLastHeartbeat(agent.lastHeartbeat)}</span>
|
||||
<span>Agent ID: {agent.agentId.slice(0, 8)}...</span>
|
||||
<span>{getTimeSinceSpawn(agent.spawnedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { AgentStatusWidget } from "../AgentStatusWidget";
|
||||
|
||||
describe("AgentStatusWidget", () => {
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
global.fetch = mockFetch as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render loading state initially", () => {
|
||||
mockFetch.mockImplementation(
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
() => new Promise(() => {}) // Never resolves
|
||||
);
|
||||
|
||||
render(<AgentStatusWidget id="test-widget" config={{}} />);
|
||||
|
||||
expect(screen.getByText("Loading agents...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should fetch and display agents from API", async () => {
|
||||
const mockAgents = [
|
||||
{
|
||||
agentId: "agent-1",
|
||||
taskId: "task-1",
|
||||
status: "running",
|
||||
agentType: "worker",
|
||||
spawnedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
agentId: "agent-2",
|
||||
taskId: "task-2",
|
||||
status: "completed",
|
||||
agentType: "reviewer",
|
||||
spawnedAt: new Date().toISOString(),
|
||||
completedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockAgents),
|
||||
});
|
||||
|
||||
render(<AgentStatusWidget id="test-widget" config={{}} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Worker Agent")).toBeInTheDocument();
|
||||
expect(screen.getByText("Code Review Agent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Task: task-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Task: task-2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display error message when fetch fails", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
statusText: "Internal Server Error",
|
||||
});
|
||||
|
||||
render(<AgentStatusWidget id="test-widget" config={{}} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Failed to fetch agents: Internal Server Error/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display no agents message when list is empty", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
});
|
||||
|
||||
render(<AgentStatusWidget id="test-widget" config={{}} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No agents running")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display agent error messages", async () => {
|
||||
const mockAgents = [
|
||||
{
|
||||
agentId: "agent-1",
|
||||
taskId: "task-1",
|
||||
status: "failed",
|
||||
agentType: "tester",
|
||||
spawnedAt: new Date().toISOString(),
|
||||
error: "Test execution failed",
|
||||
},
|
||||
];
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockAgents),
|
||||
});
|
||||
|
||||
render(<AgentStatusWidget id="test-widget" config={{}} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test execution failed")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display correct stats summary", async () => {
|
||||
const mockAgents = [
|
||||
{
|
||||
agentId: "agent-1",
|
||||
taskId: "task-1",
|
||||
status: "running",
|
||||
agentType: "worker",
|
||||
spawnedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
agentId: "agent-2",
|
||||
taskId: "task-2",
|
||||
status: "running",
|
||||
agentType: "reviewer",
|
||||
spawnedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
agentId: "agent-3",
|
||||
taskId: "task-3",
|
||||
status: "failed",
|
||||
agentType: "tester",
|
||||
spawnedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockAgents),
|
||||
});
|
||||
|
||||
render(<AgentStatusWidget id="test-widget" config={{}} />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Check stats: 3 total, 2 working, 0 idle, 1 error
|
||||
const stats = screen.getAllByText(/^[0-9]+$/);
|
||||
expect(stats[0]).toHaveTextContent("3"); // Total
|
||||
expect(stats[1]).toHaveTextContent("2"); // Working
|
||||
expect(stats[2]).toHaveTextContent("0"); // Idle
|
||||
expect(stats[3]).toHaveTextContent("1"); // Error
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/orchestrator/src/api/agents/agents.controller.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-05 12:25: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-orchestrator-src-api-agents-agents.controller.spec.ts_20260205-1225_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/orchestrator/src/api/agents/agents.controller.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 2
|
||||
**Generated:** 2026-02-05 12:25: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-orchestrator-src-api-agents-agents.controller.spec.ts_20260205-1225_2_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/orchestrator/src/api/agents/agents.controller.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 3
|
||||
**Generated:** 2026-02-05 12:25: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-orchestrator-src-api-agents-agents.controller.spec.ts_20260205-1225_3_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/orchestrator/src/api/agents/agents.controller.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-05 12:27: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-orchestrator-src-api-agents-agents.controller.spec.ts_20260205-1227_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/orchestrator/src/api/agents/agents.controller.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-05 12:28: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-orchestrator-src-api-agents-agents.controller.spec.ts_20260205-1228_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/orchestrator/src/api/agents/agents.controller.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 2
|
||||
**Generated:** 2026-02-05 12:28: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-orchestrator-src-api-agents-agents.controller.spec.ts_20260205-1228_2_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/orchestrator/src/api/agents/agents.controller.spec.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 3
|
||||
**Generated:** 2026-02-05 12:28: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-orchestrator-src-api-agents-agents.controller.spec.ts_20260205-1228_3_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/orchestrator/src/api/agents/agents.controller.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-05 12:25: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-orchestrator-src-api-agents-agents.controller.ts_20260205-1225_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/orchestrator/src/api/agents/agents.controller.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-05 12:27: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-orchestrator-src-api-agents-agents.controller.ts_20260205-1227_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/orchestrator/src/api/agents/agents.controller.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 2
|
||||
**Generated:** 2026-02-05 12:27:27
|
||||
|
||||
## 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-orchestrator-src-api-agents-agents.controller.ts_20260205-1227_2_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/orchestrator/src/api/agents/agents.controller.ts
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-05 12:28: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-orchestrator-src-api-agents-agents.controller.ts_20260205-1228_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/web/src/components/widgets/AgentStatusWidget.tsx
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-05 12:26: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-web-src-components-widgets-AgentStatusWidget.tsx_20260205-1226_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/web/src/components/widgets/AgentStatusWidget.tsx
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 2
|
||||
**Generated:** 2026-02-05 12:26:34
|
||||
|
||||
## 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-web-src-components-widgets-AgentStatusWidget.tsx_20260205-1226_2_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/web/src/components/widgets/AgentStatusWidget.tsx
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 3
|
||||
**Generated:** 2026-02-05 12:26:36
|
||||
|
||||
## 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-web-src-components-widgets-AgentStatusWidget.tsx_20260205-1226_3_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/web/src/components/widgets/AgentStatusWidget.tsx
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 4
|
||||
**Generated:** 2026-02-05 12:26:46
|
||||
|
||||
## 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-web-src-components-widgets-AgentStatusWidget.tsx_20260205-1226_4_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/web/src/components/widgets/AgentStatusWidget.tsx
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-05 12:27: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-web-src-components-widgets-AgentStatusWidget.tsx_20260205-1227_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/web/src/components/widgets/AgentStatusWidget.tsx
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-05 12:29:51
|
||||
|
||||
## 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-web-src-components-widgets-AgentStatusWidget.tsx_20260205-1229_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/web/src/components/widgets/**tests**/AgentStatusWidget.test.tsx
|
||||
**Tool Used:** Write
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-05 12:27: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-web-src-components-widgets-__tests__-AgentStatusWidget.test.tsx_20260205-1227_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/web/src/components/widgets/**tests**/AgentStatusWidget.test.tsx
|
||||
**Tool Used:** Write
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-05 12:30: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-web-src-components-widgets-__tests__-AgentStatusWidget.test.tsx_20260205-1230_1_remediation_needed.md"
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
# QA Remediation Report
|
||||
|
||||
**File:** /home/localadmin/src/mosaic-stack/apps/web/src/components/widgets/**tests**/AgentStatusWidget.test.tsx
|
||||
**Tool Used:** Edit
|
||||
**Epic:** general
|
||||
**Iteration:** 1
|
||||
**Generated:** 2026-02-05 12:31: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-web-src-components-widgets-__tests__-AgentStatusWidget.test.tsx_20260205-1231_1_remediation_needed.md"
|
||||
```
|
||||
Reference in New Issue
Block a user