594 lines
23 KiB
TypeScript
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');
|
|
});
|
|
});
|