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:
Bohdan Triapitsyn
2026-04-16 15:55:08 +03:00
committed by GitHub
parent ea5c19e934
commit 4f228f768d
28 changed files with 5231 additions and 52 deletions
+2
View File
@@ -48,11 +48,13 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"cron-parser": "^4.9.0",
"express": "^5.1.0",
"ghostty-web": "0.3.0",
"http-proxy-middleware": "^3.0.5",
"jose": "^6.1.3",
"jsonc-parser": "^3.3.1",
"luxon": "^3.5.0",
"next-themes": "^0.4.6",
"node-pty": "1.2.0-beta.12",
"openai": "^4.79.0",
+51
View File
@@ -56,6 +56,7 @@ import { createOpenCodeResolutionRuntime } from './lib/opencode/opencode-resolut
import { createBootstrapRuntime } from './lib/opencode/bootstrap-runtime.js';
import { createSessionRuntime } from './lib/opencode/session-runtime.js';
import { createOpenCodeWatcherRuntime } from './lib/opencode/watcher.js';
import { createScheduledTasksRuntime } from './lib/scheduled-tasks/runtime.js';
import { createServerStartupRuntime } from './lib/opencode/server-startup-runtime.js';
import { createTunnelWiringRuntime } from './lib/opencode/tunnel-wiring-runtime.js';
import { createStartupPipelineRuntime } from './lib/opencode/startup-pipeline-runtime.js';
@@ -66,6 +67,7 @@ import { createNotificationTriggerRuntime } from './lib/notifications/runtime.js
import { createPushRuntime } from './lib/notifications/push-runtime.js';
import { createNotificationTemplateRuntime } from './lib/notifications/template-runtime.js';
import { createGracefulShutdownRuntime } from './lib/opencode/shutdown-runtime.js';
import { createProjectConfigRuntime } from './lib/projects/project-config.js';
import webPush from 'web-push';
const __filename = fileURLToPath(import.meta.url);
@@ -74,6 +76,7 @@ const __dirname = path.dirname(__filename);
const DEFAULT_PORT = 3000;
const DESKTOP_NOTIFY_PREFIX = '[OpenChamberDesktopNotify] ';
const uiNotificationClients = new Set();
const uiOpenChamberEventClients = new Set();
const HEALTH_CHECK_INTERVAL = 15000;
const SHUTDOWN_TIMEOUT = 10000;
const MODELS_DEV_API_URL = 'https://models.dev/api.json';
@@ -146,6 +149,7 @@ const sanitizeProjects = (...args) => settingsNormalizationRuntime.sanitizeProje
const OPENCHAMBER_USER_CONFIG_ROOT = path.join(os.homedir(), '.config', 'openchamber');
const OPENCHAMBER_USER_THEMES_DIR = path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'themes');
const OPENCHAMBER_PROJECTS_CONFIG_DIR = path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'projects');
const MAX_THEME_JSON_BYTES = 512 * 1024;
@@ -313,6 +317,12 @@ const sessionRuntime = createSessionRuntime({
getNotificationClients: () => uiNotificationClients,
});
const projectConfigRuntime = createProjectConfigRuntime({
fsPromises,
path,
projectsDirPath: OPENCHAMBER_PROJECTS_CONFIG_DIR,
});
// HMR-persistent state via globalThis
// These values survive Vite HMR reloads to prevent zombie OpenCode processes
const hmrStateRuntime = createHmrStateRuntime({
@@ -754,6 +764,36 @@ const waitForOpenCodeReady = (...args) => openCodeLifecycleRuntime.waitForOpenCo
const waitForAgentPresence = (...args) => openCodeLifecycleRuntime.waitForAgentPresence(...args);
const refreshOpenCodeAfterConfigChange = (...args) => openCodeLifecycleRuntime.refreshOpenCodeAfterConfigChange(...args);
const startHealthMonitoring = () => openCodeLifecycleRuntime.startHealthMonitoring(HEALTH_CHECK_INTERVAL);
const scheduledTasksRuntime = createScheduledTasksRuntime({
projectConfigRuntime,
listProjects: async () => {
const settings = await readSettingsFromDiskMigrated();
return sanitizeProjects(settings?.projects || []);
},
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
waitForOpenCodeReady,
emitTaskRunEvent: (event) => {
for (const client of uiOpenChamberEventClients) {
try {
writeSseEvent(client, {
type: 'openchamber:scheduled-task-ran',
properties: {
projectId: event.projectID,
taskId: event.taskID,
ranAt: event.ranAt,
status: event.status,
...(event.sessionID ? { sessionId: event.sessionID } : {}),
},
});
} catch {
uiOpenChamberEventClients.delete(client);
}
}
},
logger: console,
});
const ensureGlobalWatcherStarted = async () => {
if (globalWatcherStartPromise) {
return globalWatcherStartPromise;
@@ -820,6 +860,7 @@ const gracefulShutdownRuntime = createGracefulShutdownRuntime({
activeTunnelController = value;
},
tunnelAuthController,
scheduledTasksRuntime,
});
const gracefulShutdown = (...args) => gracefulShutdownRuntime.gracefulShutdown(...args);
@@ -969,6 +1010,10 @@ async function main(options = {}) {
getOpenCodeAuthHeaders,
getOpenCodePort: () => openCodePort,
buildAugmentedPath,
projectConfigRuntime,
scheduledTasksRuntime,
getOpenChamberEventClients: () => uiOpenChamberEventClients,
writeSseEvent,
});
const startupPipelineResult = await startupPipelineRuntime.run({
@@ -1014,6 +1059,12 @@ async function main(options = {}) {
});
terminalRuntime = startupPipelineResult.terminalRuntime;
try {
await scheduledTasksRuntime.start();
} catch (error) {
console.warn('[ScheduledTasks] Failed to start runtime:', error?.message || error);
}
return {
expressApp: app,
httpServer: server,
@@ -6,6 +6,7 @@ import { registerMagicPromptRoutes } from '../magic-prompts/routes.js';
import { registerConfigEntityRoutes } from './config-entity-routes.js';
import { registerSettingsUtilityRoutes } from './core-routes.js';
import { registerProjectIconRoutes } from './project-icon-routes.js';
import { registerScheduledTaskRoutes } from '../scheduled-tasks/routes.js';
import { registerSkillRoutes } from './skill-routes.js';
import { registerOpenCodeRoutes } from './routes.js';
@@ -52,6 +53,10 @@ export const createFeatureRoutesRuntime = (dependencies) => {
getOpenCodeAuthHeaders,
getOpenCodePort,
buildAugmentedPath,
projectConfigRuntime,
scheduledTasksRuntime,
getOpenChamberEventClients,
writeSseEvent,
} = routeDependencies;
const { getProviderSources, removeProviderConfig } = await import('./index.js');
@@ -91,6 +96,15 @@ export const createFeatureRoutesRuntime = (dependencies) => {
resolveGitBinaryForSpawn,
});
registerScheduledTaskRoutes(app, {
readSettingsFromDiskMigrated,
sanitizeProjects,
projectConfigRuntime,
scheduledTasksRuntime,
getOpenChamberEventClients,
writeSseEvent,
});
const {
getAgentSources,
getAgentConfig,
@@ -307,6 +307,18 @@ export const createSettingsHelpers = (dependencies) => {
if (typeof candidate.showExpandedEditTools === 'boolean') {
result.showExpandedEditTools = candidate.showExpandedEditTools;
}
if (typeof candidate.timeFormatPreference === 'string') {
const mode = candidate.timeFormatPreference.trim();
if (mode === 'auto' || mode === '12h' || mode === '24h') {
result.timeFormatPreference = mode;
}
}
if (typeof candidate.weekStartPreference === 'string') {
const mode = candidate.weekStartPreference.trim();
if (mode === 'auto' || mode === 'sunday' || mode === 'monday') {
result.weekStartPreference = mode;
}
}
if (typeof candidate.chatRenderMode === 'string') {
const mode = candidate.chatRenderMode.trim();
if (mode === 'sorted' || mode === 'live') {
@@ -8,6 +8,7 @@ export const createGracefulShutdownRuntime = (dependencies) => {
syncToHmrState,
openCodeWatcherRuntime,
sessionRuntime,
scheduledTasksRuntime,
getHealthCheckInterval,
clearHealthCheckInterval,
getTerminalRuntime,
@@ -36,6 +37,7 @@ export const createGracefulShutdownRuntime = (dependencies) => {
openCodeWatcherRuntime.stop();
sessionRuntime.dispose();
scheduledTasksRuntime?.stop?.();
const healthCheckInterval = getHealthCheckInterval();
if (healthCheckInterval) {
@@ -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();
}
});
});
@@ -0,0 +1,44 @@
# Scheduled Tasks module
Server-owned scheduled task runtime and routes for OpenChamber-only automation.
## Scope
- Per-project scheduled task persistence is owned by `packages/web/server/lib/projects/project-config.js`.
- Runtime orchestration and execution is owned by this module.
- This module is OpenChamber feature logic; it is intentionally separate from OpenCode proxy/runtime internals.
## Files
- `packages/web/server/lib/scheduled-tasks/runtime.js`
- Next-run computation (daily/weekly/cron compatibility)
- Timer scheduling and queueing
- Concurrency controls
- Session create + prompt_async execution
- Emits OpenChamber task-run events
- `packages/web/server/lib/scheduled-tasks/routes.js`
- Scheduled task CRUD endpoints
- Manual run endpoint
- OpenChamber events SSE stream endpoint
## Public exports (runtime.js)
- `createScheduledTasksRuntime(dependencies)`
- Returned API:
- `start()`
- `stop()`
- `syncAllProjects()`
- `syncProject(projectId)`
- `runNow(projectId, taskId)`
## Public exports (routes.js)
- `registerScheduledTaskRoutes(app, dependencies)`
- Registers:
- `GET /api/projects/:projectId/scheduled-tasks`
- `PUT /api/projects/:projectId/scheduled-tasks`
- `DELETE /api/projects/:projectId/scheduled-tasks/:taskId`
- `POST /api/projects/:projectId/scheduled-tasks/:taskId/run`
- `GET /api/openchamber/scheduled-tasks/status`
- `GET /api/openchamber/events`
@@ -0,0 +1,215 @@
const asNonEmptyString = (value) => {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
};
const parseProjectID = (req) => asNonEmptyString(req?.params?.projectId);
const parseTaskID = (req) => asNonEmptyString(req?.params?.taskId);
export const registerScheduledTaskRoutes = (app, dependencies) => {
const {
readSettingsFromDiskMigrated,
sanitizeProjects,
projectConfigRuntime,
scheduledTasksRuntime,
getOpenChamberEventClients,
writeSseEvent,
} = dependencies;
const findProjectByID = async (projectID) => {
const settings = await readSettingsFromDiskMigrated();
const projects = sanitizeProjects(settings?.projects || []);
return projects.find((project) => project.id === projectID) || null;
};
app.get('/api/projects/:projectId/scheduled-tasks', async (req, res) => {
const projectID = parseProjectID(req);
if (!projectID) {
return res.status(400).json({ error: 'projectId is required' });
}
try {
const project = await findProjectByID(projectID);
if (!project) {
return res.status(404).json({ error: 'Project not found' });
}
const tasks = await projectConfigRuntime.listScheduledTasks(projectID);
return res.json({ tasks });
} catch (error) {
console.error('[ScheduledTasks] failed to load tasks:', error);
return res.status(500).json({ error: 'Failed to load scheduled tasks' });
}
});
app.put('/api/projects/:projectId/scheduled-tasks', async (req, res) => {
const projectID = parseProjectID(req);
if (!projectID) {
return res.status(400).json({ error: 'projectId is required' });
}
const taskInput = req.body && typeof req.body === 'object' ? req.body.task : null;
if (!taskInput || typeof taskInput !== 'object') {
return res.status(400).json({ error: 'task payload is required' });
}
try {
const project = await findProjectByID(projectID);
if (!project) {
return res.status(404).json({ error: 'Project not found' });
}
const upserted = await projectConfigRuntime.upsertScheduledTask(projectID, taskInput);
await scheduledTasksRuntime.syncProject(projectID);
const freshTasks = await projectConfigRuntime.listScheduledTasks(projectID);
const freshTask = freshTasks.find((task) => task.id === upserted.task.id) || upserted.task;
return res.json({
tasks: freshTasks,
task: freshTask,
created: upserted.created,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to save scheduled task';
const statusCode = message.toLowerCase().includes('required') || message.toLowerCase().includes('invalid')
? 400
: 500;
if (statusCode === 500) {
console.error('[ScheduledTasks] failed to save task:', error);
}
return res.status(statusCode).json({ error: message });
}
});
app.delete('/api/projects/:projectId/scheduled-tasks/:taskId', async (req, res) => {
const projectID = parseProjectID(req);
const taskID = parseTaskID(req);
if (!projectID) {
return res.status(400).json({ error: 'projectId is required' });
}
if (!taskID) {
return res.status(400).json({ error: 'taskId is required' });
}
try {
const project = await findProjectByID(projectID);
if (!project) {
return res.status(404).json({ error: 'Project not found' });
}
const result = await projectConfigRuntime.deleteScheduledTask(projectID, taskID);
if (!result.deleted) {
return res.status(404).json({ error: 'Task not found' });
}
await scheduledTasksRuntime.syncProject(projectID);
const freshTasks = await projectConfigRuntime.listScheduledTasks(projectID);
return res.json({ tasks: freshTasks });
} catch (error) {
console.error('[ScheduledTasks] failed to delete task:', error);
return res.status(500).json({ error: 'Failed to delete scheduled task' });
}
});
app.post('/api/projects/:projectId/scheduled-tasks/:taskId/run', async (req, res) => {
const projectID = parseProjectID(req);
const taskID = parseTaskID(req);
if (!projectID) {
return res.status(400).json({ error: 'projectId is required' });
}
if (!taskID) {
return res.status(400).json({ error: 'taskId is required' });
}
try {
const project = await findProjectByID(projectID);
if (!project) {
return res.status(404).json({ error: 'Project not found' });
}
const result = await scheduledTasksRuntime.runNow(projectID, taskID);
if (result.running || result.queued) {
return res.status(409).json({ error: result.error || 'Task already running' });
}
if (result.skipped) {
return res.status(404).json({ error: 'Task not found or disabled' });
}
if (!result.ok) {
return res.status(500).json({
error: result.error || 'Task run failed',
task: result.task,
});
}
return res.json({
ok: true,
task: result.task,
sessionId: result.sessionID,
});
} catch (error) {
console.error('[ScheduledTasks] failed to run task:', error);
return res.status(500).json({ error: 'Failed to run scheduled task' });
}
});
app.get('/api/openchamber/scheduled-tasks/status', async (_req, res) => {
try {
const settings = await readSettingsFromDiskMigrated();
const projects = sanitizeProjects(settings?.projects || []);
let enabledCount = 0;
let runningCount = 0;
for (const project of projects) {
try {
const tasks = await projectConfigRuntime.listScheduledTasks(project.id);
for (const task of tasks) {
if (task?.enabled) {
enabledCount += 1;
}
if (task?.state?.lastStatus === 'running') {
runningCount += 1;
}
}
} catch {
}
}
return res.json({
hasEnabledScheduledTasks: enabledCount > 0,
hasRunningScheduledTasks: runningCount > 0,
enabledScheduledTasksCount: enabledCount,
runningScheduledTasksCount: runningCount,
});
} catch (error) {
console.error('[ScheduledTasks] failed to resolve scheduled task status:', error);
return res.status(500).json({ error: 'Failed to resolve scheduled task status' });
}
});
app.get('/api/openchamber/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders?.();
const clients = getOpenChamberEventClients();
clients.add(res);
try {
writeSseEvent(res, {
type: 'openchamber:event-stream-ready',
properties: {
connectedAt: Date.now(),
},
});
} catch {
}
req.on('close', () => {
clients.delete(res);
});
});
};
@@ -0,0 +1,749 @@
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
import { DateTime } from 'luxon';
import parser from 'cron-parser';
const DEFAULT_GLOBAL_CONCURRENCY = 4;
const DEFAULT_PROJECT_CONCURRENCY = 2;
const DEFAULT_MAX_RUN_MS = 30 * 60 * 1000;
const JITTER_MAX_MS = 2_000;
const TASK_TITLE_MAX_LENGTH = 120;
const TASK_DUE_SLACK_MS = 5_000;
const MAX_TIMER_DELAY_MS = 2_147_483_647;
const buildTaskKey = (projectID, taskID) => `${projectID}:${taskID}`;
const parseTimeParts = (time) => {
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(typeof time === 'string' ? time : '');
if (!match) {
return null;
}
return {
hour: Number(match[1]),
minute: Number(match[2]),
};
};
const applyTimeToDate = (baseDateTime, time) => {
const parsed = parseTimeParts(time);
if (!parsed) {
return null;
}
return baseDateTime.set({
hour: parsed.hour,
minute: parsed.minute,
second: 0,
millisecond: 0,
});
};
const resolveScheduleTimes = (schedule) => {
const times = [];
if (Array.isArray(schedule?.times)) {
for (const candidate of schedule.times) {
if (typeof candidate === 'string' && /^([01]\d|2[0-3]):([0-5]\d)$/.test(candidate)) {
times.push(candidate);
}
}
}
if (times.length === 0 && typeof schedule?.time === 'string' && /^([01]\d|2[0-3]):([0-5]\d)$/.test(schedule.time)) {
times.push(schedule.time);
}
return Array.from(new Set(times)).sort((a, b) => a.localeCompare(b));
};
const weekdayAsZeroBased = (dateTime) => {
if (!dateTime || typeof dateTime.weekday !== 'number') {
return null;
}
return dateTime.weekday % 7;
};
const safeErrorMessage = (error, maxLength = 2_000) => {
const raw = error instanceof Error
? (error.message || String(error))
: String(error ?? 'Unknown error');
const trimmed = raw.trim();
if (!trimmed) {
return 'Unknown error';
}
return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed;
};
export const parseScheduledCommandPrompt = (prompt) => {
if (typeof prompt !== 'string') {
return null;
}
const trimmed = prompt.trim();
if (!trimmed.startsWith('/')) {
return null;
}
const firstLine = trimmed.split(/\r?\n/, 1)[0] || '';
const [head, ...tail] = firstLine.split(/\s+/);
const commandName = (head || '').slice(1).trim();
if (!commandName) {
return null;
}
return {
command: commandName,
arguments: tail.join(' ').trim(),
};
};
export const computeNextRunAt = (task, nowMs = Date.now()) => {
if (!task?.enabled) {
return null;
}
const schedule = task.schedule;
if (!schedule || typeof schedule !== 'object') {
return null;
}
const zone = typeof schedule.timezone === 'string' && schedule.timezone.trim().length > 0
? schedule.timezone.trim()
: DateTime.local().zoneName;
const now = DateTime.fromMillis(nowMs, { zone });
if (!now.isValid) {
return null;
}
if (schedule.kind === 'daily') {
const times = resolveScheduleTimes(schedule);
if (times.length === 0) {
return null;
}
const minAllowed = now.plus({ milliseconds: TASK_DUE_SLACK_MS });
for (const time of times) {
const candidateToday = applyTimeToDate(now, time);
if (!candidateToday || !candidateToday.isValid) {
continue;
}
if (candidateToday > minAllowed) {
return candidateToday.toMillis();
}
}
const tomorrow = now.plus({ days: 1 });
const firstTomorrow = applyTimeToDate(tomorrow, times[0]);
return firstTomorrow?.isValid ? firstTomorrow.toMillis() : null;
}
if (schedule.kind === 'weekly') {
if (!Array.isArray(schedule.weekdays) || schedule.weekdays.length === 0) {
return null;
}
const times = resolveScheduleTimes(schedule);
if (times.length === 0) {
return null;
}
const weekdaysSet = new Set(schedule.weekdays);
const minAllowed = now.plus({ milliseconds: TASK_DUE_SLACK_MS });
for (let dayOffset = 0; dayOffset <= 14; dayOffset += 1) {
const dayCandidate = now.plus({ days: dayOffset });
const zeroBasedWeekday = weekdayAsZeroBased(dayCandidate);
if (zeroBasedWeekday === null || !weekdaysSet.has(zeroBasedWeekday)) {
continue;
}
for (const time of times) {
const withTime = applyTimeToDate(dayCandidate, time);
if (!withTime || !withTime.isValid) {
continue;
}
if (withTime > minAllowed) {
return withTime.toMillis();
}
}
}
return null;
}
if (schedule.kind === 'once') {
if (typeof schedule.date !== 'string' || typeof schedule.time !== 'string') {
return null;
}
const parsed = DateTime.fromFormat(
`${schedule.date} ${schedule.time}`,
'yyyy-LL-dd HH:mm',
{ zone },
);
if (!parsed.isValid) {
return null;
}
const minAllowed = now.plus({ milliseconds: TASK_DUE_SLACK_MS });
if (parsed <= minAllowed) {
return null;
}
return parsed.toMillis();
}
if (schedule.kind === 'cron') {
try {
const iterator = parser.parseExpression(schedule.cron, {
tz: zone,
currentDate: new Date(nowMs),
});
return iterator.next().getTime();
} catch {
return null;
}
}
return null;
};
export const formatScheduledSessionTitle = (task, nowMs = Date.now()) => {
const timezone = typeof task?.schedule?.timezone === 'string' && task.schedule.timezone.trim().length > 0
? task.schedule.timezone.trim()
: DateTime.local().zoneName;
const stamp = DateTime.fromMillis(nowMs, { zone: timezone }).toFormat('yyyy-LL-dd HH:mm');
const taskName = typeof task?.name === 'string' && task.name.trim().length > 0
? task.name.trim()
: 'Scheduled task';
const suffix = ` ${stamp}`;
const maxTaskNameLength = Math.max(1, TASK_TITLE_MAX_LENGTH - suffix.length);
const trimmedName = taskName.length > maxTaskNameLength
? taskName.slice(0, maxTaskNameLength)
: taskName;
return `${trimmedName}${suffix}`;
};
export const createScheduledTasksRuntime = (deps) => {
const {
projectConfigRuntime,
listProjects,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
waitForOpenCodeReady,
emitTaskRunEvent,
logger = console,
maxGlobalConcurrency = DEFAULT_GLOBAL_CONCURRENCY,
maxProjectConcurrency = DEFAULT_PROJECT_CONCURRENCY,
maxRunDurationMs = DEFAULT_MAX_RUN_MS,
} = deps;
let started = false;
const tasksByProject = new Map();
const projectPathByID = new Map();
const timersByTaskKey = new Map();
const queuedTaskKeys = new Set();
const runningTaskKeys = new Set();
const runningCountByProject = new Map();
let runningGlobalCount = 0;
const queue = [];
const clearTimerForKey = (taskKey) => {
const timer = timersByTaskKey.get(taskKey);
if (timer) {
clearTimeout(timer);
timersByTaskKey.delete(taskKey);
}
};
const clearProjectTimers = (projectID) => {
const tasks = tasksByProject.get(projectID);
if (!tasks) {
return;
}
for (const task of tasks.values()) {
clearTimerForKey(buildTaskKey(projectID, task.id));
queuedTaskKeys.delete(buildTaskKey(projectID, task.id));
}
};
const setProjectTasks = (projectID, tasks) => {
clearProjectTimers(projectID);
const taskMap = new Map();
for (const task of tasks) {
taskMap.set(task.id, task);
}
tasksByProject.set(projectID, taskMap);
};
const scheduleTask = (projectID, taskID, nextRunAt) => {
const taskKey = buildTaskKey(projectID, taskID);
clearTimerForKey(taskKey);
if (!Number.isFinite(nextRunAt) || nextRunAt <= 0) {
return;
}
const delayBase = Math.max(0, Math.round(nextRunAt - Date.now()));
const jitter = Math.floor(Math.random() * (JITTER_MAX_MS + 1));
const delay = delayBase + jitter;
const boundedDelay = Math.min(delay, MAX_TIMER_DELAY_MS);
const timer = setTimeout(async () => {
if (delay > MAX_TIMER_DELAY_MS) {
scheduleTask(projectID, taskID, nextRunAt);
return;
}
clearTimerForKey(taskKey);
const taskMap = tasksByProject.get(projectID);
const task = taskMap?.get(taskID);
if (!task || !task.enabled) {
return;
}
queueTaskRun(projectID, taskID, 'scheduled');
pumpQueue();
}, boundedDelay);
timersByTaskKey.set(taskKey, timer);
};
const updateInMemoryTask = (projectID, nextTask) => {
if (!nextTask) {
return;
}
const taskMap = tasksByProject.get(projectID);
if (!taskMap) {
return;
}
taskMap.set(nextTask.id, nextTask);
};
const syncTaskSchedule = async (projectID, task) => {
if (!task) {
return;
}
const nextRunAt = computeNextRunAt(task, Date.now());
const statePatch = {
nextRunAt: Number.isFinite(nextRunAt) ? nextRunAt : undefined,
updatedAt: Date.now(),
};
const result = await projectConfigRuntime.updateScheduledTaskState(projectID, task.id, statePatch);
if (result.task) {
updateInMemoryTask(projectID, result.task);
if (result.task.enabled && Number.isFinite(result.task.state?.nextRunAt)) {
scheduleTask(projectID, result.task.id, result.task.state.nextRunAt);
}
}
};
const ensureProjectPath = async (projectID) => {
if (projectPathByID.has(projectID)) {
return projectPathByID.get(projectID) || null;
}
try {
const projects = await listProjects();
const project = projects.find((item) => item?.id === projectID && item?.path);
if (project?.path) {
projectPathByID.set(projectID, project.path);
return project.path;
}
} catch {
}
return null;
};
const syncProject = async (projectID) => {
await ensureProjectPath(projectID);
const tasks = await projectConfigRuntime.listScheduledTasks(projectID);
setProjectTasks(projectID, tasks);
for (const task of tasks) {
await syncTaskSchedule(projectID, task);
}
return tasks;
};
const syncAllProjects = async () => {
const projects = await listProjects();
const activeProjectIDs = new Set();
projectPathByID.clear();
for (const project of projects) {
if (!project?.id || !project?.path) {
continue;
}
activeProjectIDs.add(project.id);
projectPathByID.set(project.id, project.path);
}
for (const existingProjectID of Array.from(tasksByProject.keys())) {
if (!activeProjectIDs.has(existingProjectID)) {
clearProjectTimers(existingProjectID);
tasksByProject.delete(existingProjectID);
}
}
for (const projectID of activeProjectIDs) {
await syncProject(projectID);
}
};
const queueTaskRun = (projectID, taskID, reason) => {
const taskKey = buildTaskKey(projectID, taskID);
if (queuedTaskKeys.has(taskKey) || runningTaskKeys.has(taskKey)) {
return;
}
queuedTaskKeys.add(taskKey);
queue.push({ projectID, taskID, reason });
};
const canRunTask = (projectID) => {
if (runningGlobalCount >= maxGlobalConcurrency) {
return false;
}
const projectRunning = runningCountByProject.get(projectID) || 0;
return projectRunning < maxProjectConcurrency;
};
const buildPromptAsyncPayload = (task) => ({
model: {
providerID: task.execution.providerID,
modelID: task.execution.modelID,
},
...(task.execution.agent ? { agent: task.execution.agent } : {}),
...(task.execution.variant ? { variant: task.execution.variant } : {}),
parts: [
{
type: 'text',
text: task.execution.prompt,
},
],
});
const runPromptAsync = async ({ baseUrl, authHeaders, sessionID, projectPath, task }) => {
const promptUrl = new URL(`${baseUrl}/session/${encodeURIComponent(sessionID)}/prompt_async`);
promptUrl.searchParams.set('directory', projectPath);
const response = await fetch(promptUrl.toString(), {
method: 'POST',
headers: {
...authHeaders,
'content-type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify(buildPromptAsyncPayload(task)),
});
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new Error(`prompt_async failed (${response.status})${body ? `: ${body}` : ''}`);
}
};
const runScheduledCommandIfApplicable = async ({ client, projectPath, sessionID, task }) => {
const parsed = parseScheduledCommandPrompt(task?.execution?.prompt);
if (!parsed) {
return false;
}
let commands = [];
try {
const response = await client.command.list({ directory: projectPath });
commands = Array.isArray(response?.data) ? response.data : [];
} catch {
return false;
}
const hasMatchingCommand = commands.some((command) => command?.name === parsed.command);
if (!hasMatchingCommand) {
return false;
}
await client.session.command({
sessionID,
directory: projectPath,
command: parsed.command,
arguments: parsed.arguments,
...(task.execution.agent ? { agent: task.execution.agent } : {}),
model: `${task.execution.providerID}/${task.execution.modelID}`,
...(task.execution.variant ? { variant: task.execution.variant } : {}),
});
return true;
};
const runTaskWithWatchdog = async (projectID, task, reason) => {
const startedAt = Date.now();
const title = formatScheduledSessionTitle(task, startedAt);
const projectPath = projectPathByID.get(projectID);
if (!projectPath) {
throw new Error('project path is unavailable');
}
if (typeof waitForOpenCodeReady === 'function') {
await waitForOpenCodeReady(10_000, 250);
}
const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
const authHeaders = getOpenCodeAuthHeaders();
const client = createOpencodeClient({
baseUrl,
headers: authHeaders,
});
const sessionResponse = await client.session.create({
directory: projectPath,
title,
});
const sessionID = sessionResponse?.data?.id;
if (!sessionID) {
throw new Error('failed to create session');
}
try {
emitTaskRunEvent?.({
projectID,
taskID: task.id,
ranAt: startedAt,
status: 'running',
sessionID,
});
} catch {
}
const executedAsCommand = await runScheduledCommandIfApplicable({
client,
projectPath,
sessionID,
task,
});
if (!executedAsCommand) {
await runPromptAsync({
baseUrl,
authHeaders,
sessionID,
projectPath,
task,
});
}
const finishedAt = Date.now();
return {
sessionID,
durationMs: Math.max(0, finishedAt - startedAt),
reason,
startedAt,
finishedAt,
};
};
const runTask = async (projectID, taskID, reason) => {
const taskMap = tasksByProject.get(projectID);
const task = taskMap?.get(taskID);
if (!task || !task.enabled) {
return { ok: false, skipped: true };
}
const taskKey = buildTaskKey(projectID, taskID);
if (runningTaskKeys.has(taskKey)) {
return { ok: false, running: true };
}
runningTaskKeys.add(taskKey);
runningGlobalCount += 1;
runningCountByProject.set(projectID, (runningCountByProject.get(projectID) || 0) + 1);
const runStartedAt = Date.now();
await projectConfigRuntime.updateScheduledTaskState(projectID, taskID, {
lastRunAt: runStartedAt,
lastStatus: 'running',
lastError: undefined,
updatedAt: runStartedAt,
}).then((result) => {
if (result.task) {
updateInMemoryTask(projectID, result.task);
}
});
let status = 'success';
let sessionID;
let durationMs = 0;
let errorMessage;
try {
const runPromise = runTaskWithWatchdog(projectID, task, reason);
let timeoutID;
const timeoutPromise = new Promise((_, reject) => {
timeoutID = setTimeout(() => {
reject(new Error('scheduled task run timed out'));
}, maxRunDurationMs);
});
const result = await Promise.race([runPromise, timeoutPromise]).finally(() => {
if (timeoutID) {
clearTimeout(timeoutID);
}
});
sessionID = result.sessionID;
durationMs = result.durationMs;
status = 'success';
logger.info?.(
'[ScheduledTasks] run completed',
{ projectID, taskID, status, reason, sessionID, durationMs }
);
} catch (error) {
status = 'error';
errorMessage = safeErrorMessage(error);
logger.warn?.('[ScheduledTasks] run failed', {
projectID,
taskID,
reason,
status,
error: errorMessage,
});
}
const finishedAt = Date.now();
if (!durationMs) {
durationMs = Math.max(0, finishedAt - runStartedAt);
}
let latestTask = (tasksByProject.get(projectID)?.get(taskID)) || task;
const shouldConsumeOneTimeTask = latestTask?.schedule?.kind === 'once' && reason === 'scheduled';
if (shouldConsumeOneTimeTask && latestTask?.enabled) {
try {
const consumed = await projectConfigRuntime.upsertScheduledTask(projectID, {
...latestTask,
enabled: false,
});
latestTask = consumed.task || latestTask;
updateInMemoryTask(projectID, latestTask);
} catch (consumeError) {
logger.warn?.('[ScheduledTasks] failed to consume one-time task', {
projectID,
taskID,
error: safeErrorMessage(consumeError),
});
}
}
const nextRunAt = computeNextRunAt(latestTask, finishedAt);
const statePatch = {
lastStatus: status,
lastDurationMs: durationMs,
lastError: status === 'error' ? errorMessage : undefined,
lastSessionId: status === 'success' ? sessionID : undefined,
nextRunAt: Number.isFinite(nextRunAt) ? nextRunAt : undefined,
updatedAt: finishedAt,
};
const stateResult = await projectConfigRuntime.updateScheduledTaskState(projectID, taskID, statePatch);
if (stateResult.task) {
updateInMemoryTask(projectID, stateResult.task);
if (stateResult.task.enabled && Number.isFinite(stateResult.task.state?.nextRunAt)) {
scheduleTask(projectID, taskID, stateResult.task.state.nextRunAt);
}
}
try {
emitTaskRunEvent?.({
projectID,
taskID,
ranAt: finishedAt,
status,
...(sessionID ? { sessionID } : {}),
});
} catch {
}
runningTaskKeys.delete(taskKey);
runningGlobalCount = Math.max(0, runningGlobalCount - 1);
const nextProjectCount = Math.max(0, (runningCountByProject.get(projectID) || 1) - 1);
if (nextProjectCount === 0) {
runningCountByProject.delete(projectID);
} else {
runningCountByProject.set(projectID, nextProjectCount);
}
return {
ok: status === 'success',
status,
sessionID,
task: stateResult.task || null,
error: errorMessage,
};
};
const pumpQueue = () => {
if (!started) {
return;
}
let consumed = false;
for (let index = 0; index < queue.length; index += 1) {
const item = queue[index];
if (!canRunTask(item.projectID)) {
continue;
}
queue.splice(index, 1);
index -= 1;
const taskKey = buildTaskKey(item.projectID, item.taskID);
queuedTaskKeys.delete(taskKey);
consumed = true;
void runTask(item.projectID, item.taskID, item.reason).finally(() => {
pumpQueue();
});
}
if (!consumed && queue.length > 0) {
return;
}
};
const runNow = async (projectID, taskID) => {
const taskKey = buildTaskKey(projectID, taskID);
if (runningTaskKeys.has(taskKey)) {
return {
ok: false,
running: true,
error: 'task is already running',
};
}
if (queuedTaskKeys.has(taskKey)) {
return {
ok: false,
queued: true,
error: 'task is already queued',
};
}
return runTask(projectID, taskID, 'manual');
};
const start = async () => {
if (started) {
return;
}
started = true;
await syncAllProjects();
};
const stop = () => {
if (!started) {
return;
}
started = false;
for (const timer of timersByTaskKey.values()) {
clearTimeout(timer);
}
timersByTaskKey.clear();
queuedTaskKeys.clear();
queue.length = 0;
};
return {
start,
stop,
syncAllProjects,
syncProject,
runNow,
};
};
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'bun:test';
import { computeNextRunAt, formatScheduledSessionTitle, parseScheduledCommandPrompt } from './runtime.js';
describe('scheduled-tasks runtime helpers', () => {
it('computes next daily run in timezone', () => {
const nowUtc = Date.UTC(2025, 0, 1, 8, 0, 0);
const next = computeNextRunAt({
enabled: true,
schedule: {
kind: 'daily',
times: ['09:30'],
timezone: 'UTC',
},
}, nowUtc);
expect(next).toBe(Date.UTC(2025, 0, 1, 9, 30, 0));
});
it('computes weekly next run using weekdays', () => {
// Monday 2025-01-06 10:00:00 UTC
const nowUtc = Date.UTC(2025, 0, 6, 10, 0, 0);
const next = computeNextRunAt({
enabled: true,
schedule: {
kind: 'weekly',
times: ['09:00'],
weekdays: [1, 3],
timezone: 'UTC',
},
}, nowUtc);
// Wednesday 2025-01-08 09:00:00 UTC
expect(next).toBe(Date.UTC(2025, 0, 8, 9, 0, 0));
});
it('picks nearest time from multiple daily times', () => {
const nowUtc = Date.UTC(2025, 0, 1, 9, 20, 0);
const next = computeNextRunAt({
enabled: true,
schedule: {
kind: 'daily',
times: ['09:15', '09:45', '18:00'],
timezone: 'UTC',
},
}, nowUtc);
expect(next).toBe(Date.UTC(2025, 0, 1, 9, 45, 0));
});
it('computes one-time next run for future date', () => {
const nowUtc = Date.UTC(2026, 3, 15, 10, 0, 0);
const next = computeNextRunAt({
enabled: true,
schedule: {
kind: 'once',
date: '2026-04-16',
time: '13:30',
timezone: 'UTC',
},
}, nowUtc);
expect(next).toBe(Date.UTC(2026, 3, 16, 13, 30, 0));
});
it('returns null for past one-time schedule', () => {
const nowUtc = Date.UTC(2026, 3, 16, 14, 0, 0);
const next = computeNextRunAt({
enabled: true,
schedule: {
kind: 'once',
date: '2026-04-16',
time: '13:30',
timezone: 'UTC',
},
}, nowUtc);
expect(next).toBeNull();
});
it('formats session title with timestamp suffix', () => {
const title = formatScheduledSessionTitle({
name: 'Morning Sync',
schedule: { timezone: 'UTC' },
}, Date.UTC(2025, 2, 10, 7, 5, 0));
expect(title).toBe('Morning Sync 2025-03-10 07:05');
});
it('parses slash command prompt for scheduled command mode', () => {
expect(parseScheduledCommandPrompt('/review src/components')).toEqual({
command: 'review',
arguments: 'src/components',
});
});
it('returns null when prompt is not a slash command', () => {
expect(parseScheduledCommandPrompt('Summarize open issues')).toBeNull();
expect(parseScheduledCommandPrompt('/')).toBeNull();
});
});