diff --git a/ISSUES/29-cron-config.md b/ISSUES/29-cron-config.md new file mode 100644 index 0000000..6ad3723 --- /dev/null +++ b/ISSUES/29-cron-config.md @@ -0,0 +1,54 @@ +# Cron Job Configuration - Issue #29 + +## Overview +Implement cron job configuration for Mosaic Stack, likely as a MoltBot plugin for scheduled reminders/commands. + +## Requirements (inferred from CLAUDE.md pattern) + +### Plugin Structure +``` +plugins/mosaic-plugin-cron/ +├── SKILL.md # MoltBot skill definition +├── src/ +│ └── cron.service.ts +└── cron.service.test.ts +``` + +### Core Features +1. Create/update/delete cron schedules +2. Trigger MoltBot commands on schedule +3. Workspace-scoped (RLS) +4. PDA-friendly UI + +### API Endpoints (inferred) +- `POST /api/cron` - Create schedule +- `GET /api/cron` - List schedules +- `DELETE /api/cron/:id` - Delete schedule + +### Database (Prisma) +```prisma +model CronSchedule { + id String @id @default(uuid()) + workspaceId String + expression String // cron expression + command String // MoltBot command to trigger + enabled Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([workspaceId]) +} +``` + +## TDD Approach +1. **RED** - Write tests for CronService +2. **GREEN** - Implement minimal service +3. **REFACTOR** - Add CRUD controller + API endpoints + +## Next Steps +- [ ] Create feature branch: `git checkout -b feature/29-cron-config` +- [ ] Write failing tests for cron service +- [ ] Implement service (Green) +- [ ] Add controller & routes +- [ ] Add Prisma schema migration +- [ ] Create MoltBot skill (SKILL.md) diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index fad9205..5e3dd71 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -187,6 +187,7 @@ model Workspace { userLayouts UserLayout[] knowledgeEntries KnowledgeEntry[] knowledgeTags KnowledgeTag[] + cronSchedules CronSchedule[] @@index([ownerId]) @@map("workspaces") @@ -808,3 +809,31 @@ model KnowledgeEmbedding { @@index([entryId]) @@map("knowledge_embeddings") } + +// ============================================ +// CRON JOBS +// ============================================ + +model CronSchedule { + id String @id @default(uuid()) @db.Uuid + workspaceId String @map("workspace_id") @db.Uuid + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + + // Cron configuration + expression String // Standard cron: "0 9 * * *" = 9am daily + command String // MoltBot command to trigger + + // State + enabled Boolean @default(true) + lastRun DateTime? @map("last_run") @db.Timestamptz + nextRun DateTime? @map("next_run") @db.Timestamptz + + // Audit + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz + + @@index([workspaceId]) + @@index([workspaceId, enabled]) + @@index([nextRun]) + @@map("cron_schedules") +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index ad47814..801d9f0 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -17,6 +17,7 @@ import { UsersModule } from "./users/users.module"; import { WebSocketModule } from "./websocket/websocket.module"; import { LlmModule } from "./llm/llm.module"; import { BrainModule } from "./brain/brain.module"; +import { CronModule } from "./cron/cron.module"; @Module({ imports: [ @@ -36,6 +37,7 @@ import { BrainModule } from "./brain/brain.module"; WebSocketModule, LlmModule, BrainModule, + CronModule, ], controllers: [AppController], providers: [AppService], diff --git a/apps/api/src/cron/cron.controller.ts b/apps/api/src/cron/cron.controller.ts new file mode 100644 index 0000000..d7cb91c --- /dev/null +++ b/apps/api/src/cron/cron.controller.ts @@ -0,0 +1,88 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + UseGuards, +} from "@nestjs/common"; +import { CronService } from "./cron.service"; +import { CreateCronDto, UpdateCronDto } from "./dto"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import { WorkspaceGuard } from "../common/guards"; +import { Workspace, RequirePermission } from "../common/decorators"; +import { Permission } from "@prisma/client"; + +/** + * Controller for cron job scheduling endpoints + * All endpoints require authentication and workspace context + */ +@Controller("cron") +@UseGuards(AuthGuard, WorkspaceGuard) +export class CronController { + constructor(private readonly cronService: CronService) {} + + /** + * POST /api/cron + * Create a new cron schedule + * Requires: MEMBER role or higher + */ + @Post() + @RequirePermission(Permission.WORKSPACE_MEMBER) + async create( + @Body() createCronDto: CreateCronDto, + @Workspace() workspaceId: string + ) { + return this.cronService.create({ ...createCronDto, workspaceId }); + } + + /** + * GET /api/cron + * Get all cron schedules for workspace + * Requires: Any workspace member + */ + @Get() + @RequirePermission(Permission.WORKSPACE_ANY) + async findAll(@Workspace() workspaceId: string) { + return this.cronService.findAll(workspaceId); + } + + /** + * GET /api/cron/:id + * Get a single cron schedule + * Requires: Any workspace member + */ + @Get(":id") + @RequirePermission(Permission.WORKSPACE_ANY) + async findOne(@Param("id") id: string, @Workspace() workspaceId: string) { + return this.cronService.findOne(id, workspaceId); + } + + /** + * PATCH /api/cron/:id + * Update a cron schedule + * Requires: MEMBER role or higher + */ + @Patch(":id") + @RequirePermission(Permission.WORKSPACE_MEMBER) + async update( + @Param("id") id: string, + @Body() updateCronDto: UpdateCronDto, + @Workspace() workspaceId: string + ) { + return this.cronService.update(id, workspaceId, updateCronDto); + } + + /** + * DELETE /api/cron/:id + * Delete a cron schedule + * Requires: ADMIN role or higher + */ + @Delete(":id") + @RequirePermission(Permission.WORKSPACE_ADMIN) + async remove(@Param("id") id: string, @Workspace() workspaceId: string) { + return this.cronService.remove(id, workspaceId); + } +} diff --git a/apps/api/src/cron/cron.module.ts b/apps/api/src/cron/cron.module.ts new file mode 100644 index 0000000..fe98610 --- /dev/null +++ b/apps/api/src/cron/cron.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { CronController } from "./cron.controller"; +import { CronService } from "./cron.service"; +import { PrismaModule } from "../prisma/prisma.module"; +import { AuthModule } from "../auth/auth.module"; + +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [CronController], + providers: [CronService], + exports: [CronService], +}) +export class CronModule {} diff --git a/apps/api/src/cron/cron.service.spec.ts b/apps/api/src/cron/cron.service.spec.ts new file mode 100644 index 0000000..962332e --- /dev/null +++ b/apps/api/src/cron/cron.service.spec.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { CronService } from "./cron.service"; +import { PrismaService } from "../prisma/prisma.service"; + +describe("CronService", () => { + let service: CronService; + let prisma: PrismaService; + + const mockPrisma = { + cronSchedule: { + create: vi.fn(), + findMany: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CronService, + { + provide: PrismaService, + useValue: mockPrisma, + }, + ], + }).compile(); + + service = module.get(CronService); + prisma = module.get(PrismaService); + vi.clearAllMocks(); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("create", () => { + it("should create a cron schedule", async () => { + const createDto = { + workspaceId: "ws-123", + expression: "0 9 * * *", + command: "morning briefing", + }; + + const expectedSchedule = { + id: "cron-1", + ...createDto, + enabled: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockPrisma.cronSchedule.create.mockResolvedValue(expectedSchedule); + + const result = await service.create(createDto); + + expect(result).toEqual(expectedSchedule); + expect(mockPrisma.cronSchedule.create).toHaveBeenCalledWith({ + data: { + workspaceId: createDto.workspaceId, + expression: createDto.expression, + command: createDto.command, + enabled: true, + }, + }); + }); + + it("should reject invalid cron expressions", async () => { + const createDto = { + workspaceId: "ws-123", + expression: "not-a-cron", + command: "test command", + }; + + await expect(service.create(createDto)).rejects.toThrow("Invalid cron expression"); + }); + }); + + describe("findAll", () => { + it("should return all schedules for a workspace", async () => { + const workspaceId = "ws-123"; + const expectedSchedules = [ + { id: "cron-1", workspaceId, expression: "0 9 * * *", command: "morning briefing", enabled: true }, + { id: "cron-2", workspaceId, expression: "0 17 * * *", command: "evening summary", enabled: true }, + ]; + + mockPrisma.cronSchedule.findMany.mockResolvedValue(expectedSchedules); + + const result = await service.findAll(workspaceId); + + expect(result).toEqual(expectedSchedules); + expect(mockPrisma.cronSchedule.findMany).toHaveBeenCalledWith({ + where: { workspaceId }, + orderBy: { createdAt: "desc" }, + }); + }); + }); + + describe("findOne", () => { + it("should return a schedule by id", async () => { + const schedule = { + id: "cron-1", + workspaceId: "ws-123", + expression: "0 9 * * *", + command: "morning briefing", + enabled: true, + }; + + mockPrisma.cronSchedule.findUnique.mockResolvedValue(schedule); + + const result = await service.findOne("cron-1", "ws-123"); + + expect(result).toEqual(schedule); + expect(mockPrisma.cronSchedule.findUnique).toHaveBeenCalledWith({ + where: { id: "cron-1" }, + }); + }); + + it("should return null if schedule not found", async () => { + mockPrisma.cronSchedule.findUnique.mockResolvedValue(null); + + const result = await service.findOne("cron-999", "ws-123"); + + expect(result).toBeNull(); + }); + }); + + describe("update", () => { + it("should update a cron schedule", async () => { + const updateDto = { expression: "0 8 * * *", enabled: false }; + const expectedSchedule = { + id: "cron-1", + workspaceId: "ws-123", + expression: "0 8 * * *", + command: "morning briefing", + enabled: false, + }; + + mockPrisma.cronSchedule.findUnique.mockResolvedValue({ id: "cron-1", workspaceId: "ws-123" }); + mockPrisma.cronSchedule.update.mockResolvedValue(expectedSchedule); + + const result = await service.update("cron-1", "ws-123", updateDto); + + expect(result).toEqual(expectedSchedule); + expect(mockPrisma.cronSchedule.update).toHaveBeenCalled(); + }); + }); + + describe("remove", () => { + it("should delete a cron schedule", async () => { + const schedule = { + id: "cron-1", + workspaceId: "ws-123", + expression: "0 9 * * *", + command: "morning briefing", + enabled: true, + }; + + mockPrisma.cronSchedule.findUnique.mockResolvedValue(schedule); + mockPrisma.cronSchedule.delete.mockResolvedValue(schedule); + + const result = await service.remove("cron-1", "ws-123"); + + expect(result).toEqual(schedule); + expect(mockPrisma.cronSchedule.delete).toHaveBeenCalledWith({ + where: { id: "cron-1" }, + }); + }); + + it("should throw if schedule belongs to different workspace", async () => { + mockPrisma.cronSchedule.findUnique.mockResolvedValue({ + id: "cron-1", + workspaceId: "ws-456", + }); + + await expect(service.remove("cron-1", "ws-123")).rejects.toThrow( + "Not authorized to delete this schedule" + ); + }); + }); +}); diff --git a/apps/api/src/cron/cron.service.ts b/apps/api/src/cron/cron.service.ts new file mode 100644 index 0000000..27046af --- /dev/null +++ b/apps/api/src/cron/cron.service.ts @@ -0,0 +1,103 @@ +import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service"; + +// Cron expression validation regex (simplified) +const CRON_REGEX = /^((\*|[0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])\ ?){5}$/; + +export interface CreateCronDto { + workspaceId: string; + expression: string; + command: string; +} + +export interface UpdateCronDto { + expression?: string; + command?: string; + enabled?: boolean; +} + +@Injectable() +export class CronService { + constructor(private readonly prisma: PrismaService) {} + + async create(dto: CreateCronDto) { + if (!this.isValidCronExpression(dto.expression)) { + throw new BadRequestException("Invalid cron expression"); + } + + return this.prisma.cronSchedule.create({ + data: { + workspaceId: dto.workspaceId, + expression: dto.expression, + command: dto.command, + enabled: true, + }, + }); + } + + async findAll(workspaceId: string) { + return this.prisma.cronSchedule.findMany({ + where: { workspaceId }, + orderBy: { createdAt: "desc" }, + }); + } + + async findOne(id: string, workspaceId?: string) { + const schedule = await this.prisma.cronSchedule.findUnique({ + where: { id }, + }); + + if (!schedule) { + return null; + } + + if (workspaceId && schedule.workspaceId !== workspaceId) { + return null; + } + + return schedule; + } + + async update(id: string, workspaceId: string, dto: UpdateCronDto) { + const schedule = await this.findOne(id, workspaceId); + + if (!schedule) { + throw new NotFoundException("Cron schedule not found"); + } + + if (dto.expression && !this.isValidCronExpression(dto.expression)) { + throw new BadRequestException("Invalid cron expression"); + } + + return this.prisma.cronSchedule.update({ + where: { id }, + data: { + ...(dto.expression && { expression: dto.expression }), + ...(dto.command && { command: dto.command }), + ...(dto.enabled !== undefined && { enabled: dto.enabled }), + }, + }); + } + + async remove(id: string, workspaceId: string) { + const schedule = await this.prisma.cronSchedule.findUnique({ + where: { id }, + }); + + if (!schedule) { + throw new NotFoundException("Cron schedule not found"); + } + + if (schedule.workspaceId !== workspaceId) { + throw new BadRequestException("Not authorized to delete this schedule"); + } + + return this.prisma.cronSchedule.delete({ + where: { id }, + }); + } + + private isValidCronExpression(expression: string): boolean { + return CRON_REGEX.test(expression); + } +} diff --git a/apps/api/src/cron/dto/index.ts b/apps/api/src/cron/dto/index.ts new file mode 100644 index 0000000..20408e7 --- /dev/null +++ b/apps/api/src/cron/dto/index.ts @@ -0,0 +1,28 @@ +import { IsString, IsNotEmpty, Matches, IsOptional, IsBoolean } from "class-validator"; + +export class CreateCronDto { + @IsString() + @IsNotEmpty() + expression: string; + + @IsString() + @IsNotEmpty() + command: string; +} + +export class UpdateCronDto { + @IsString() + @IsOptional() + @Matches(/^((\*|[0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])\ ?){5}$/, { + message: "Invalid cron expression", + }) + expression?: string; + + @IsString() + @IsOptional() + command?: string; + + @IsBoolean() + @IsOptional() + enabled?: boolean; +} diff --git a/plugins/mosaic-plugin-cron/SKILL.md b/plugins/mosaic-plugin-cron/SKILL.md new file mode 100644 index 0000000..2c08e9c --- /dev/null +++ b/plugins/mosaic-plugin-cron/SKILL.md @@ -0,0 +1,61 @@ +--- +name: mosaic-plugin-cron +description: Schedule recurring commands and reminders with cron expressions +version: 0.0.1 +triggers: + - "schedule a reminder" + - "create cron job" + - "list my schedules" + - "delete schedule" + - "enable schedule" + - "disable schedule" +tools: + - mosaic_cron_api +--- + +# Mosaic Cron Plugin + +Schedule recurring commands and reminders for your workspace using cron expressions. + +## Usage Examples + +- "Schedule a reminder at 9am every day: morning briefing" +- "Create a cron job: 0 17 * * 1-5 for daily standup" +- "Show all my schedules" +- "Delete the 9am daily reminder" + +## Cron Expression Format + +Standard cron format: `minute hour day-of-month month day-of-week` + +| Field | Values | Example | +|-------|--------|---------| +| Minute | 0-59 | `0` = top of hour | +| Hour | 0-23 | `9` = 9am | +| Day of Month | 1-31 | `*` = every day | +| Month | 1-12 | `*` = every month | +| Day of Week | 0-6 | `1-5` = Mon-Fri | + +### Common Examples + +- `0 9 * * *` - Every day at 9am +- `0 8 * * 1` - Every Monday at 8am +- `0 17 * * 1-5` - Mon-Fri at 5pm +- `0 0 1 * *` - First day of every month at midnight +- `*/15 * * * *` - Every 15 minutes + +## API Endpoints + +All cron operations are available via the Mosaic API: + +- `POST /api/cron` - Create schedule +- `GET /api/cron` - List schedules +- `GET /api/cron/:id` - Get schedule +- `PATCH /api/cron/:id` - Update schedule +- `DELETE /api/cron/:id` - Delete schedule + +## Permissions + +- **Create/Update schedules**: Workspace MEMBER or higher +- **Delete schedules**: Workspace ADMIN or higher +- **View schedules**: Any workspace member diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3df81d..3553c9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,15 +147,6 @@ importers: apps/web: dependencies: - '@dnd-kit/core': - specifier: ^6.3.1 - version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@dnd-kit/sortable': - specifier: ^10.0.0 - version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) - '@dnd-kit/utilities': - specifier: ^3.2.2 - version: 3.2.2(react@19.2.4) '@mosaic/shared': specifier: workspace:* version: link:../../packages/shared @@ -595,28 +586,6 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} - '@dnd-kit/accessibility@3.1.1': - resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} - peerDependencies: - react: '>=16.8.0' - - '@dnd-kit/core@6.3.1': - resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - - '@dnd-kit/sortable@10.0.0': - resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} - peerDependencies: - '@dnd-kit/core': ^6.3.0 - react: '>=16.8.0' - - '@dnd-kit/utilities@3.2.2': - resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} - peerDependencies: - react: '>=16.8.0' - '@emnapi/runtime@1.8.1': resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} @@ -4791,31 +4760,6 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} - '@dnd-kit/accessibility@3.1.1(react@19.2.4)': - dependencies: - react: 19.2.4 - tslib: 2.8.1 - - '@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@dnd-kit/accessibility': 3.1.1(react@19.2.4) - '@dnd-kit/utilities': 3.2.2(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - tslib: 2.8.1 - - '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': - dependencies: - '@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@dnd-kit/utilities': 3.2.2(react@19.2.4) - react: 19.2.4 - tslib: 2.8.1 - - '@dnd-kit/utilities@3.2.2(react@19.2.4)': - dependencies: - react: 19.2.4 - tslib: 2.8.1 - '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1