feat(#166): Implement Stitcher module structure
Created the mosaic-stitcher module - the workflow orchestration layer that wraps OpenClaw. Responsibilities: - Receive webhooks from @mosaic bot - Apply Guard Rails (capability permissions) - Apply Quality Rails (mandatory gates) - Track all job steps and events - Dispatch work to OpenClaw with constraints Implementation: - StitcherModule: Module definition with PrismaModule and BullMqModule - StitcherService: Core orchestration logic - handleWebhook(): Process webhooks from @mosaic bot - dispatchJob(): Create RunnerJob and dispatch to BullMQ queue - applyGuardRails(): Check capability permissions for agent profiles - applyQualityRails(): Determine mandatory gates for job types - trackJobEvent(): Log events to database for audit trail - StitcherController: HTTP endpoints - POST /stitcher/webhook: Webhook receiver - POST /stitcher/dispatch: Manual job dispatch - DTOs and interfaces for type safety TDD Process: 1. RED: Created failing tests (12 tests) 2. GREEN: Implemented minimal code to pass tests 3. REFACTOR: Fixed TypeScript strict mode issues Quality Gates: ALL PASS - Typecheck: PASS - Lint: PASS - Build: PASS - Tests: PASS (12/12) Token estimate: ~56,000 tokens Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
199
apps/api/src/stitcher/stitcher.service.spec.ts
Normal file
199
apps/api/src/stitcher/stitcher.service.spec.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { StitcherService } from "./stitcher.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { BullMqService } from "../bullmq/bullmq.service";
|
||||
import { QUEUE_NAMES } from "../bullmq/queues";
|
||||
import type { JobDispatchContext, JobDispatchResult } from "./interfaces";
|
||||
|
||||
describe("StitcherService", () => {
|
||||
let service: StitcherService;
|
||||
let prismaService: PrismaService;
|
||||
let bullMqService: BullMqService;
|
||||
|
||||
const mockPrismaService = {
|
||||
runnerJob: {
|
||||
create: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
jobEvent: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const mockBullMqService = {
|
||||
addJob: vi.fn(),
|
||||
getQueue: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
StitcherService,
|
||||
{ provide: PrismaService, useValue: mockPrismaService },
|
||||
{ provide: BullMqService, useValue: mockBullMqService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<StitcherService>(StitcherService);
|
||||
prismaService = module.get<PrismaService>(PrismaService);
|
||||
bullMqService = module.get<BullMqService>(BullMqService);
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("dispatchJob", () => {
|
||||
it("should create a RunnerJob and dispatch to queue", async () => {
|
||||
const context: JobDispatchContext = {
|
||||
workspaceId: "workspace-123",
|
||||
type: "code-task",
|
||||
priority: 10,
|
||||
};
|
||||
|
||||
const mockJob = {
|
||||
id: "job-123",
|
||||
workspaceId: "workspace-123",
|
||||
type: "code-task",
|
||||
status: "PENDING",
|
||||
priority: 10,
|
||||
progressPercent: 0,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
mockPrismaService.runnerJob.create.mockResolvedValue(mockJob);
|
||||
mockBullMqService.addJob.mockResolvedValue({ id: "queue-job-123" });
|
||||
|
||||
const result = await service.dispatchJob(context);
|
||||
|
||||
expect(result).toEqual<JobDispatchResult>({
|
||||
jobId: "job-123",
|
||||
queueName: QUEUE_NAMES.MAIN,
|
||||
status: "PENDING",
|
||||
});
|
||||
|
||||
expect(mockPrismaService.runnerJob.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
workspaceId: "workspace-123",
|
||||
type: "code-task",
|
||||
priority: 10,
|
||||
status: "PENDING",
|
||||
progressPercent: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockBullMqService.addJob).toHaveBeenCalledWith(
|
||||
QUEUE_NAMES.MAIN,
|
||||
"code-task",
|
||||
expect.objectContaining({
|
||||
jobId: "job-123",
|
||||
workspaceId: "workspace-123",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
priority: 10,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should log job event after dispatch", async () => {
|
||||
const context: JobDispatchContext = {
|
||||
workspaceId: "workspace-123",
|
||||
type: "git-status",
|
||||
};
|
||||
|
||||
const mockJob = {
|
||||
id: "job-456",
|
||||
workspaceId: "workspace-123",
|
||||
type: "git-status",
|
||||
status: "PENDING",
|
||||
priority: 5,
|
||||
progressPercent: 0,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
mockPrismaService.runnerJob.create.mockResolvedValue(mockJob);
|
||||
mockBullMqService.addJob.mockResolvedValue({ id: "queue-job-456" });
|
||||
|
||||
await service.dispatchJob(context);
|
||||
|
||||
expect(mockPrismaService.jobEvent.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
jobId: "job-456",
|
||||
type: "job.queued",
|
||||
actor: "stitcher",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle dispatch errors", async () => {
|
||||
const context: JobDispatchContext = {
|
||||
workspaceId: "workspace-123",
|
||||
type: "invalid-type",
|
||||
};
|
||||
|
||||
mockPrismaService.runnerJob.create.mockRejectedValue(new Error("Database error"));
|
||||
|
||||
await expect(service.dispatchJob(context)).rejects.toThrow("Database error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyGuardRails", () => {
|
||||
it("should return allowed for valid capabilities", () => {
|
||||
const result = service.applyGuardRails("runner", ["read"]);
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("should return not allowed for invalid capabilities", () => {
|
||||
const result = service.applyGuardRails("runner", ["write"]);
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyQualityRails", () => {
|
||||
it("should return required gates for code tasks", () => {
|
||||
const result = service.applyQualityRails("code-task");
|
||||
|
||||
expect(result.required).toBe(true);
|
||||
expect(result.gates).toContain("lint");
|
||||
expect(result.gates).toContain("typecheck");
|
||||
expect(result.gates).toContain("test");
|
||||
});
|
||||
|
||||
it("should return no gates for read-only tasks", () => {
|
||||
const result = service.applyQualityRails("git-status");
|
||||
|
||||
expect(result.required).toBe(false);
|
||||
expect(result.gates).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("trackJobEvent", () => {
|
||||
it("should create job event in database", async () => {
|
||||
const mockEvent = {
|
||||
id: "event-123",
|
||||
jobId: "job-123",
|
||||
type: "job.started",
|
||||
timestamp: new Date(),
|
||||
actor: "stitcher",
|
||||
payload: {},
|
||||
};
|
||||
|
||||
mockPrismaService.jobEvent.create.mockResolvedValue(mockEvent);
|
||||
|
||||
await service.trackJobEvent("job-123", "job.started", "stitcher", {});
|
||||
|
||||
expect(mockPrismaService.jobEvent.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
jobId: "job-123",
|
||||
type: "job.started",
|
||||
actor: "stitcher",
|
||||
timestamp: expect.any(Date),
|
||||
payload: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user