feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
import { type Application, type Request, type Response } from 'express';
|
||||
import { type Repository } from '../db/repository.js';
|
||||
import type { BrowserSessionRepo } from '../db/browser-session-repo.js';
|
||||
import { convertToCron, calcNextRun, toSqliteDatetime } from '../scheduler.js';
|
||||
import { type Scheduler } from '../scheduler.js';
|
||||
|
||||
export interface ScheduledTasksApiOptions {
|
||||
/**
|
||||
* Optional. When set, accepting browserSessionProfileId on create / update
|
||||
* verifies the profile belongs to the requesting user. Without it, the
|
||||
* field is silently dropped (legacy / no-auth deployments).
|
||||
*/
|
||||
sessRepo?: BrowserSessionRepo;
|
||||
}
|
||||
|
||||
export function mountScheduledTasksApi(
|
||||
app: Application,
|
||||
repo: Repository,
|
||||
scheduler: Scheduler,
|
||||
apiOpts: ScheduledTasksApiOptions = {},
|
||||
): void {
|
||||
const { sessRepo } = apiOpts;
|
||||
|
||||
/**
|
||||
* Validate and resolve a browserSessionProfileId from a request body.
|
||||
* Returns:
|
||||
* - { ok: true, value: number | null } when accepted (null = unset / clear).
|
||||
* - { ok: false, error } when validation fails (caller sends 400).
|
||||
* Pass an undefined raw to skip validation entirely (PATCH "field absent" case).
|
||||
*/
|
||||
function resolveBrowserSessionProfileId(
|
||||
raw: unknown,
|
||||
user: Express.User | undefined,
|
||||
): { ok: true; value: number | null } | { ok: false; error: string } {
|
||||
if (raw === undefined) return { ok: true, value: null };
|
||||
if (raw === null || raw === '') return { ok: true, value: null };
|
||||
const n = Number(raw);
|
||||
if (!Number.isInteger(n) || n <= 0) {
|
||||
return { ok: false, error: 'browserSessionProfileId must be a positive integer' };
|
||||
}
|
||||
if (sessRepo) {
|
||||
if (!user?.id) {
|
||||
return { ok: false, error: 'browserSessionProfileId requires an authenticated user' };
|
||||
}
|
||||
const owned = sessRepo.getProfileById(n, user.id);
|
||||
if (!owned) {
|
||||
return { ok: false, error: 'browser session profile not found or not owned by you' };
|
||||
}
|
||||
}
|
||||
return { ok: true, value: n };
|
||||
}
|
||||
|
||||
// 一覧取得
|
||||
app.get('/api/scheduled-tasks', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const viewer = req.user as Express.User | undefined;
|
||||
const tasks = await repo.listScheduledTasks(viewer ? { viewer } : undefined);
|
||||
res.json({ tasks });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: `Failed to list scheduled tasks: ${err}` });
|
||||
}
|
||||
});
|
||||
|
||||
// 詳細取得
|
||||
app.get('/api/scheduled-tasks/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const viewer = req.user as Express.User | undefined;
|
||||
const task = await repo.getScheduledTask(id, { viewer });
|
||||
if (!task) { res.status(404).json({ error: 'Not found' }); return; }
|
||||
res.json({ task });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: `Failed to get scheduled task: ${err}` });
|
||||
}
|
||||
});
|
||||
|
||||
// 新規作成
|
||||
app.post('/api/scheduled-tasks', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { title, body, piece, profile, outputFormat, scheduleType, hour, minute, dayOfWeek, dayOfMonth, cronExpression: rawCron, scheduledAt } = req.body;
|
||||
|
||||
// task_kind: 'agent' (default) or 'script'
|
||||
const rawTaskKind = req.body?.taskKind;
|
||||
const taskKind: 'agent' | 'script' = rawTaskKind === 'script' ? 'script' : 'agent';
|
||||
let scriptName: string | null = null;
|
||||
let scriptParams: string | null = null;
|
||||
if (taskKind === 'script') {
|
||||
const rawScriptName = req.body?.scriptName;
|
||||
if (typeof rawScriptName !== 'string' || !rawScriptName.trim()) {
|
||||
res.status(400).json({ error: 'scriptName is required when taskKind=script' });
|
||||
return;
|
||||
}
|
||||
scriptName = rawScriptName.trim();
|
||||
const rawScriptParams = req.body?.scriptParams;
|
||||
if (rawScriptParams !== undefined && rawScriptParams !== null) {
|
||||
if (typeof rawScriptParams === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(rawScriptParams);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('scriptParams must be a JSON object');
|
||||
}
|
||||
scriptParams = JSON.stringify(parsed);
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: `scriptParams is not valid JSON: ${(err as Error).message}` });
|
||||
return;
|
||||
}
|
||||
} else if (typeof rawScriptParams === 'object' && !Array.isArray(rawScriptParams)) {
|
||||
scriptParams = JSON.stringify(rawScriptParams);
|
||||
} else {
|
||||
res.status(400).json({ error: 'scriptParams must be a JSON object (or stringified JSON object)' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (taskKind === 'agent' && !body) { res.status(400).json({ error: 'body is required' }); return; }
|
||||
if (!scheduleType) { res.status(400).json({ error: 'scheduleType is required' }); return; }
|
||||
|
||||
// Visibility extraction + validation (mirrors POST /api/local/tasks)
|
||||
const rawVisibility = req.body?.visibility ?? 'private';
|
||||
if (!['private', 'org', 'public'].includes(rawVisibility)) {
|
||||
res.status(400).json({ error: 'invalid visibility' });
|
||||
return;
|
||||
}
|
||||
const visibility = rawVisibility as 'private' | 'org' | 'public';
|
||||
const rawScopeOrgId = req.body?.visibilityScopeOrgId;
|
||||
const visibilityScopeOrgId: string | null =
|
||||
typeof rawScopeOrgId === 'string' && rawScopeOrgId.length > 0 ? rawScopeOrgId : null;
|
||||
if (visibility === 'org') {
|
||||
const orgIds = (req.user as Express.User | undefined)?.orgIds ?? [];
|
||||
if (!visibilityScopeOrgId || !orgIds.includes(visibilityScopeOrgId)) {
|
||||
res.status(400).json({ error: 'visibility_scope_org_id must be one of your orgs' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const ownerId = (req.user as Express.User | undefined)?.id ?? null;
|
||||
|
||||
const profileBinding = resolveBrowserSessionProfileId(
|
||||
req.body?.browserSessionProfileId,
|
||||
req.user as Express.User | undefined,
|
||||
);
|
||||
if (!profileBinding.ok) {
|
||||
res.status(400).json({ error: profileBinding.error });
|
||||
return;
|
||||
}
|
||||
|
||||
const cronExpr = convertToCron(scheduleType, { hour, minute, dayOfWeek, dayOfMonth, cronExpression: rawCron });
|
||||
|
||||
let nextRunAt: string;
|
||||
if (scheduleType === 'once') {
|
||||
if (!scheduledAt) { res.status(400).json({ error: 'scheduledAt is required for once type' }); return; }
|
||||
nextRunAt = toSqliteDatetime(new Date(scheduledAt));
|
||||
} else {
|
||||
const next = calcNextRun(cronExpr);
|
||||
if (!next) { res.status(400).json({ error: 'Failed to calculate next run time' }); return; }
|
||||
nextRunAt = next;
|
||||
}
|
||||
|
||||
const task = await repo.createScheduledTask({
|
||||
title: title || null,
|
||||
body: taskKind === 'script' ? (body ?? '') : body,
|
||||
pieceName: piece ?? 'auto',
|
||||
profile: profile ?? 'auto',
|
||||
outputFormat: outputFormat ?? 'markdown',
|
||||
cronExpression: cronExpr,
|
||||
nextRunAt,
|
||||
ownerId,
|
||||
visibility,
|
||||
visibilityScopeOrgId: visibility === 'org' ? visibilityScopeOrgId : null,
|
||||
browserSessionProfileId: profileBinding.value,
|
||||
taskKind,
|
||||
scriptName,
|
||||
scriptParams,
|
||||
});
|
||||
|
||||
res.status(201).json({ task });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: `Failed to create scheduled task: ${err}` });
|
||||
}
|
||||
});
|
||||
|
||||
// 編集
|
||||
app.patch('/api/scheduled-tasks/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const viewer = req.user as Express.User | undefined;
|
||||
const existing = await repo.getScheduledTask(id, { viewer });
|
||||
if (!existing) { res.status(404).json({ error: 'Not found' }); return; }
|
||||
if (viewer && viewer.role !== 'admin' && existing.ownerId !== viewer.id) {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const updates: Record<string, any> = {};
|
||||
|
||||
if (req.body.title !== undefined) updates.title = req.body.title;
|
||||
if (req.body.body !== undefined) updates.body = req.body.body;
|
||||
if (req.body.piece !== undefined) updates.pieceName = req.body.piece;
|
||||
if (req.body.profile !== undefined) updates.profile = req.body.profile;
|
||||
if (req.body.outputFormat !== undefined) updates.outputFormat = req.body.outputFormat;
|
||||
|
||||
// スケジュール変更
|
||||
if (req.body.scheduleType) {
|
||||
const cronExpr = convertToCron(req.body.scheduleType, {
|
||||
hour: req.body.hour,
|
||||
minute: req.body.minute,
|
||||
dayOfWeek: req.body.dayOfWeek,
|
||||
dayOfMonth: req.body.dayOfMonth,
|
||||
cronExpression: req.body.cronExpression,
|
||||
});
|
||||
updates.cronExpression = cronExpr;
|
||||
|
||||
if (req.body.scheduleType === 'once' && req.body.scheduledAt) {
|
||||
updates.nextRunAt = toSqliteDatetime(new Date(req.body.scheduledAt));
|
||||
} else {
|
||||
const next = calcNextRun(cronExpr);
|
||||
if (next) updates.nextRunAt = next;
|
||||
}
|
||||
}
|
||||
|
||||
// 一時停止/再開
|
||||
if (req.body.isActive !== undefined) {
|
||||
updates.isActive = req.body.isActive;
|
||||
// 再開時は next_run_at を再計算
|
||||
if (req.body.isActive && !updates.cronExpression) {
|
||||
const next = calcNextRun(existing.cronExpression);
|
||||
if (next) updates.nextRunAt = next;
|
||||
}
|
||||
}
|
||||
|
||||
// Visibility 変更 (POST と同じバリデーション)
|
||||
if (req.body.visibility !== undefined) {
|
||||
const rawVisibility = req.body.visibility;
|
||||
if (!['private', 'org', 'public'].includes(rawVisibility)) {
|
||||
res.status(400).json({ error: 'invalid visibility' });
|
||||
return;
|
||||
}
|
||||
const rawScopeOrgId = req.body.visibilityScopeOrgId;
|
||||
const visibilityScopeOrgId: string | null =
|
||||
typeof rawScopeOrgId === 'string' && rawScopeOrgId.length > 0 ? rawScopeOrgId : null;
|
||||
if (rawVisibility === 'org') {
|
||||
const orgIds = viewer?.orgIds ?? [];
|
||||
if (!visibilityScopeOrgId || !orgIds.includes(visibilityScopeOrgId)) {
|
||||
res.status(400).json({ error: 'visibility_scope_org_id must be one of your orgs' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
updates.visibility = rawVisibility;
|
||||
updates.visibilityScopeOrgId = rawVisibility === 'org' ? visibilityScopeOrgId : null;
|
||||
}
|
||||
|
||||
// browserSessionProfileId 変更 (owner check)
|
||||
if (req.body.browserSessionProfileId !== undefined) {
|
||||
const binding = resolveBrowserSessionProfileId(req.body.browserSessionProfileId, viewer);
|
||||
if (!binding.ok) {
|
||||
res.status(400).json({ error: binding.error });
|
||||
return;
|
||||
}
|
||||
updates.browserSessionProfileId = binding.value;
|
||||
}
|
||||
|
||||
// taskKind / scriptName / scriptParams (PATCH 用)
|
||||
if (req.body.taskKind !== undefined) {
|
||||
if (req.body.taskKind !== 'agent' && req.body.taskKind !== 'script') {
|
||||
res.status(400).json({ error: "taskKind must be 'agent' or 'script'" });
|
||||
return;
|
||||
}
|
||||
updates.taskKind = req.body.taskKind;
|
||||
}
|
||||
if (req.body.scriptName !== undefined) {
|
||||
if (req.body.scriptName === null || req.body.scriptName === '') {
|
||||
updates.scriptName = null;
|
||||
} else if (typeof req.body.scriptName === 'string') {
|
||||
updates.scriptName = req.body.scriptName.trim();
|
||||
} else {
|
||||
res.status(400).json({ error: 'scriptName must be a string' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (req.body.scriptParams !== undefined) {
|
||||
if (req.body.scriptParams === null) {
|
||||
updates.scriptParams = null;
|
||||
} else if (typeof req.body.scriptParams === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(req.body.scriptParams);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('scriptParams must be a JSON object');
|
||||
}
|
||||
updates.scriptParams = JSON.stringify(parsed);
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: `scriptParams is not valid JSON: ${(err as Error).message}` });
|
||||
return;
|
||||
}
|
||||
} else if (typeof req.body.scriptParams === 'object' && !Array.isArray(req.body.scriptParams)) {
|
||||
updates.scriptParams = JSON.stringify(req.body.scriptParams);
|
||||
} else {
|
||||
res.status(400).json({ error: 'scriptParams must be a JSON object (or stringified JSON object)' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await repo.updateScheduledTask(id, updates);
|
||||
res.json({ task: updated });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: `Failed to update scheduled task: ${err}` });
|
||||
}
|
||||
});
|
||||
|
||||
// 削除
|
||||
app.delete('/api/scheduled-tasks/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const viewer = req.user as Express.User | undefined;
|
||||
const existing = await repo.getScheduledTask(id, { viewer });
|
||||
if (!existing) { res.status(404).json({ error: 'Not found' }); return; }
|
||||
if (viewer && viewer.role !== 'admin' && existing.ownerId !== viewer.id) {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
return;
|
||||
}
|
||||
const deleted = await repo.deleteScheduledTask(id);
|
||||
if (!deleted) { res.status(404).json({ error: 'Not found' }); return; }
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: `Failed to delete scheduled task: ${err}` });
|
||||
}
|
||||
});
|
||||
|
||||
// 手動即時実行
|
||||
app.post('/api/scheduled-tasks/:id/trigger', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const viewer = req.user as Express.User | undefined;
|
||||
const existing = await repo.getScheduledTask(id, { viewer });
|
||||
if (!existing) { res.status(404).json({ error: 'Not found' }); return; }
|
||||
if (viewer && viewer.role !== 'admin' && existing.ownerId !== viewer.id) {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
return;
|
||||
}
|
||||
await scheduler.executeById(id);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: `Failed to trigger scheduled task: ${err}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user