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 => { 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 => { 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 => { 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'); }); });