feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,447 @@
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
import { mkdirSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { Repository, type ScheduledTask } from './db/repository.js';
|
||||
import type { BrowserSessionRepo } from './db/browser-session-repo.js';
|
||||
import { logger } from './logger.js';
|
||||
import { loadConfig } from './config.js';
|
||||
import { resolveAndRunUserScript } from './user-folder/script-orchestrator.js';
|
||||
|
||||
// 進行中とみなすステータス(これらの場合はスキップ)
|
||||
const IN_PROGRESS_STATUSES = new Set([
|
||||
'queued', 'dispatching', 'running', 'waiting_human', 'waiting_subtasks',
|
||||
]);
|
||||
|
||||
export interface ScheduleInput {
|
||||
hour?: number;
|
||||
minute?: number;
|
||||
dayOfWeek?: number;
|
||||
dayOfMonth?: number;
|
||||
cronExpression?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* プリセット(daily/weekly/monthly)を cron 式に変換する。
|
||||
* cron タイプはそのまま返し、once は 'once' を返す。
|
||||
*/
|
||||
export function convertToCron(scheduleType: string, input: ScheduleInput): string {
|
||||
const m = input.minute ?? 0;
|
||||
const h = input.hour ?? 0;
|
||||
|
||||
switch (scheduleType) {
|
||||
case 'daily':
|
||||
return `${m} ${h} * * *`;
|
||||
case 'weekly':
|
||||
return `${m} ${h} * * ${input.dayOfWeek ?? 0}`;
|
||||
case 'monthly':
|
||||
return `${m} ${h} ${input.dayOfMonth ?? 1} * *`;
|
||||
case 'cron':
|
||||
if (!input.cronExpression) throw new Error('cronExpression is required for cron type');
|
||||
return input.cronExpression;
|
||||
case 'once':
|
||||
return 'once';
|
||||
default:
|
||||
throw new Error(`Unknown schedule type: ${scheduleType}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Date を SQLite datetime() 互換フォーマット (YYYY-MM-DD HH:MM:SS) に変換する。
|
||||
* SQLite の datetime('now') は UTC で 'YYYY-MM-DD HH:MM:SS' を返すため、
|
||||
* toISOString() の 'T' や 'Z'、ミリ秒部分を除去して一致させる。
|
||||
*/
|
||||
export function toSqliteDatetime(date: Date): string {
|
||||
return date.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* cron 式から次回実行時刻(SQLite datetime 互換 UTC)を算出する。
|
||||
* 'once' の場合は null を返す。
|
||||
*/
|
||||
export function calcNextRun(cronExpression: string): string | null {
|
||||
if (cronExpression === 'once') return null;
|
||||
const interval = CronExpressionParser.parse(cronExpression, { tz: 'UTC' });
|
||||
return toSqliteDatetime(interval.next().toDate());
|
||||
}
|
||||
|
||||
export interface SchedulerOptions {
|
||||
/**
|
||||
* 'auto' を実 piece 名に解決するクラシファイア。UI 経路 (local-tasks-api) と同じ
|
||||
* 関数を渡す。未指定または例外時は 'chat' に fallback する。
|
||||
* これを渡さないと pieceName='auto' のスケジュールが worker で
|
||||
* `Piece not found: auto` で必ず失敗する。
|
||||
*/
|
||||
selectPiece?: (body: string, fileNames: string[], userId?: string) => Promise<string>;
|
||||
/**
|
||||
* task_kind='script' をサポートするための依存。未指定なら script kind の実行は
|
||||
* "Scheduler not configured for script kind" でその場で fail させる。
|
||||
*/
|
||||
sessRepo?: BrowserSessionRepo;
|
||||
masterKeyPath?: string;
|
||||
userFolderRoot?: string;
|
||||
/**
|
||||
* Override how the user-script security gate reads config. Production
|
||||
* leaves this undefined (defaults to loadConfig()). Tests inject their own
|
||||
* to avoid relying on a real config.yaml on disk, since vitest workers
|
||||
* can't process.chdir().
|
||||
*/
|
||||
getUserScriptGate?: () => { enabled: boolean; allowUserids?: string[] };
|
||||
}
|
||||
|
||||
export class Scheduler {
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private running = false;
|
||||
private readonly pollIntervalMs = 60_000;
|
||||
private readonly selectPiece?: (body: string, fileNames: string[], userId?: string) => Promise<string>;
|
||||
private readonly sessRepo?: BrowserSessionRepo;
|
||||
private readonly masterKeyPath?: string;
|
||||
private readonly userFolderRoot?: string;
|
||||
private readonly getUserScriptGate: () => { enabled: boolean; allowUserids?: string[] };
|
||||
|
||||
constructor(
|
||||
private readonly repo: Repository,
|
||||
private readonly worktreeDir: string,
|
||||
options?: SchedulerOptions,
|
||||
) {
|
||||
this.selectPiece = options?.selectPiece;
|
||||
this.sessRepo = options?.sessRepo;
|
||||
this.masterKeyPath = options?.masterKeyPath;
|
||||
this.userFolderRoot = options?.userFolderRoot;
|
||||
this.getUserScriptGate = options?.getUserScriptGate ?? (() => {
|
||||
const cfg = loadConfig();
|
||||
return {
|
||||
enabled: cfg.tools?.userScriptsEnabled === true,
|
||||
allowUserids: cfg.tools?.userScriptsAllowUserids,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
logger.info('[scheduler] started');
|
||||
// 起動時に即座に1回 tick(再起動後の溜まり分を処理)
|
||||
void this.tick().catch(err => logger.error(`[scheduler] initial tick error: ${err}`));
|
||||
this.scheduleTick();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.running = false;
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
logger.info('[scheduler] stopped');
|
||||
}
|
||||
|
||||
private scheduleTick(): void {
|
||||
if (!this.running) return;
|
||||
this.timer = setTimeout(async () => {
|
||||
try {
|
||||
await this.tick();
|
||||
} catch (err) {
|
||||
logger.error(`[scheduler] tick error: ${err}`);
|
||||
}
|
||||
this.scheduleTick();
|
||||
}, this.pollIntervalMs);
|
||||
}
|
||||
|
||||
async tick(): Promise<number> {
|
||||
const dueItems = await this.repo.getScheduledTasksDue();
|
||||
let executed = 0;
|
||||
|
||||
for (const item of dueItems) {
|
||||
try {
|
||||
// 前回ジョブが進行中ならスキップ
|
||||
if (item.lastJobId) {
|
||||
const lastJob = await this.repo.getJob(item.lastJobId);
|
||||
if (lastJob && IN_PROGRESS_STATUSES.has(lastJob.status)) {
|
||||
logger.info(`[scheduler] skipping scheduled_task=${item.id}: last job ${item.lastJobId} is ${lastJob.status}`);
|
||||
// next_run_at だけ次回に更新
|
||||
const nextRun = calcNextRun(item.cronExpression);
|
||||
if (nextRun) {
|
||||
await this.repo.updateScheduledTask(item.id, { nextRunAt: nextRun });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
await this.executeScheduledTask(item);
|
||||
executed++;
|
||||
} catch (err) {
|
||||
logger.error(`[scheduler] failed to execute scheduled_task=${item.id}: ${err}`);
|
||||
// claim(next_run_at を 9999-12-31 に設定)後の失敗時は次回実行時刻を復元し、
|
||||
// タスクが永久にロックされるのを防ぐ
|
||||
try {
|
||||
const nextRun = calcNextRun(item.cronExpression);
|
||||
if (nextRun) {
|
||||
await this.repo.updateScheduledTask(item.id, { nextRunAt: nextRun });
|
||||
logger.info(`[scheduler] restored next_run_at for scheduled_task=${item.id}`);
|
||||
}
|
||||
} catch (restoreErr) {
|
||||
logger.error(`[scheduler] failed to restore next_run_at for scheduled_task=${item.id}: ${restoreErr}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return executed;
|
||||
}
|
||||
|
||||
private async executeScheduledTask(item: ScheduledTask): Promise<void> {
|
||||
if (item.taskKind === 'script') {
|
||||
await this.executeScriptScheduledTask(item);
|
||||
return;
|
||||
}
|
||||
await this.executeAgentScheduledTask(item);
|
||||
}
|
||||
|
||||
private async executeAgentScheduledTask(item: ScheduledTask): Promise<void> {
|
||||
const now = toSqliteDatetime(new Date());
|
||||
|
||||
// タイトルに日時を付加
|
||||
const dateStr = new Date().toLocaleString('ja-JP', { timeZone: 'Asia/Tokyo', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
const title = item.title ? `${item.title} (${dateStr})` : `スケジュール実行 (${dateStr})`;
|
||||
|
||||
// 'auto' は piece-classifier で実 piece 名に解決する。
|
||||
// pieces/auto.yaml は存在しないので、'auto' をそのまま流すと worker が
|
||||
// loadPiece('auto') で `Piece not found: auto` を投げて即失敗する。
|
||||
// UI 経路 (local-tasks-api) と同じ classifier をコンストラクタ経由で受け取る。
|
||||
let resolvedPiece = item.pieceName;
|
||||
if (resolvedPiece === 'auto') {
|
||||
if (this.selectPiece) {
|
||||
try {
|
||||
resolvedPiece = await this.selectPiece(item.body, [], item.ownerId ?? undefined);
|
||||
} catch (err) {
|
||||
logger.warn(`[scheduler] piece classification failed for scheduled_task=${item.id}: ${err}, falling back to 'chat'`);
|
||||
resolvedPiece = 'chat';
|
||||
}
|
||||
} else {
|
||||
logger.warn(`[scheduler] selectPiece not configured, falling back to 'chat' for scheduled_task=${item.id}`);
|
||||
resolvedPiece = 'chat';
|
||||
}
|
||||
}
|
||||
|
||||
// 新規ローカルタスク作成。
|
||||
// 所有権・可視性はスケジュール定義から継承する。継承しないと local_task.owner_id が
|
||||
// NULL (system 扱い)・visibility が 'private' (admin のみ閲覧可) になり、
|
||||
// 作成者本人が自分のスケジュール実行結果を見られなくなる。
|
||||
const task = await this.repo.createLocalTask({
|
||||
title,
|
||||
body: item.body,
|
||||
pieceName: resolvedPiece,
|
||||
profile: item.profile as 'auto' | 'fast' | 'quality',
|
||||
outputFormat: item.outputFormat as 'text' | 'markdown' | 'json',
|
||||
ownerId: item.ownerId,
|
||||
visibility: item.visibility,
|
||||
visibilityScopeOrgId: item.visibilityScopeOrgId,
|
||||
browserSessionProfileId: item.browserSessionProfileId ?? null,
|
||||
});
|
||||
|
||||
// ワークスペース作成
|
||||
const workspacePath = join(this.worktreeDir, 'local', String(task.id));
|
||||
mkdirSync(join(workspacePath, 'input'), { recursive: true });
|
||||
mkdirSync(join(workspacePath, 'output'), { recursive: true });
|
||||
mkdirSync(join(workspacePath, 'logs'), { recursive: true });
|
||||
await this.repo.updateLocalTask(task.id, { workspacePath });
|
||||
|
||||
// ジョブ作成
|
||||
const metadataBlock = [
|
||||
'---',
|
||||
`ui_profile: ${item.profile}`,
|
||||
`ui_output_format: ${item.outputFormat}`,
|
||||
`ui_ask_policy: low`,
|
||||
`ui_priority: medium`,
|
||||
'---',
|
||||
].join('\n');
|
||||
const instruction = `${title}\n\n${item.body}\n\n${metadataBlock}`.trim();
|
||||
|
||||
const job = await this.repo.createJob({
|
||||
repo: `local/task-${task.id}`,
|
||||
issueNumber: task.id,
|
||||
instruction,
|
||||
pieceName: resolvedPiece,
|
||||
role: item.profile as any,
|
||||
ownerId: item.ownerId,
|
||||
visibility: item.visibility,
|
||||
visibilityScopeOrgId: item.visibilityScopeOrgId,
|
||||
browserSessionProfileId: item.browserSessionProfileId ?? null,
|
||||
});
|
||||
|
||||
await this.repo.addAuditLog(job.id, 'job_queued_scheduled', 'scheduler', {
|
||||
scheduledTaskId: item.id,
|
||||
taskId: task.id,
|
||||
requestedPiece: item.pieceName,
|
||||
resolvedPiece,
|
||||
});
|
||||
|
||||
// scheduled_tasks を更新
|
||||
const nextRun = calcNextRun(item.cronExpression);
|
||||
await this.repo.updateScheduledTask(item.id, {
|
||||
lastRunAt: now,
|
||||
lastJobId: job.id,
|
||||
nextRunAt: nextRun ?? item.nextRunAt,
|
||||
isActive: item.cronExpression === 'once' ? false : undefined,
|
||||
});
|
||||
|
||||
logger.info(`[scheduler] executed scheduled_task=${item.id} → task=${task.id} job=${job.id}`);
|
||||
}
|
||||
|
||||
private async executeScriptScheduledTask(item: ScheduledTask): Promise<void> {
|
||||
const now = toSqliteDatetime(new Date());
|
||||
|
||||
if (!item.scriptName) {
|
||||
throw new Error(`scheduled_task=${item.id}: task_kind='script' but script_name is null`);
|
||||
}
|
||||
if (!this.userFolderRoot) {
|
||||
throw new Error(`scheduled_task=${item.id}: task_kind='script' but Scheduler.userFolderRoot was not configured`);
|
||||
}
|
||||
if (!item.ownerId) {
|
||||
throw new Error(`scheduled_task=${item.id}: task_kind='script' requires an owner_id (scripts are per-user)`);
|
||||
}
|
||||
|
||||
// Same security gates as the LLM-facing RunUserScript tool: global config
|
||||
// toggle + optional per-user allowlist. A scheduled run is automated, so a
|
||||
// mis-enabled user could quietly exfiltrate via cron without the gate.
|
||||
const gate = this.getUserScriptGate();
|
||||
if (!gate.enabled) {
|
||||
throw new Error(
|
||||
`scheduled_task=${item.id}: user scripts are disabled (tools.user_scripts_enabled=false)`,
|
||||
);
|
||||
}
|
||||
if (Array.isArray(gate.allowUserids) && gate.allowUserids.length > 0 && !gate.allowUserids.includes(item.ownerId)) {
|
||||
throw new Error(
|
||||
`scheduled_task=${item.id}: owner "${item.ownerId}" is not in tools.user_scripts_allow_userids`,
|
||||
);
|
||||
}
|
||||
|
||||
// Decode params; tolerate null / empty
|
||||
let params: Record<string, unknown> = {};
|
||||
if (item.scriptParams) {
|
||||
try {
|
||||
const parsed = JSON.parse(item.scriptParams);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
params = parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error(`scheduled_task=${item.id}: malformed script_params JSON: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const dateStr = new Date().toLocaleString('ja-JP', { timeZone: 'Asia/Tokyo', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
const title = item.title ? `${item.title} (${dateStr})` : `スクリプト実行: ${item.scriptName} (${dateStr})`;
|
||||
|
||||
const task = await this.repo.createLocalTask({
|
||||
title,
|
||||
body: `script: ${item.scriptName}\nparams: ${item.scriptParams ?? '{}'}`,
|
||||
pieceName: 'script',
|
||||
profile: item.profile as 'auto' | 'fast' | 'quality',
|
||||
outputFormat: item.outputFormat as 'text' | 'markdown' | 'json',
|
||||
ownerId: item.ownerId,
|
||||
visibility: item.visibility,
|
||||
visibilityScopeOrgId: item.visibilityScopeOrgId,
|
||||
browserSessionProfileId: item.browserSessionProfileId ?? null,
|
||||
});
|
||||
|
||||
const workspacePath = join(this.worktreeDir, 'local', String(task.id));
|
||||
mkdirSync(join(workspacePath, 'input'), { recursive: true });
|
||||
mkdirSync(join(workspacePath, 'output'), { recursive: true });
|
||||
mkdirSync(join(workspacePath, 'logs'), { recursive: true });
|
||||
await this.repo.updateLocalTask(task.id, { workspacePath });
|
||||
|
||||
// Create job in a pre-completed state — scheduler runs the script inline,
|
||||
// so the worker queue never sees it. We still create the job row so the UI
|
||||
// (which keys off jobs) shows the run in task history.
|
||||
const job = await this.repo.createJob({
|
||||
repo: `local/task-${task.id}`,
|
||||
issueNumber: task.id,
|
||||
instruction: `Scheduled script run: ${item.scriptName}`,
|
||||
pieceName: 'script',
|
||||
role: item.profile as any,
|
||||
ownerId: item.ownerId,
|
||||
visibility: item.visibility,
|
||||
visibilityScopeOrgId: item.visibilityScopeOrgId,
|
||||
browserSessionProfileId: item.browserSessionProfileId ?? null,
|
||||
});
|
||||
|
||||
await this.repo.addAuditLog(job.id, 'job_queued_scheduled', 'scheduler', {
|
||||
scheduledTaskId: item.id,
|
||||
taskId: task.id,
|
||||
kind: 'script',
|
||||
scriptName: item.scriptName,
|
||||
});
|
||||
|
||||
let runFailed = false;
|
||||
let errorMessage: string | null = null;
|
||||
try {
|
||||
const runResult = await resolveAndRunUserScript({
|
||||
rootDir: this.userFolderRoot,
|
||||
userId: item.ownerId,
|
||||
name: item.scriptName,
|
||||
params,
|
||||
sessRepo: this.sessRepo,
|
||||
masterKeyPath: this.masterKeyPath,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
const summary = [
|
||||
`script: ${item.scriptName}`,
|
||||
`subdir: ${runResult.ok ? runResult.subdir : runResult.subdir ?? 'unresolved'}`,
|
||||
`ok: ${runResult.ok}`,
|
||||
runResult.ok ? `durationMs: ${runResult.durationMs}` : `error: ${runResult.error}`,
|
||||
].join('\n');
|
||||
writeFileSync(join(workspacePath, 'logs', 'script-run.log'), summary + '\n', 'utf-8');
|
||||
|
||||
if (runResult.ok) {
|
||||
const out = typeof runResult.result === 'string'
|
||||
? runResult.result
|
||||
: JSON.stringify(runResult.result, null, 2);
|
||||
writeFileSync(join(workspacePath, 'output', 'script-output.txt'), out ?? '', 'utf-8');
|
||||
if (runResult.logs.length > 0) {
|
||||
writeFileSync(join(workspacePath, 'logs', 'script-stdout.log'), runResult.logs.join('\n') + '\n', 'utf-8');
|
||||
}
|
||||
} else {
|
||||
runFailed = true;
|
||||
errorMessage = runResult.error;
|
||||
writeFileSync(join(workspacePath, 'logs', 'script-error.log'), runResult.error + '\n', 'utf-8');
|
||||
}
|
||||
} catch (err) {
|
||||
runFailed = true;
|
||||
errorMessage = (err as Error).message;
|
||||
writeFileSync(join(workspacePath, 'logs', 'script-error.log'), `unexpected error: ${errorMessage}\n`, 'utf-8');
|
||||
}
|
||||
|
||||
await this.repo.updateJob(job.id, {
|
||||
status: runFailed ? 'failed' : 'succeeded',
|
||||
...(runFailed && errorMessage ? { errorSummary: errorMessage.slice(0, 1000) } : {}),
|
||||
});
|
||||
|
||||
await this.repo.addAuditLog(job.id, 'user_script_run', 'scheduler', {
|
||||
scheduledTaskId: item.id,
|
||||
userId: item.ownerId,
|
||||
scriptName: item.scriptName,
|
||||
ok: !runFailed,
|
||||
...(runFailed && errorMessage ? { error: errorMessage.slice(0, 500) } : {}),
|
||||
});
|
||||
|
||||
const nextRun = calcNextRun(item.cronExpression);
|
||||
await this.repo.updateScheduledTask(item.id, {
|
||||
lastRunAt: now,
|
||||
lastJobId: job.id,
|
||||
nextRunAt: nextRun ?? item.nextRunAt,
|
||||
isActive: item.cronExpression === 'once' ? false : undefined,
|
||||
});
|
||||
|
||||
if (runFailed) {
|
||||
logger.warn(`[scheduler] script scheduled_task=${item.id} (${item.scriptName}) failed: ${errorMessage}`);
|
||||
} else {
|
||||
logger.info(`[scheduler] executed script scheduled_task=${item.id} → task=${task.id} job=${job.id} script=${item.scriptName}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 手動トリガー用: 指定IDのスケジュールを即時実行 */
|
||||
async executeById(id: number): Promise<void> {
|
||||
const item = await this.repo.getScheduledTask(id);
|
||||
if (!item) throw new Error(`Scheduled task ${id} not found`);
|
||||
await this.executeScheduledTask(item);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user