import * as db from '../../db/subagentschedule.js'; import { emit } from '../sse.js'; // #region Cron Validation const CRON_NICKNAMES = new Set([ '@yearly', '@annually', '@monthly', '@weekly', '@daily', '@midnight', '@hourly', ]); const MONTH_NAMES = { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12, }; const DAY_NAMES = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6, }; const FIELD_RANGES = [ { name: 'minute', min: 0, max: 59 }, { name: 'hour', min: 0, max: 23 }, { name: 'day of month', min: 1, max: 31 }, { name: 'month', min: 1, max: 12, names: MONTH_NAMES }, { name: 'day of week', min: 0, max: 7, names: DAY_NAMES }, ]; function parseCronValue(val, min, max, names) { if (names && typeof val === 'string') { const lower = val.toLowerCase(); if (lower in names) return names[lower]; } if (!/^\d+$/.test(val)) return null; const num = parseInt(val, 10); if (num < min || num > max) return null; return num; } function validateField(part, min, max, names) { if (!part) return false; const items = part.split(','); if (items.length === 0) return false; for (const item of items) { if (!item) return false; const slashParts = item.split('/'); if (slashParts.length > 2) return false; if (slashParts.length === 2) { const step = slashParts[1]; if (!/^\d+$/.test(step)) return false; const stepNum = parseInt(step, 10); if (stepNum <= 0 || stepNum > max) return false; } const base = slashParts[0]; if (base === '*' || base === '?') { continue; } const dashParts = base.split('-'); if (dashParts.length > 2) return false; if (dashParts.length === 2) { const start = parseCronValue(dashParts[0], min, max, names); const end = parseCronValue(dashParts[1], min, max, names); if (start === null || end === null || start > end) return false; continue; } const val = parseCronValue(base, min, max, names); if (val === null) return false; } return true; } function isValidCron(cron) { if (typeof cron !== 'string') return false; const trimmed = cron.trim(); if (!trimmed) return false; if (CRON_NICKNAMES.has(trimmed.toLowerCase())) return true; const parts = trimmed.split(/\s+/); if (parts.length !== 5) return false; for (let i = 0; i < 5; i++) { const part = parts[i]; const { min, max, names } = FIELD_RANGES[i]; if (!validateField(part, min, max, names)) { return false; } } return true; } // #endregion // #region Helpers function requireSelectedCompany(req) { const companyId = req.user?.selectedCompanyId; if (companyId == null) { return { error: 'No company selected' }; } return { companyId }; } function parsePositiveInt(value, field) { const id = Number(value); if (!Number.isInteger(id) || id <= 0) { return { error: `${field} is required` }; } return { id }; } function subagentscheduleErrorStatus(error) { if (error.message === 'No company selected') return 400; if (error.message === 'Not authorized') return 403; if (error.message === 'Company not found') return 404; if (error.message === 'Subagent schedule not found') return 404; if (error.message === 'Prompt required') return 400; if (error.message === 'Cron expression required') return 400; if (error.message === 'Invalid cron expression') return 400; if (error.message?.startsWith('Invalid max iterations')) return 400; return 500; } // #endregion /** * Registers subagent schedule CRUD routes for the authenticated user's selected company. * @param {import('express').Application} app * @param {string} [basePath=''] */ export function register(app, basePath = '') { /** * @openapi * /subagentschedule: * get: * tags: [SubagentSchedule] * summary: List subagent schedules * description: Lists all subagent schedules for the selected company. * security: * - cookieAuth: [] * responses: * 200: * description: List of subagent schedules * content: * application/json: * schema: * type: object * properties: * schedules: * type: array * items: * $ref: '#/components/schemas/SubagentSchedule' * 400: * description: No company selected * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 403: * description: Not authorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region GET /subagentschedule app.get(`${basePath}/subagentschedule`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } try { const schedules = await db.list(req.user.id); res.status(200).json({ schedules }); } catch (error) { const status = subagentscheduleErrorStatus(error); if (status === 500) console.error('List subagent schedules failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /subagentschedule/{id}: * get: * tags: [SubagentSchedule] * summary: Get subagent schedule by ID * description: Retrieves a single subagent schedule for the selected company. * security: * - cookieAuth: [] * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Schedule ID * responses: * 200: * description: Subagent schedule details * content: * application/json: * schema: * type: object * properties: * schedule: * $ref: '#/components/schemas/SubagentSchedule' * 400: * description: Invalid ID or no company selected * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 403: * description: Not authorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 404: * description: Subagent schedule not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region GET /subagentschedule/:id app.get(`${basePath}/subagentschedule/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const parsedId = parsePositiveInt(req.params.id, 'id'); if (parsedId.error) { return res.status(400).json({ error: parsedId.error }); } try { const schedule = await db.getById(req.user.id, parsedId.id); if (!schedule) { return res.status(404).json({ error: 'Subagent schedule not found' }); } res.status(200).json({ schedule }); } catch (error) { const status = subagentscheduleErrorStatus(error); if (status === 500) console.error('Get subagent schedule failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /subagentschedule: * post: * tags: [SubagentSchedule] * summary: Create subagent schedule * description: | * Creates a new scheduled subagent execution for the selected company. * `cron` must be a valid 5-part cron timetable expression (e.g. "0 9 * * 1-5"). * security: * - cookieAuth: [] * x-signals: [subagentschedule] * requestBody: * required: true * content: * application/json: * schema: * $ref: '#/components/schemas/SubagentScheduleCreateInput' * responses: * 201: * description: Created subagent schedule * content: * application/json: * schema: * type: object * properties: * schedule: * $ref: '#/components/schemas/SubagentSchedule' * 400: * description: Invalid input, missing prompt, or invalid cron expression * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 403: * description: Not authorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region POST /subagentschedule app.post(`${basePath}/subagentschedule`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const { desc, prompt, skills, lastagentnote, max_itr, cron } = req.body ?? {}; if (cron !== undefined && !isValidCron(cron)) { return res.status(400).json({ error: 'Invalid cron expression' }); } try { const schedule = await db.create(req.user.id, { desc, prompt, skills, lastagentnote, max_itr, cron, }); emit('subagentschedule', { companyId: selected.companyId, action: 'create', scheduleId: schedule.id }); res.status(201).json({ schedule }); } catch (error) { const status = subagentscheduleErrorStatus(error); if (status === 500) console.error('Create subagent schedule failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /subagentschedule/{id}: * put: * tags: [SubagentSchedule] * summary: Update subagent schedule * description: Updates an existing subagent schedule for the selected company. * security: * - cookieAuth: [] * x-signals: [subagentschedule] * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Schedule ID * requestBody: * required: true * content: * application/json: * schema: * $ref: '#/components/schemas/SubagentScheduleUpdateInput' * responses: * 200: * description: Updated subagent schedule * content: * application/json: * schema: * type: object * properties: * schedule: * $ref: '#/components/schemas/SubagentSchedule' * 400: * description: Invalid input or invalid cron expression * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 403: * description: Not authorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 404: * description: Subagent schedule not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region PUT /subagentschedule/:id app.put(`${basePath}/subagentschedule/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const parsedId = parsePositiveInt(req.params.id, 'id'); if (parsedId.error) { return res.status(400).json({ error: parsedId.error }); } const { desc, prompt, skills, lastagentnote, max_itr, cron, lastrun_at } = req.body ?? {}; if (cron !== undefined && !isValidCron(cron)) { return res.status(400).json({ error: 'Invalid cron expression' }); } try { const schedule = await db.update(req.user.id, parsedId.id, { desc, prompt, skills, lastagentnote, max_itr, cron, lastrun_at, }); emit('subagentschedule', { companyId: selected.companyId, action: 'update', scheduleId: schedule.id }); res.status(200).json({ schedule }); } catch (error) { const status = subagentscheduleErrorStatus(error); if (status === 500) console.error('Update subagent schedule failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion /** * @openapi * /subagentschedule/{id}: * delete: * tags: [SubagentSchedule] * summary: Delete subagent schedule * description: Deletes a subagent schedule for the selected company. * security: * - cookieAuth: [] * x-signals: [subagentschedule] * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: Schedule ID * responses: * 204: * description: Schedule deleted successfully * 400: * description: Invalid ID or no company selected * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 403: * description: Not authorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 404: * description: Subagent schedule not found * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region DELETE /subagentschedule/:id app.delete(`${basePath}/subagentschedule/:id`, async (req, res) => { const selected = requireSelectedCompany(req); if (selected.error) { return res.status(400).json({ error: selected.error }); } const parsedId = parsePositiveInt(req.params.id, 'id'); if (parsedId.error) { return res.status(400).json({ error: parsedId.error }); } try { await db.remove(req.user.id, parsedId.id); emit('subagentschedule', { companyId: selected.companyId, action: 'delete', scheduleId: parsedId.id }); res.status(204).end(); } catch (error) { const status = subagentscheduleErrorStatus(error); if (status === 500) console.error('Delete subagent schedule failed:', error); res.status(status).json({ error: error.message }); } }); // #endregion }