Files
openchamber/packages/web/server/lib/scheduled-tasks/service.js
T
makeittech 9b6b90504c fix(tasks): cover syncProject wiring and allow deleting orphans after file removal
Review follow-up:

- runtime.test.js: add syncProject wiring tests with a real temp-dir
  project and real project-config runtime — asserts reconcileLoopTasks is
  driven with the discovered loops when the project path is known (task
  created, nextRunAt computed) and that plain listing is used when the
  path cannot be resolved (reconcile not called).
- service.js: DELETE on a loop-owned task is rejected with a 400 only
  while its loop file still exists on disk; once the file is gone the
  orphan task can be deleted directly instead of waiting for the next
  reconcile. Tests use real temp files for both branches.
- DOCUMENTATION.md: delete semantics updated accordingly.
- PR description refreshed for the final HEAD (test counts, reconciliation
  contract, evidence wording).
2026-08-06 09:49:31 +03:00

160 lines
6.2 KiB
JavaScript

import fs from 'node:fs';
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 && fs.existsSync(existing.loopFile)) {
// Loop tasks are owned by their `.agents/loops` markdown file: deleting
// the JSON row would be silently undone by the next reconcile while the
// file exists. The file itself is the removal surface. Once the file is
// gone (the task is an orphan that the next sync would remove anyway),
// deleting the row is safe and allowed.
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,
};
};