feat: add scheduled tasks with locale-aware scheduling and safer desktop quit flow (#920)
* feat: keep desktop app running in background when closing last window Closing last window hides it instead of quitting — sidecar keeps running Cmd+Q now shows confirmation dialog warning about stopping background processes Clicking dock icon reopens hidden window or creates a new one * docs: add scheduled tasks impl plan * feat: add scheduled tasks runtime, api, and ui * feat: conditionally confirm desktop quit on risks * chore: remove scheduled tasks plan doc * feat: add scheduled tasks runtime and management UI Add server-side scheduled task runtime with project-backed config persistence Add task scheduling UI and API integration for creating and editing schedules Add tests for runtime scheduling behavior and project config validation * feat: add locale display preferences for scheduled tasks Add Appearance settings for time format and week start with settings.json persistence Apply preferences in scheduled task editor for time display and weekday ordering Rename Thinking level control and disable it when model variants are unavailable * feat: improve scheduled tasks editor and sidebar action order Reorder session sidebar header actions to separate creation and management tools Polish scheduled tasks dialog layout and controls for clearer editing flow * feat: polish scheduled task editor usability Improve scheduled task dialog layout for clearer scheduling controls Refine time and weekday inputs for more intuitive task configuration Update editor labels and control states for better model variant guidance * feat: add prompt autocomplete and command-aware scheduled runs Add @ and / autocomplete support to task, multi-run, and agent manager prompt fields Fix agent mention selection so subagents can be inserted from @ suggestions Run scheduled prompts as commands when they match slash commands, with message fallback
This commit is contained in:
committed by
GitHub
parent
ea5c19e934
commit
4f228f768d
@@ -0,0 +1,558 @@
|
||||
import { DateTime, IANAZone } from 'luxon';
|
||||
import parser from 'cron-parser';
|
||||
|
||||
const PROJECT_CONFIG_VERSION = 1;
|
||||
const MAX_TASK_NAME_LENGTH = 80;
|
||||
const MAX_TASK_PROMPT_LENGTH = 20_000;
|
||||
const MAX_CRON_LENGTH = 200;
|
||||
const MAX_LAST_ERROR_LENGTH = 2_000;
|
||||
|
||||
const asNonEmptyString = (value) => {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
};
|
||||
|
||||
const clampLength = (value, maxLength) => {
|
||||
if (typeof value !== 'string') {
|
||||
return '';
|
||||
}
|
||||
return value.length > maxLength ? value.slice(0, maxLength) : value;
|
||||
};
|
||||
|
||||
const normalizeStatus = (value) => {
|
||||
if (value === 'running' || value === 'success' || value === 'error' || value === 'idle') {
|
||||
return value;
|
||||
}
|
||||
return 'idle';
|
||||
};
|
||||
|
||||
const normalizeTimeValue = (value) => {
|
||||
const time = asNonEmptyString(value);
|
||||
if (!time) {
|
||||
return null;
|
||||
}
|
||||
if (!/^([01]\d|2[0-3]):([0-5]\d)$/.test(time)) {
|
||||
return null;
|
||||
}
|
||||
return time;
|
||||
};
|
||||
|
||||
const normalizeDateValue = (value) => {
|
||||
const date = asNonEmptyString(value);
|
||||
if (!date) {
|
||||
return null;
|
||||
}
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
return null;
|
||||
}
|
||||
const parsed = DateTime.fromISO(date, { zone: 'UTC' });
|
||||
if (!parsed.isValid || parsed.toFormat('yyyy-LL-dd') !== date) {
|
||||
return null;
|
||||
}
|
||||
return date;
|
||||
};
|
||||
|
||||
const normalizeWeekdays = (value) => {
|
||||
if (!Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const unique = new Set();
|
||||
for (const entry of value) {
|
||||
if (!Number.isInteger(entry)) {
|
||||
return null;
|
||||
}
|
||||
if (entry < 0 || entry > 6) {
|
||||
return null;
|
||||
}
|
||||
unique.add(entry);
|
||||
}
|
||||
|
||||
if (unique.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Array.from(unique).sort((a, b) => a - b);
|
||||
};
|
||||
|
||||
const resolveScheduleTimes = (value, existingSchedule) => {
|
||||
const times = [];
|
||||
|
||||
if (Array.isArray(value?.times)) {
|
||||
for (const item of value.times) {
|
||||
const normalized = normalizeTimeValue(item);
|
||||
if (!normalized) {
|
||||
throw new Error('schedule.times must contain HH:mm values');
|
||||
}
|
||||
times.push(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
const legacySingleTime = normalizeTimeValue(value?.time);
|
||||
if (legacySingleTime) {
|
||||
times.push(legacySingleTime);
|
||||
}
|
||||
|
||||
if (times.length === 0 && Array.isArray(existingSchedule?.times)) {
|
||||
for (const item of existingSchedule.times) {
|
||||
const normalized = normalizeTimeValue(item);
|
||||
if (normalized) {
|
||||
times.push(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueSorted = Array.from(new Set(times)).sort((a, b) => a.localeCompare(b));
|
||||
if (uniqueSorted.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return uniqueSorted;
|
||||
};
|
||||
|
||||
const resolveDefaultTimezone = () => {
|
||||
const resolved = DateTime.local().zoneName;
|
||||
if (resolved && IANAZone.isValidZone(resolved)) {
|
||||
return resolved;
|
||||
}
|
||||
return 'UTC';
|
||||
};
|
||||
|
||||
const normalizeTimezone = (value, fallback = resolveDefaultTimezone()) => {
|
||||
const timezone = asNonEmptyString(value);
|
||||
if (!timezone) {
|
||||
return fallback;
|
||||
}
|
||||
return IANAZone.isValidZone(timezone) ? timezone : null;
|
||||
};
|
||||
|
||||
const validateCronExpression = (expression, timezone) => {
|
||||
try {
|
||||
const iterator = parser.parseExpression(expression, {
|
||||
tz: timezone,
|
||||
currentDate: new Date(),
|
||||
});
|
||||
iterator.next();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeSchedule = (value, existingSchedule) => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
throw new Error('schedule is required');
|
||||
}
|
||||
|
||||
const kind = asNonEmptyString(value.kind);
|
||||
if (kind !== 'daily' && kind !== 'weekly' && kind !== 'once' && kind !== 'cron') {
|
||||
throw new Error('schedule.kind must be daily, weekly, once, or cron');
|
||||
}
|
||||
|
||||
const fallbackTimezone = existingSchedule?.timezone || resolveDefaultTimezone();
|
||||
const timezone = normalizeTimezone(value.timezone, fallbackTimezone);
|
||||
if (!timezone) {
|
||||
throw new Error('schedule.timezone must be a valid IANA timezone');
|
||||
}
|
||||
|
||||
if (kind === 'daily') {
|
||||
const times = resolveScheduleTimes(value, existingSchedule);
|
||||
if (!times) {
|
||||
throw new Error('schedule.times must include at least one HH:mm value for daily schedule');
|
||||
}
|
||||
return { kind, times, timezone };
|
||||
}
|
||||
|
||||
if (kind === 'weekly') {
|
||||
const times = resolveScheduleTimes(value, existingSchedule);
|
||||
if (!times) {
|
||||
throw new Error('schedule.times must include at least one HH:mm value for weekly schedule');
|
||||
}
|
||||
const weekdays = normalizeWeekdays(value.weekdays);
|
||||
if (!weekdays) {
|
||||
throw new Error('schedule.weekdays must include values from 0 to 6 for weekly schedule');
|
||||
}
|
||||
return { kind, times, weekdays, timezone };
|
||||
}
|
||||
|
||||
if (kind === 'once') {
|
||||
const date = normalizeDateValue(value.date);
|
||||
if (!date) {
|
||||
throw new Error('schedule.date must be YYYY-MM-DD for once schedule');
|
||||
}
|
||||
|
||||
const time = normalizeTimeValue(value.time);
|
||||
if (!time) {
|
||||
throw new Error('schedule.time must be HH:mm for once schedule');
|
||||
}
|
||||
|
||||
return { kind, date, time, timezone };
|
||||
}
|
||||
|
||||
const cron = clampLength(asNonEmptyString(value.cron) || '', MAX_CRON_LENGTH);
|
||||
if (!cron) {
|
||||
throw new Error('schedule.cron is required for cron schedule');
|
||||
}
|
||||
|
||||
if (!validateCronExpression(cron, timezone)) {
|
||||
throw new Error('schedule.cron is invalid');
|
||||
}
|
||||
|
||||
return { kind, cron, timezone };
|
||||
};
|
||||
|
||||
const normalizeExecution = (value) => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
throw new Error('execution is required');
|
||||
}
|
||||
|
||||
const prompt = clampLength(asNonEmptyString(value.prompt) || '', MAX_TASK_PROMPT_LENGTH);
|
||||
const providerID = asNonEmptyString(value.providerID);
|
||||
const modelID = asNonEmptyString(value.modelID);
|
||||
const variant = asNonEmptyString(value.variant);
|
||||
const agent = asNonEmptyString(value.agent);
|
||||
|
||||
if (!prompt) {
|
||||
throw new Error('execution.prompt is required');
|
||||
}
|
||||
if (!providerID) {
|
||||
throw new Error('execution.providerID is required');
|
||||
}
|
||||
if (!modelID) {
|
||||
throw new Error('execution.modelID is required');
|
||||
}
|
||||
|
||||
return {
|
||||
prompt,
|
||||
providerID,
|
||||
modelID,
|
||||
...(variant ? { variant } : {}),
|
||||
...(agent ? { agent } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeState = (value, fallback) => {
|
||||
const source = value && typeof value === 'object' ? value : fallback || {};
|
||||
const lastRunAt = typeof source.lastRunAt === 'number' && Number.isFinite(source.lastRunAt)
|
||||
? Math.max(0, Math.round(source.lastRunAt))
|
||||
: undefined;
|
||||
const lastDurationMs = typeof source.lastDurationMs === 'number' && Number.isFinite(source.lastDurationMs)
|
||||
? Math.max(0, Math.round(source.lastDurationMs))
|
||||
: undefined;
|
||||
const nextRunAt = typeof source.nextRunAt === 'number' && Number.isFinite(source.nextRunAt)
|
||||
? Math.max(0, Math.round(source.nextRunAt))
|
||||
: undefined;
|
||||
const lastSessionId = asNonEmptyString(source.lastSessionId);
|
||||
const lastErrorRaw = asNonEmptyString(source.lastError);
|
||||
const lastError = lastErrorRaw ? clampLength(lastErrorRaw, MAX_LAST_ERROR_LENGTH) : undefined;
|
||||
|
||||
return {
|
||||
createdAt: typeof source.createdAt === 'number' && Number.isFinite(source.createdAt)
|
||||
? Math.max(0, Math.round(source.createdAt))
|
||||
: Date.now(),
|
||||
updatedAt: typeof source.updatedAt === 'number' && Number.isFinite(source.updatedAt)
|
||||
? Math.max(0, Math.round(source.updatedAt))
|
||||
: Date.now(),
|
||||
lastStatus: normalizeStatus(source.lastStatus),
|
||||
...(typeof lastRunAt === 'number' ? { lastRunAt } : {}),
|
||||
...(typeof lastDurationMs === 'number' ? { lastDurationMs } : {}),
|
||||
...(typeof nextRunAt === 'number' ? { nextRunAt } : {}),
|
||||
...(lastSessionId ? { lastSessionId } : {}),
|
||||
...(lastError ? { lastError } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeTaskForStorage = (value, options) => {
|
||||
const {
|
||||
now,
|
||||
createId,
|
||||
existingTask,
|
||||
allowCreate,
|
||||
} = options;
|
||||
|
||||
if (!value || typeof value !== 'object') {
|
||||
throw new Error('task is required');
|
||||
}
|
||||
|
||||
const incomingId = asNonEmptyString(value.id);
|
||||
const existingId = asNonEmptyString(existingTask?.id);
|
||||
|
||||
if (existingTask) {
|
||||
if (incomingId && incomingId !== existingId) {
|
||||
throw new Error('task.id is immutable');
|
||||
}
|
||||
}
|
||||
|
||||
if (!existingTask && incomingId && !allowCreate) {
|
||||
throw new Error('task.id does not exist');
|
||||
}
|
||||
|
||||
const id = existingId || incomingId || createId();
|
||||
const name = clampLength(asNonEmptyString(value.name) || '', MAX_TASK_NAME_LENGTH);
|
||||
if (!name) {
|
||||
throw new Error('task.name is required');
|
||||
}
|
||||
|
||||
const enabled = typeof value.enabled === 'boolean'
|
||||
? value.enabled
|
||||
: (existingTask?.enabled ?? true);
|
||||
|
||||
const schedule = normalizeSchedule(value.schedule, existingTask?.schedule);
|
||||
const execution = normalizeExecution(value.execution);
|
||||
|
||||
const nowMs = Math.max(0, Math.round(now));
|
||||
const baseState = normalizeState(value.state, existingTask?.state);
|
||||
const state = {
|
||||
...baseState,
|
||||
createdAt: existingTask?.state?.createdAt ?? baseState.createdAt ?? nowMs,
|
||||
updatedAt: nowMs,
|
||||
};
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
enabled,
|
||||
schedule,
|
||||
execution,
|
||||
state,
|
||||
};
|
||||
};
|
||||
|
||||
const createEmptyProjectConfig = () => ({
|
||||
version: PROJECT_CONFIG_VERSION,
|
||||
scheduledTasks: [],
|
||||
});
|
||||
|
||||
export const createProjectConfigRuntime = (deps) => {
|
||||
const {
|
||||
fsPromises,
|
||||
path,
|
||||
projectsDirPath,
|
||||
createTaskID,
|
||||
} = deps;
|
||||
|
||||
const taskIDFactory = typeof createTaskID === 'function'
|
||||
? createTaskID
|
||||
: (() => {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `task_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
});
|
||||
|
||||
const writeLocks = new Map();
|
||||
|
||||
const sanitizeProjectID = (projectID) => {
|
||||
const value = asNonEmptyString(projectID);
|
||||
if (!value) {
|
||||
throw new Error('projectId is required');
|
||||
}
|
||||
if (!/^[a-zA-Z0-9._:-]+$/.test(value)) {
|
||||
throw new Error('projectId contains unsupported characters');
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const resolveProjectConfigPath = (projectID) => {
|
||||
const safeProjectID = sanitizeProjectID(projectID);
|
||||
return path.join(projectsDirPath, `${safeProjectID}.json`);
|
||||
};
|
||||
|
||||
const readProjectConfigFromDisk = async (projectID) => {
|
||||
const filePath = resolveProjectConfigPath(projectID);
|
||||
|
||||
try {
|
||||
const raw = await fsPromises.readFile(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return createEmptyProjectConfig();
|
||||
}
|
||||
const tasksRaw = Array.isArray(parsed.scheduledTasks) ? parsed.scheduledTasks : [];
|
||||
const now = Date.now();
|
||||
const scheduledTasks = [];
|
||||
for (const task of tasksRaw) {
|
||||
try {
|
||||
const normalized = normalizeTaskForStorage(task, {
|
||||
now,
|
||||
createId: taskIDFactory,
|
||||
existingTask: null,
|
||||
allowCreate: true,
|
||||
});
|
||||
scheduledTasks.push(normalized);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
version: PROJECT_CONFIG_VERSION,
|
||||
scheduledTasks,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||
return createEmptyProjectConfig();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const writeProjectConfigToDisk = async (projectID, config) => {
|
||||
const filePath = resolveProjectConfigPath(projectID);
|
||||
const parentDirectory = path.dirname(filePath);
|
||||
const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
await fsPromises.mkdir(parentDirectory, { recursive: true });
|
||||
await fsPromises.writeFile(temporaryPath, JSON.stringify(config, null, 2), 'utf8');
|
||||
await fsPromises.rename(temporaryPath, filePath);
|
||||
};
|
||||
|
||||
const withProjectWriteLock = async (projectID, mutate) => {
|
||||
const key = sanitizeProjectID(projectID);
|
||||
const previous = writeLocks.get(key) || Promise.resolve();
|
||||
let release;
|
||||
const next = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const chained = previous.finally(() => next);
|
||||
writeLocks.set(key, chained);
|
||||
|
||||
await previous;
|
||||
try {
|
||||
return await mutate();
|
||||
} finally {
|
||||
release();
|
||||
const current = writeLocks.get(key);
|
||||
if (current === chained) {
|
||||
writeLocks.delete(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const listScheduledTasks = async (projectID) => {
|
||||
const config = await readProjectConfigFromDisk(projectID);
|
||||
return config.scheduledTasks;
|
||||
};
|
||||
|
||||
const upsertScheduledTask = async (projectID, taskInput) => {
|
||||
return withProjectWriteLock(projectID, async () => {
|
||||
const now = Date.now();
|
||||
const current = await readProjectConfigFromDisk(projectID);
|
||||
const incomingID = asNonEmptyString(taskInput?.id);
|
||||
const existingIndex = incomingID
|
||||
? current.scheduledTasks.findIndex((task) => task.id === incomingID)
|
||||
: -1;
|
||||
const existingTask = existingIndex >= 0 ? current.scheduledTasks[existingIndex] : null;
|
||||
|
||||
const normalizedTask = normalizeTaskForStorage(taskInput, {
|
||||
now,
|
||||
createId: taskIDFactory,
|
||||
existingTask,
|
||||
allowCreate: true,
|
||||
});
|
||||
|
||||
const nextTasks = current.scheduledTasks.slice();
|
||||
const created = !existingTask;
|
||||
if (existingIndex >= 0) {
|
||||
nextTasks[existingIndex] = normalizedTask;
|
||||
} else {
|
||||
nextTasks.push(normalizedTask);
|
||||
}
|
||||
|
||||
const nextConfig = {
|
||||
version: PROJECT_CONFIG_VERSION,
|
||||
scheduledTasks: nextTasks,
|
||||
};
|
||||
await writeProjectConfigToDisk(projectID, nextConfig);
|
||||
|
||||
return {
|
||||
task: normalizedTask,
|
||||
tasks: nextTasks,
|
||||
created,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const deleteScheduledTask = async (projectID, taskID) => {
|
||||
return withProjectWriteLock(projectID, async () => {
|
||||
const normalizedTaskID = asNonEmptyString(taskID);
|
||||
if (!normalizedTaskID) {
|
||||
throw new Error('taskId is required');
|
||||
}
|
||||
|
||||
const current = await readProjectConfigFromDisk(projectID);
|
||||
const nextTasks = current.scheduledTasks.filter((task) => task.id !== normalizedTaskID);
|
||||
const deleted = nextTasks.length !== current.scheduledTasks.length;
|
||||
|
||||
if (deleted) {
|
||||
await writeProjectConfigToDisk(projectID, {
|
||||
version: PROJECT_CONFIG_VERSION,
|
||||
scheduledTasks: nextTasks,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
deleted,
|
||||
tasks: nextTasks,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const updateScheduledTaskState = async (projectID, taskID, statePatch) => {
|
||||
return withProjectWriteLock(projectID, async () => {
|
||||
const normalizedTaskID = asNonEmptyString(taskID);
|
||||
if (!normalizedTaskID) {
|
||||
throw new Error('taskId is required');
|
||||
}
|
||||
|
||||
const current = await readProjectConfigFromDisk(projectID);
|
||||
const taskIndex = current.scheduledTasks.findIndex((task) => task.id === normalizedTaskID);
|
||||
if (taskIndex === -1) {
|
||||
return { task: null, tasks: current.scheduledTasks };
|
||||
}
|
||||
|
||||
const currentTask = current.scheduledTasks[taskIndex];
|
||||
const patchObject = statePatch && typeof statePatch === 'object' ? statePatch : {};
|
||||
const nextTask = {
|
||||
...currentTask,
|
||||
state: normalizeState(
|
||||
{
|
||||
...currentTask.state,
|
||||
...patchObject,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
currentTask.state,
|
||||
),
|
||||
};
|
||||
|
||||
const nextTasks = current.scheduledTasks.slice();
|
||||
nextTasks[taskIndex] = nextTask;
|
||||
|
||||
await writeProjectConfigToDisk(projectID, {
|
||||
version: PROJECT_CONFIG_VERSION,
|
||||
scheduledTasks: nextTasks,
|
||||
});
|
||||
|
||||
return {
|
||||
task: nextTask,
|
||||
tasks: nextTasks,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
listScheduledTasks,
|
||||
upsertScheduledTask,
|
||||
deleteScheduledTask,
|
||||
updateScheduledTaskState,
|
||||
resolveProjectConfigPath,
|
||||
};
|
||||
};
|
||||
|
||||
export {
|
||||
MAX_TASK_NAME_LENGTH,
|
||||
MAX_TASK_PROMPT_LENGTH,
|
||||
MAX_CRON_LENGTH,
|
||||
MAX_LAST_ERROR_LENGTH,
|
||||
normalizeTaskForStorage,
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { mkdtemp, rm } from 'fs/promises';
|
||||
import { createProjectConfigRuntime } from './project-config.js';
|
||||
|
||||
const createRuntime = async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-scheduled-project-config-'));
|
||||
const runtime = createProjectConfigRuntime({
|
||||
fsPromises: await import('fs/promises'),
|
||||
path,
|
||||
projectsDirPath: tempRoot,
|
||||
createTaskID: () => 'task-fixed-id',
|
||||
});
|
||||
return {
|
||||
runtime,
|
||||
cleanup: async () => {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe('project-config runtime', () => {
|
||||
it('creates and persists a scheduled task', async () => {
|
||||
const { runtime, cleanup } = await createRuntime();
|
||||
try {
|
||||
const result = await runtime.upsertScheduledTask('project-test', {
|
||||
name: 'Nightly digest',
|
||||
enabled: true,
|
||||
schedule: {
|
||||
kind: 'daily',
|
||||
time: '09:30',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
execution: {
|
||||
prompt: 'Summarize repository changes',
|
||||
providerID: 'openai',
|
||||
modelID: 'gpt-4.1',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.created).toBe(true);
|
||||
expect(result.task.id).toBe('task-fixed-id');
|
||||
const reloaded = await runtime.listScheduledTasks('project-test');
|
||||
expect(reloaded).toHaveLength(1);
|
||||
expect(reloaded[0].name).toBe('Nightly digest');
|
||||
expect(reloaded[0].schedule.timezone).toBe('UTC');
|
||||
expect(reloaded[0].schedule.times).toEqual(['09:30']);
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid cron expressions', async () => {
|
||||
const { runtime, cleanup } = await createRuntime();
|
||||
try {
|
||||
await expect(runtime.upsertScheduledTask('project-test', {
|
||||
name: 'Invalid cron task',
|
||||
enabled: true,
|
||||
schedule: {
|
||||
kind: 'cron',
|
||||
cron: 'invalid cron',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
execution: {
|
||||
prompt: 'Run checks',
|
||||
providerID: 'openai',
|
||||
modelID: 'gpt-4.1',
|
||||
},
|
||||
})).rejects.toThrow('schedule.cron is invalid');
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts one-time schedule with date and time', async () => {
|
||||
const { runtime, cleanup } = await createRuntime();
|
||||
try {
|
||||
const result = await runtime.upsertScheduledTask('project-test', {
|
||||
name: 'One-time review',
|
||||
enabled: true,
|
||||
schedule: {
|
||||
kind: 'once',
|
||||
date: '2026-04-20',
|
||||
time: '13:45',
|
||||
timezone: 'Europe/Kyiv',
|
||||
},
|
||||
execution: {
|
||||
prompt: 'Create a release summary',
|
||||
providerID: 'openai',
|
||||
modelID: 'gpt-4.1',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.task.schedule.kind).toBe('once');
|
||||
expect(result.task.schedule.date).toBe('2026-04-20');
|
||||
expect(result.task.schedule.time).toBe('13:45');
|
||||
expect(result.task.schedule.timezone).toBe('Europe/Kyiv');
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user