529 lines
17 KiB
JavaScript
529 lines
17 KiB
JavaScript
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
|
|
}
|