maestro/src/bridge/scheduled-tasks-api.ts
oss-sync 483464597a
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (a360d15)
2026-06-09 09:19:09 +00:00

355 lines
14 KiB
TypeScript

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;
/**
* Whether the auth subsystem is wired. When `false` (no-auth single-user
* deployment) requests carry no `req.user`, so new scheduled tasks (and the
* jobs the scheduler spawns from them) are owned by the stable `local` user
* instead of NULL — keeping per-task reflection / visibility consistent with
* the rest of the no-auth path. Defaults to `true`.
*/
authActive?: boolean;
}
export function mountScheduledTasksApi(
app: Application,
repo: Repository,
scheduler: Scheduler,
apiOpts: ScheduledTasksApiOptions = {},
): void {
const { sessRepo } = apiOpts;
const noAuthOwner: string | null = (apiOpts.authActive ?? true) ? null : 'local';
/**
* 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 ?? noAuthOwner;
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}` });
}
});
}