Files
openchamber/packages/web/server/lib/scheduled-tasks/runtime.js
T
Bohdan Triapitsyn 6fd3afd25a feat: replace prompt templates with snippets
Replace the prompt-template workflow with snippet support that is compatible with opencode snippet conventions. Snippets are now stored and loaded from global and project snippet directories, including legacy pluralized paths, with frontmatter metadata for aliases and descriptions. Snippet expansion supports recursive references plus prepend and append sections, while inject sections are treated as unsupported no-ops so OpenChamber remains compatible without requiring an external plugin.

Add the snippets settings experience and remove the old prompt-template settings surface. The new settings page and sidebar support creating, editing, deleting, selecting, and describing snippets, with localized copy across every supported locale. The settings navigation now exposes Snippets with a dedicated icon and metadata.

Wire snippets into all prompt-entry surfaces that need them. Chat, multi-run groups, and scheduled task prompts now offer hash-trigger snippet autocomplete and expand snippets before sending work to OpenCode. Chat also uses an adaptive compact placeholder on mobile or narrow composer widths so helper trigger guidance stays readable in constrained layouts.

Keep multi-run aligned with grouped prompts. Multi-run sessions now use a shared title builder that handles both legacy titles and the newer g1, g2 prompt-group title format. Fusion parsing now recognizes grouped multi-run titles, scopes fusion sources to the same prompt group, and creates fusion sessions under the matching group so outputs from different prompts are not mixed accidentally.

Harden the icon sprite pipeline. The sprite generator now discovers icon names used through typed icon maps, JSX icon props, IconName returns, and generated-value flows without scanning unrelated string literals or the generated sprite itself. The generated sprite is strictly typed so invalid icon names are caught by type checking, and existing invalid or unsafe icon references were cleaned up across settings, provider, Git identity, scheduled task, voice, header, and sidebar surfaces.

Update backend configuration routes and documentation for snippets. The OpenCode config route layer now exposes snippet CRUD and expansion endpoints, accepts JSON bodies for snippet writes, and removes the old prompt-template provider. Scheduled task runtime expansion now uses snippets before dispatching messages.

Add regression coverage for snippet storage and expansion, config-route JSON handling, and multi-run title parsing. Validated with full type checking, full linting, targeted multi-run title tests, and targeted OpenCode snippet/config route tests.
2026-05-21 20:00:35 +03:00

775 lines
21 KiB
JavaScript

import { createOpencodeClient } from '@opencode-ai/sdk/v2';
import { DateTime } from 'luxon';
import parser from 'cron-parser';
import { expandSnippets } from '../opencode/snippets.js';
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 (!started) {
return;
}
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, projectPath) => ({
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: expandSnippets(task.execution.prompt, projectPath),
},
],
});
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, projectPath)),
});
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;
};
const getStatus = () => {
let enabledCount = 0;
for (const taskMap of tasksByProject.values()) {
for (const task of taskMap.values()) {
if (task?.enabled) {
enabledCount += 1;
}
}
}
const runningCount = runningTaskKeys.size;
return {
hasEnabledScheduledTasks: enabledCount > 0,
hasRunningScheduledTasks: runningCount > 0,
enabledScheduledTasksCount: enabledCount,
runningScheduledTasksCount: runningCount,
};
};
return {
start,
stop,
syncAllProjects,
syncProject,
runNow,
getStatus,
};
};