maestro/src/scheduler.test.ts
oss-sync 9f8958c4a2
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (ce93095)
2026-06-10 03:52:37 +00:00

594 lines
23 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { convertToCron, calcNextRun, toSqliteDatetime, Scheduler } from './scheduler.js';
import { Repository } from './db/repository.js';
describe('convertToCron', () => {
it('daily 09:00 → 0 9 * * *', () => {
expect(convertToCron('daily', { hour: 9, minute: 0 })).toBe('0 9 * * *');
});
it('weekly 月曜 09:00 → 0 9 * * 1', () => {
expect(convertToCron('weekly', { hour: 9, minute: 0, dayOfWeek: 1 })).toBe('0 9 * * 1');
});
it('monthly 15日 10:30 → 30 10 15 * *', () => {
expect(convertToCron('monthly', { hour: 10, minute: 30, dayOfMonth: 15 })).toBe('30 10 15 * *');
});
it('cron はそのまま返す', () => {
expect(convertToCron('cron', { cronExpression: '*/5 * * * *' })).toBe('*/5 * * * *');
});
it('once は "once" を返す', () => {
expect(convertToCron('once', {})).toBe('once');
});
});
describe('toSqliteDatetime', () => {
it('ISO 8601 の T と Z を除去して SQLite 互換フォーマットにする', () => {
const result = toSqliteDatetime(new Date('2026-03-25T09:00:00.000Z'));
expect(result).toBe('2026-03-25 09:00:00');
});
it('T, ミリ秒, Z が含まれない', () => {
const result = toSqliteDatetime(new Date());
expect(result).not.toContain('T');
expect(result).not.toContain('Z');
expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
});
});
describe('calcNextRun', () => {
it('cron 式から次回実行時刻を算出する', () => {
const next = calcNextRun('0 9 * * *');
expect(next).toBeTruthy();
expect(new Date(next!).getTime()).toBeGreaterThan(Date.now());
});
it('SQLite datetime 互換フォーマットを返す', () => {
const next = calcNextRun('0 9 * * *');
expect(next).toBeTruthy();
// T や Z が含まれず、YYYY-MM-DD HH:MM:SS 形式
expect(next).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
});
it('once の場合は null を返す', () => {
expect(calcNextRun('once')).toBeNull();
});
});
describe('Scheduler.executeScheduledTask: ownership inheritance', () => {
// Regression: scheduler used to call createLocalTask() without passing
// ownerId / visibility / visibilityScopeOrgId from the schedule, so the
// spawned local_task ended up as { owner_id: NULL, visibility: 'private' }
// and only admins could see it. Non-admin users couldn't find their own
// schedule's results.
let tempDir: string;
let repo: Repository;
let scheduler: Scheduler;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'agent-sched-exec-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
scheduler = new Scheduler(repo, join(tempDir, 'workspaces'));
});
afterEach(() => {
repo.close();
rmSync(tempDir, { recursive: true, force: true });
});
it('propagates ownerId / visibility / visibilityScopeOrgId from schedule to spawned local_task', async () => {
const owner = repo.createUser({ email: 'alice@x.com', name: 'alice', role: 'user', status: 'active' });
const sched = await repo.createScheduledTask({
body: 'do the thing',
pieceName: 'auto',
profile: 'auto',
outputFormat: 'markdown',
cronExpression: '0 9 * * *',
nextRunAt: '2099-01-01 09:00:00',
ownerId: owner.id,
visibility: 'org',
visibilityScopeOrgId: '42',
});
await scheduler.executeById(sched.id);
const after = await repo.getScheduledTask(sched.id);
expect(after?.lastJobId).toBeTruthy();
const job = await repo.getJob(after!.lastJobId!);
expect(job).not.toBeNull();
const localTaskId = job!.issueNumber;
const task = await repo.getLocalTask(localTaskId);
expect(task?.ownerId).toBe(owner.id);
expect(task?.visibility).toBe('org');
expect(task?.visibilityScopeOrgId).toBe('42');
});
it('keeps ownerId NULL when schedule itself has no owner (system schedule)', async () => {
const sched = await repo.createScheduledTask({
body: 'system thing',
pieceName: 'auto',
profile: 'auto',
outputFormat: 'markdown',
cronExpression: '0 9 * * *',
nextRunAt: '2099-01-01 09:00:00',
});
await scheduler.executeById(sched.id);
const after = await repo.getScheduledTask(sched.id);
const job = await repo.getJob(after!.lastJobId!);
const task = await repo.getLocalTask(job!.issueNumber);
expect(task?.ownerId).toBeNull();
});
// Regression: scheduler used to forward pieceName='auto' as-is to
// createLocalTask/createJob, but pieces/auto.yaml does not exist, so
// worker always failed with `Piece not found: auto`. Fix injects a
// selectPiece classifier (same as UI path) and resolves 'auto' before
// creating the task/job.
it('resolves pieceName="auto" via selectPiece before creating task/job', async () => {
let classifierCalled = false;
const selectPiece = async (body: string, _fileNames: string[]): Promise<string> => {
classifierCalled = true;
expect(body).toBe('research the market');
return 'research';
};
const schedWithClassifier = new Scheduler(repo, join(tempDir, 'workspaces'), { selectPiece });
const owner = repo.createUser({ email: 'carol@x.com', name: 'carol', role: 'user', status: 'active' });
const sched = await repo.createScheduledTask({
body: 'research the market',
pieceName: 'auto',
profile: 'auto',
outputFormat: 'markdown',
cronExpression: '0 9 * * *',
nextRunAt: '2099-01-01 09:00:00',
ownerId: owner.id,
});
await schedWithClassifier.executeById(sched.id);
expect(classifierCalled).toBe(true);
const after = await repo.getScheduledTask(sched.id);
const job = await repo.getJob(after!.lastJobId!);
const task = await repo.getLocalTask(job!.issueNumber);
expect(job?.pieceName).toBe('research');
expect(task?.pieceName).toBe('research');
});
it('falls back to "chat" when selectPiece is not configured for pieceName="auto"', async () => {
const sched = await repo.createScheduledTask({
body: 'no classifier here',
pieceName: 'auto',
profile: 'auto',
outputFormat: 'markdown',
cronExpression: '0 9 * * *',
nextRunAt: '2099-01-01 09:00:00',
});
await scheduler.executeById(sched.id);
const after = await repo.getScheduledTask(sched.id);
const job = await repo.getJob(after!.lastJobId!);
const task = await repo.getLocalTask(job!.issueNumber);
expect(job?.pieceName).toBe('chat');
expect(task?.pieceName).toBe('chat');
});
it('preserves explicit pieceName (does not run classifier when piece is not "auto")', async () => {
let classifierCalled = false;
const selectPiece = async (): Promise<string> => {
classifierCalled = true;
return 'research';
};
const schedWithClassifier = new Scheduler(repo, join(tempDir, 'workspaces'), { selectPiece });
const sched = await repo.createScheduledTask({
body: 'use chat explicitly',
pieceName: 'chat',
profile: 'auto',
outputFormat: 'markdown',
cronExpression: '0 9 * * *',
nextRunAt: '2099-01-01 09:00:00',
});
await schedWithClassifier.executeById(sched.id);
expect(classifierCalled).toBe(false);
const after = await repo.getScheduledTask(sched.id);
const job = await repo.getJob(after!.lastJobId!);
expect(job?.pieceName).toBe('chat');
});
it('falls back to "chat" when selectPiece throws', async () => {
const selectPiece = async (): Promise<string> => {
throw new Error('classifier exploded');
};
const schedWithClassifier = new Scheduler(repo, join(tempDir, 'workspaces'), { selectPiece });
const sched = await repo.createScheduledTask({
body: 'classifier will explode',
pieceName: 'auto',
profile: 'auto',
outputFormat: 'markdown',
cronExpression: '0 9 * * *',
nextRunAt: '2099-01-01 09:00:00',
});
await schedWithClassifier.executeById(sched.id);
const after = await repo.getScheduledTask(sched.id);
const job = await repo.getJob(after!.lastJobId!);
expect(job?.pieceName).toBe('chat');
});
// Regression: cron-fired path goes through tick() → getScheduledTasksDue()
// which uses SELECT * (no JOIN), unlike executeById() which uses
// getScheduledTask() (with LEFT JOIN users). Make sure ownership is
// still propagated through this path. Reported symptom: scheduled task
// owned by user, but cron-fired execution showing as "system".
it('cron tick() propagates ownerId / visibility from schedule to spawned local_task', async () => {
const owner = repo.createUser({ email: 'bob@x.com', name: 'bob', role: 'user', status: 'active' });
const sched = await repo.createScheduledTask({
body: 'do the thing via cron',
pieceName: 'auto',
profile: 'auto',
outputFormat: 'markdown',
cronExpression: '0 9 * * *',
// Past time so getScheduledTasksDue returns it
nextRunAt: '2000-01-01 09:00:00',
ownerId: owner.id,
visibility: 'private',
});
const executed = await scheduler.tick();
expect(executed).toBe(1);
const after = await repo.getScheduledTask(sched.id);
expect(after?.lastJobId).toBeTruthy();
const job = await repo.getJob(after!.lastJobId!);
expect(job?.ownerId).toBe(owner.id);
const task = await repo.getLocalTask(job!.issueNumber);
expect(task?.ownerId).toBe(owner.id);
expect(task?.visibility).toBe('private');
});
});
describe('Scheduler.tick: skip-on-in-progress', () => {
// Regression: a scheduled task's previous job ending in `waiting_human` (ASK)
// used to be treated as "in progress" and block all future scheduled runs
// forever — waiting_human has no auto-recovery, and an unattended schedule has
// nobody to answer the ASK, so the schedule silently died. Now waiting_human is
// excluded from the skip set: each scheduled run is an independent task, so we
// leave the stale waiting_human task as-is and start a fresh run.
let tempDir: string;
let repo: Repository;
let scheduler: Scheduler;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'agent-sched-skip-'));
repo = new Repository(join(tempDir, 'db.sqlite'));
scheduler = new Scheduler(repo, join(tempDir, 'workspaces'));
});
afterEach(() => {
repo.close();
rmSync(tempDir, { recursive: true, force: true });
});
// Run a first tick to produce a job, force that job into `status`, then re-arm
// the schedule (past next_run_at) and return its id + the first job id.
async function armWithLastJobStatus(status: string): Promise<{ schedId: number; firstJobId: string }> {
const sched = await repo.createScheduledTask({
body: 'recurring thing',
pieceName: 'chat',
profile: 'auto',
outputFormat: 'markdown',
cronExpression: '0 9 * * *',
nextRunAt: '2000-01-01 09:00:00',
});
const first = await scheduler.tick();
expect(first).toBe(1);
const after = await repo.getScheduledTask(sched.id);
const firstJobId = after!.lastJobId!;
await repo.updateJob(firstJobId, { status: status as any });
// Re-arm: getScheduledTasksDue advanced next_run_at to the future.
await repo.updateScheduledTask(sched.id, { nextRunAt: '2000-01-01 09:00:00' });
return { schedId: sched.id, firstJobId };
}
it('starts a NEW run when the previous job is waiting_human (no longer blocks)', async () => {
const { schedId, firstJobId } = await armWithLastJobStatus('waiting_human');
const executed = await scheduler.tick();
expect(executed).toBe(1);
const after = await repo.getScheduledTask(schedId);
expect(after?.lastJobId).toBeTruthy();
expect(after?.lastJobId).not.toBe(firstJobId); // a fresh job was created
});
it('still skips when the previous job is running (genuinely in progress)', async () => {
const { schedId, firstJobId } = await armWithLastJobStatus('running');
const executed = await scheduler.tick();
expect(executed).toBe(0);
const after = await repo.getScheduledTask(schedId);
expect(after?.lastJobId).toBe(firstJobId); // unchanged — skipped
});
it('still skips when the previous job is waiting_subtasks (auto-recovering transient)', async () => {
const { schedId, firstJobId } = await armWithLastJobStatus('waiting_subtasks');
const executed = await scheduler.tick();
expect(executed).toBe(0);
const after = await repo.getScheduledTask(schedId);
expect(after?.lastJobId).toBe(firstJobId); // unchanged — skipped
});
});
describe('Scheduler.executeScheduledTask: task_kind="script"', () => {
// Scheduler runs a user-authored script directly (no LLM agent loop) when
// task_kind='script'. The job row gets created in a pre-completed state.
let tempDir: string;
let userFolderRoot: string;
let repo: Repository;
let scheduler: Scheduler;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'agent-sched-script-'));
userFolderRoot = join(tempDir, 'users');
mkdirSync(userFolderRoot, { recursive: true });
repo = new Repository(join(tempDir, 'db.sqlite'));
scheduler = new Scheduler(repo, join(tempDir, 'workspaces'), {
userFolderRoot,
masterKeyPath: join(tempDir, 'master.key'),
getUserScriptGate: () => ({ enabled: true }),
});
});
afterEach(() => {
repo.close();
rmSync(tempDir, { recursive: true, force: true });
});
function writeScript(userId: string, name: string, source: string): void {
const dir = join(userFolderRoot, userId, 'browser-macros');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, name), source, 'utf-8');
}
it('runs a browser-macro and marks the job succeeded with stdout saved to workspace', async () => {
const owner = repo.createUser({ email: 'eve@x.com', name: 'eve', role: 'user', status: 'active' });
writeScript(owner.id, 'hello.js', `---
params:
- name: name
type: string
---
async function main({ params }) {
console.log('hello from script');
return { greeting: 'hello', name: params.name };
}
module.exports = main;
`);
const sched = await repo.createScheduledTask({
body: '', // body is unused for script kind
cronExpression: '0 9 * * *',
nextRunAt: '2099-01-01 09:00:00',
ownerId: owner.id,
visibility: 'private',
taskKind: 'script',
scriptName: 'hello',
scriptParams: JSON.stringify({ name: 'world' }),
});
await scheduler.executeById(sched.id);
const after = await repo.getScheduledTask(sched.id);
expect(after?.lastJobId).toBeTruthy();
const job = await repo.getJob(after!.lastJobId!);
expect(job?.status).toBe('succeeded');
const task = await repo.getLocalTask(job!.issueNumber);
const workspacePath = task!.workspacePath!;
expect(existsSync(join(workspacePath, 'output', 'script-output.txt'))).toBe(true);
const output = readFileSync(join(workspacePath, 'output', 'script-output.txt'), 'utf-8');
expect(output).toContain('hello');
expect(output).toContain('world');
});
it('marks the job failed when the script throws and saves the error to logs/', async () => {
const owner = repo.createUser({ email: 'mallory@x.com', name: 'mallory', role: 'user', status: 'active' });
writeScript(owner.id, 'boom.js', `async function main() { throw new Error('intentional explosion'); }
module.exports = main;
`);
const sched = await repo.createScheduledTask({
body: '',
cronExpression: '0 9 * * *',
nextRunAt: '2099-01-01 09:00:00',
ownerId: owner.id,
taskKind: 'script',
scriptName: 'boom',
});
await scheduler.executeById(sched.id);
const after = await repo.getScheduledTask(sched.id);
const job = await repo.getJob(after!.lastJobId!);
expect(job?.status).toBe('failed');
expect(job?.errorSummary ?? '').toContain('intentional explosion');
const task = await repo.getLocalTask(job!.issueNumber);
const errLog = readFileSync(join(task!.workspacePath!, 'logs', 'script-error.log'), 'utf-8');
expect(errLog).toContain('intentional explosion');
});
it('marks the job failed when the script does not exist', async () => {
const owner = repo.createUser({ email: 'oscar@x.com', name: 'oscar', role: 'user', status: 'active' });
const sched = await repo.createScheduledTask({
body: '',
cronExpression: '0 9 * * *',
nextRunAt: '2099-01-01 09:00:00',
ownerId: owner.id,
taskKind: 'script',
scriptName: 'does-not-exist',
});
await scheduler.executeById(sched.id);
const after = await repo.getScheduledTask(sched.id);
const job = await repo.getJob(after!.lastJobId!);
expect(job?.status).toBe('failed');
expect(job?.errorSummary ?? '').toMatch(/not found/i);
});
it('throws when scriptName is null for task_kind=script (defensive guard)', async () => {
const owner = repo.createUser({ email: 'peggy@x.com', name: 'peggy', role: 'user', status: 'active' });
const sched = await repo.createScheduledTask({
body: '',
cronExpression: '0 9 * * *',
nextRunAt: '2099-01-01 09:00:00',
ownerId: owner.id,
taskKind: 'script',
// scriptName intentionally omitted
});
await expect(scheduler.executeById(sched.id)).rejects.toThrow(/script_name is null/);
});
it('runs as the "local" user when the schedule has no owner (no-auth mode)', async () => {
// Regression: no-auth deployments store scheduled tasks with ownerId=null
// (scheduled-tasks-api has no authenticated user). executeScriptScheduledTask
// used to throw "requires an owner_id", so no-auth scheduled scripts could
// never run. Now it falls back to the 'local' namespace (same as the
// RunUserScript tool), resolving the macro from data/users/local/browser-macros/.
writeScript('local', 'noauth.js', `async function main() {
return { ran: 'no-auth-local' };
}
module.exports = main;
`);
const sched = await repo.createScheduledTask({
body: '',
cronExpression: '0 9 * * *',
nextRunAt: '2099-01-01 09:00:00',
// ownerId intentionally omitted → null, as in no-auth mode
taskKind: 'script',
scriptName: 'noauth',
});
await scheduler.executeById(sched.id);
const after = await repo.getScheduledTask(sched.id);
expect(after?.lastJobId).toBeTruthy();
const job = await repo.getJob(after!.lastJobId!);
expect(job?.status).toBe('succeeded');
const task = await repo.getLocalTask(job!.issueNumber);
const output = readFileSync(join(task!.workspacePath!, 'output', 'script-output.txt'), 'utf-8');
expect(output).toContain('no-auth-local'); // script's return value was saved
// The audit row records the resolved 'local' owner, not null.
const audits = repo.getDb()
.prepare("SELECT detail FROM audit_log WHERE job_id = ? AND action = 'user_script_run'")
.all(after!.lastJobId!) as Array<{ detail: string }>;
expect(audits).toHaveLength(1);
expect((JSON.parse(audits[0].detail) as { userId: string }).userId).toBe('local');
});
it('refuses to run when the user-script gate is disabled', async () => {
const gatedScheduler = new Scheduler(repo, join(tempDir, 'workspaces'), {
userFolderRoot,
masterKeyPath: join(tempDir, 'master.key'),
getUserScriptGate: () => ({ enabled: false }),
});
const owner = repo.createUser({ email: 'denied@x.com', name: 'denied', role: 'user', status: 'active' });
writeScript(owner.id, 'noop.js', `async function main(){return 0;}\nmodule.exports=main;\n`);
const sched = await repo.createScheduledTask({
body: '',
cronExpression: '0 9 * * *',
nextRunAt: '2099-01-01 09:00:00',
ownerId: owner.id,
taskKind: 'script',
scriptName: 'noop',
});
await expect(gatedScheduler.executeById(sched.id)).rejects.toThrow(/user_scripts_enabled=false/);
});
it('refuses to run when the owner is not in the per-user allowlist', async () => {
const gatedScheduler = new Scheduler(repo, join(tempDir, 'workspaces'), {
userFolderRoot,
masterKeyPath: join(tempDir, 'master.key'),
getUserScriptGate: () => ({ enabled: true, allowUserids: ['someone-else'] }),
});
const owner = repo.createUser({ email: 'outsider@x.com', name: 'outsider', role: 'user', status: 'active' });
writeScript(owner.id, 'noop.js', `async function main(){return 0;}\nmodule.exports=main;\n`);
const sched = await repo.createScheduledTask({
body: '',
cronExpression: '0 9 * * *',
nextRunAt: '2099-01-01 09:00:00',
ownerId: owner.id,
taskKind: 'script',
scriptName: 'noop',
});
await expect(gatedScheduler.executeById(sched.id)).rejects.toThrow(/not in tools.user_scripts_allow_userids/);
});
it('records a user_script_run audit row after a successful script execution', async () => {
const owner = repo.createUser({ email: 'audit@x.com', name: 'audit', role: 'user', status: 'active' });
writeScript(owner.id, 'tick.js', `async function main(){return 'ok';}\nmodule.exports=main;\n`);
const sched = await repo.createScheduledTask({
body: '',
cronExpression: '0 9 * * *',
nextRunAt: '2099-01-01 09:00:00',
ownerId: owner.id,
taskKind: 'script',
scriptName: 'tick',
});
await scheduler.executeById(sched.id);
const after = await repo.getScheduledTask(sched.id);
const audits = repo.getDb()
.prepare("SELECT action, actor, detail FROM audit_log WHERE job_id = ? ORDER BY id")
.all(after!.lastJobId!) as Array<{ action: string; actor: string; detail: string }>;
const userScriptRow = audits.find(a => a.action === 'user_script_run');
expect(userScriptRow).toBeDefined();
expect(userScriptRow!.actor).toBe('scheduler');
const detail = JSON.parse(userScriptRow!.detail) as { ok: boolean; scriptName: string; userId: string };
expect(detail.ok).toBe(true);
expect(detail.scriptName).toBe('tick');
expect(detail.userId).toBe(owner.id);
});
it('agent task_kind (existing default behaviour) still goes through the worker queue', async () => {
const owner = repo.createUser({ email: 'trent@x.com', name: 'trent', role: 'user', status: 'active' });
const sched = await repo.createScheduledTask({
body: 'do agent-y stuff',
pieceName: 'chat',
cronExpression: '0 9 * * *',
nextRunAt: '2099-01-01 09:00:00',
ownerId: owner.id,
// taskKind omitted → defaults to 'agent'
});
await scheduler.executeById(sched.id);
const after = await repo.getScheduledTask(sched.id);
const job = await repo.getJob(after!.lastJobId!);
// Agent path leaves the job queued — worker picks it up later.
expect(job?.status).toBe('queued');
expect(after?.taskKind).toBe('agent');
});
});