This commit is contained in:
seb
2026-10-17 02:59:35 +02:00
parent fd21cb7319
commit c61df92d63
13 changed files with 3214 additions and 2198 deletions

View File

@@ -1453,6 +1453,74 @@
* desc:
* type: string
* nullable: true
* SubagentSchedule:
* type: object
* required: [id, company_id, prompt, cron, max_itr, created_at]
* properties:
* id:
* type: integer
* company_id:
* type: integer
* desc:
* type: string
* nullable: true
* prompt:
* type: string
* skills:
* type: string
* nullable: true
* lastagentnote:
* type: string
* nullable: true
* max_itr:
* type: integer
* cron:
* type: string
* created_at:
* type: string
* lastrun_at:
* type: string
* nullable: true
* SubagentScheduleCreateInput:
* type: object
* required: [prompt, cron]
* properties:
* prompt:
* type: string
* cron:
* type: string
* desc:
* type: string
* nullable: true
* skills:
* type: string
* nullable: true
* lastagentnote:
* type: string
* nullable: true
* max_itr:
* type: integer
* SubagentScheduleUpdateInput:
* type: object
* properties:
* prompt:
* type: string
* cron:
* type: string
* desc:
* type: string
* nullable: true
* skills:
* type: string
* nullable: true
* lastagentnote:
* type: string
* nullable: true
* max_itr:
* type: integer
* lastrun_at:
* type: string
* nullable: true
* Posterminal:
* type: object
* required: [id, name]

View File

@@ -0,0 +1,528 @@
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
}