import { Injectable, Logger } from "@nestjs/common"; import { PrismaService } from "../prisma/prisma.service"; import { BullMqService } from "../bullmq/bullmq.service"; import { QUEUE_NAMES } from "../bullmq/queues"; import type { JobDispatchContext, JobDispatchResult, GuardRailsResult, QualityRailsResult, } from "./interfaces"; import type { WebhookPayloadDto } from "./dto"; /** * StitcherService - 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 */ @Injectable() export class StitcherService { private readonly logger = new Logger(StitcherService.name); constructor( private readonly prisma: PrismaService, private readonly bullMq: BullMqService ) {} /** * Handle webhook from @mosaic bot */ async handleWebhook(payload: WebhookPayloadDto): Promise { this.logger.log( `Webhook received: ${payload.action} on ${payload.repository}#${payload.issueNumber}` ); // For now, create a simple job dispatch context // In the future, this will query workspace info and determine job type const context: JobDispatchContext = { workspaceId: "default-workspace", // TODO: Determine from repository type: "code-task", priority: 10, metadata: { issueNumber: payload.issueNumber, repository: payload.repository, action: payload.action, comment: payload.comment, }, }; return this.dispatchJob(context); } /** * Dispatch a job to the queue with guard rails and quality rails applied */ async dispatchJob(context: JobDispatchContext): Promise { const { workspaceId, type, priority = 5, metadata } = context; this.logger.log(`Dispatching job: ${type} for workspace ${workspaceId}`); // Create RunnerJob in database const job = await this.prisma.runnerJob.create({ data: { workspaceId, type, priority, status: "PENDING", progressPercent: 0, }, }); // Log job creation event await this.trackJobEvent(job.id, "job.created", "stitcher", { type, priority, metadata, }); // Dispatch to BullMQ queue await this.bullMq.addJob( QUEUE_NAMES.MAIN, type, { jobId: job.id, workspaceId, type, metadata, }, { priority, } ); // Log job queued event await this.trackJobEvent(job.id, "job.queued", "stitcher", { queueName: QUEUE_NAMES.MAIN, }); this.logger.log(`Job ${job.id} dispatched to ${QUEUE_NAMES.MAIN}`); return { jobId: job.id, queueName: QUEUE_NAMES.MAIN, status: job.status, }; } /** * Apply Guard Rails - capability permission check */ applyGuardRails(agentProfile: string, capabilities: string[]): GuardRailsResult { // Define allowed capabilities per agent profile const allowedCapabilities: Record = { runner: ["read", "fetch", "query"], weaver: ["read", "write", "commit"], inspector: ["read", "validate", "gate"], herald: ["read", "report", "notify"], }; const allowed = allowedCapabilities[agentProfile] ?? []; const hasPermission = capabilities.every((cap) => allowed.includes(cap)); if (hasPermission) { return { allowed: true, }; } const requiredCap = capabilities.find((cap) => !allowed.includes(cap)); const result: GuardRailsResult = { allowed: false, reason: `Profile ${agentProfile} not allowed capabilities: ${capabilities.join(", ")}`, }; if (requiredCap !== undefined) { result.requiredCapability = requiredCap; } return result; } /** * Apply Quality Rails - determine mandatory gates for job type */ applyQualityRails(jobType: string): QualityRailsResult { // Code tasks require full quality gates if (jobType === "code-task") { return { required: true, gates: ["lint", "typecheck", "test", "coverage"], }; } // Read-only tasks don't require gates if (jobType === "git-status" || jobType === "priority-calc") { return { required: false, gates: [], skipReason: "Read-only task - no quality gates required", }; } // Default: basic gates return { required: true, gates: ["lint", "typecheck"], }; } /** * Track job event in database */ async trackJobEvent( jobId: string, type: string, actor: string, payload: Record ): Promise { await this.prisma.jobEvent.create({ data: { jobId, type, actor, timestamp: new Date(), payload: payload as object, }, }); } }