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.
157 lines
6.0 KiB
JavaScript
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,
|
|
};
|
|
};
|