Files
openchamber/packages/web/server/lib/scheduled-tasks/service.js
T
makeittech 359225d363 fix(tasks): harden loop reconciliation against renames and malformed files
Review fixes for the markdown loop feature:

- Loop-owned tasks now adopt by loop file path, not task name, so renaming
  a loop (frontmatter name or UI rename) renames the task in place instead
  of leaving a stale duplicate that keeps running the old definition;
  orphan duplicates of the same file are unscheduled.
- Unparseable loop files are reported to the scheduler as
  definition:null entries: a task whose file still exists is kept with its
  last good definition, and only a genuinely removed file unschedules it.
  Transiently malformed files (mid-edit, bad merge) no longer delete tasks
  or their runtime state.
- Adoption preserves UI-only execution fields (goalEnabled, goalTokenBudget,
  permissionAutoAccept, variant) that the portable format does not define.
- DELETE on a loop-sourced task now returns 400 with guidance to remove the
  loop file, instead of being silently undone by the next reconcile.
- Loops default to enabled: false; discovery of repository content never
  auto-executes scheduled sessions unless the file explicitly enables them.

Regression tests for each fix; DOCUMENTATION.md updated.
2026-08-06 09:28:31 +03:00

157 lines
6.0 KiB
JavaScript

import path from 'node:path';
import { OpenChamberControlError } from '../openchamber-control/error.js';
const asNonEmptyString = (value) => {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
};
export const createScheduledTaskService = (dependencies) => {
const {
readSettingsFromDiskMigrated,
sanitizeProjects,
projectConfigRuntime,
scheduledTasksRuntime,
} = dependencies;
const listProjects = async () => {
const settings = await readSettingsFromDiskMigrated();
return sanitizeProjects(settings?.projects || []);
};
const findProjectByID = async (projectID) => {
const normalized = asNonEmptyString(projectID);
if (!normalized) throw new OpenChamberControlError('projectId is required', 400);
const projects = await listProjects();
const project = projects.find((entry) => entry.id === normalized) || null;
if (!project) throw new OpenChamberControlError('Project not found', 404);
return project;
};
const resolveProjectID = async ({ projectId, directory } = {}) => {
const requestedProjectID = asNonEmptyString(projectId);
const requestedDirectory = asNonEmptyString(directory);
if (requestedProjectID && requestedDirectory) {
throw new OpenChamberControlError('Provide only one of projectId or directory', 400);
}
if (requestedProjectID) {
await findProjectByID(requestedProjectID);
return requestedProjectID;
}
if (!requestedDirectory) throw new OpenChamberControlError('projectId or directory is required', 400);
const resolvedDirectory = path.resolve(requestedDirectory);
const projects = await listProjects();
const project = projects.find((entry) => path.resolve(entry.path) === resolvedDirectory);
if (!project) throw new OpenChamberControlError(`Project not found for directory: ${resolvedDirectory}`, 404);
return project.id;
};
const list = async (projectID) => {
await findProjectByID(projectID);
return projectConfigRuntime.listScheduledTasks(projectID);
};
const upsert = async (projectID, taskInput) => {
await findProjectByID(projectID);
if (!taskInput || typeof taskInput !== 'object') {
throw new OpenChamberControlError('task payload is required', 400);
}
let upserted;
try {
upserted = await projectConfigRuntime.upsertScheduledTask(projectID, taskInput);
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to save scheduled task';
const invalid = message.toLowerCase().includes('required') || message.toLowerCase().includes('invalid');
throw new OpenChamberControlError(message, invalid ? 400 : 500);
}
await scheduledTasksRuntime.syncProject(projectID);
const tasks = await projectConfigRuntime.listScheduledTasks(projectID);
return {
tasks,
task: tasks.find((task) => task.id === upserted.task.id) || upserted.task,
created: upserted.created,
};
};
const remove = async (projectID, taskID) => {
await findProjectByID(projectID);
const normalizedTaskID = asNonEmptyString(taskID);
if (!normalizedTaskID) throw new OpenChamberControlError('taskId is required', 400);
const current = await projectConfigRuntime.listScheduledTasks(projectID);
const existing = current.find((task) => task.id === normalizedTaskID) || null;
if (existing?.loopFile) {
// Loop tasks are owned by their `.agents/loops` markdown file: deleting
// the JSON row would be silently undone by the next reconcile. The file
// itself is the removal surface.
throw new OpenChamberControlError(
'Loop task is managed by its .agents/loops markdown file; delete the file to remove the task',
400,
);
}
const result = await projectConfigRuntime.deleteScheduledTask(projectID, normalizedTaskID);
if (!result.deleted) throw new OpenChamberControlError('Task not found', 404);
await scheduledTasksRuntime.syncProject(projectID);
return projectConfigRuntime.listScheduledTasks(projectID);
};
const run = async (projectID, taskID) => {
await findProjectByID(projectID);
const normalizedTaskID = asNonEmptyString(taskID);
if (!normalizedTaskID) throw new OpenChamberControlError('taskId is required', 400);
const result = await scheduledTasksRuntime.runNow(projectID, normalizedTaskID);
if (result.running || result.queued) {
throw new OpenChamberControlError(result.error || 'Task already running', 409);
}
if (result.skipped) throw new OpenChamberControlError('Task not found or disabled', 404);
if (!result.ok) {
throw new OpenChamberControlError(result.error || 'Task run failed', 500, { task: result.task });
}
return { task: result.task, sessionId: result.sessionID };
};
const setEnabled = async (projectID, taskID, enabled) => {
const tasks = await list(projectID);
const task = tasks.find((entry) => entry?.id === taskID);
if (!task) throw new OpenChamberControlError('Task not found', 404);
const result = await upsert(projectID, { ...task, enabled });
return result.task;
};
const status = async () => {
if (typeof scheduledTasksRuntime.getStatus === 'function') {
return scheduledTasksRuntime.getStatus();
}
const projects = await listProjects();
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 {
hasEnabledScheduledTasks: enabledCount > 0,
hasRunningScheduledTasks: runningCount > 0,
enabledScheduledTasksCount: enabledCount,
runningScheduledTasksCount: runningCount,
};
};
return {
listProjects,
resolveProjectID,
list,
upsert,
remove,
run,
setEnabled,
status,
};
};