feat(tasks): manage markdown loops from scheduled tasks
This commit is contained in:
@@ -26,6 +26,7 @@ Server-owned scheduled task runtime and routes for OpenChamber-only automation.
|
||||
- `packages/web/server/lib/scheduled-tasks/routes.js`
|
||||
- Scheduled task CRUD endpoints
|
||||
- Listing tasks reconciles loop files first, so opening the Scheduled Tasks UI discovers file additions, edits, and removals without a server restart
|
||||
- Loop-file endpoints toggle `enabled` in frontmatter or delete the authoritative markdown file, then reconcile the project
|
||||
- Manual run endpoint
|
||||
- OpenChamber events SSE stream endpoint
|
||||
|
||||
@@ -94,14 +95,12 @@ project write lock on every `syncProject` when the project path is known:
|
||||
- **Malformed files** (missing `name`/`schedule`/`model`/body, invalid cron,
|
||||
unreadable) are reported to the scheduler as `definition: null` entries and
|
||||
warned about; they never block valid loops in the same or other scopes.
|
||||
- **UI edits** to a loop-sourced task are preserved in the config but the loop
|
||||
file remains authoritative: the next reconciliation re-applies the file's
|
||||
definition (including `enabled`). Use `enabled: false` in the file to
|
||||
disable. Deleting a loop-sourced task through the API is rejected with a 400
|
||||
while its loop file still exists on disk — the loop file is the removal
|
||||
surface; once the file is gone, deleting the orphan task is allowed. The
|
||||
scheduled-tasks UI marks loop tasks as file-managed and disables their
|
||||
edit/enable/delete actions for the same reason; `run now` remains available.
|
||||
- **Loop-file mutations.** The loop file remains authoritative. The scheduled-
|
||||
tasks UI opens it in the built-in file editor, updates its `enabled`
|
||||
frontmatter through the loop-file endpoint, and deletes the file through the
|
||||
loop-file endpoint after confirmation. Each mutation reconciles the project.
|
||||
The general task deletion API still rejects loop-sourced tasks while their
|
||||
file exists; once the file is gone, deleting an orphan task is allowed.
|
||||
|
||||
## Public exports (runtime.js)
|
||||
|
||||
@@ -120,6 +119,8 @@ project write lock on every `syncProject` when the project path is known:
|
||||
- `GET /api/projects/:projectId/scheduled-tasks`
|
||||
- `PUT /api/projects/:projectId/scheduled-tasks`
|
||||
- `DELETE /api/projects/:projectId/scheduled-tasks/:taskId`
|
||||
- `PATCH /api/projects/:projectId/scheduled-tasks/:taskId/loop-file`
|
||||
- `DELETE /api/projects/:projectId/scheduled-tasks/:taskId/loop-file`
|
||||
- `POST /api/projects/:projectId/scheduled-tasks/:taskId/run`
|
||||
- `GET /api/openchamber/scheduled-tasks/status`
|
||||
- `GET /api/openchamber/events`
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { parseMdFile, getAncestors, findWorktreeRoot } from '../opencode/shared.js';
|
||||
import { parseMdFile, writeMdFile, getAncestors, findWorktreeRoot } from '../opencode/shared.js';
|
||||
import { MAX_TASK_NAME_LENGTH } from '../projects/project-config.js';
|
||||
|
||||
const LOOP_DIR_NAME = 'loops';
|
||||
@@ -140,6 +140,15 @@ export const parseLoopDefinition = (filePath) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const setLoopFileEnabled = (filePath, enabled) => {
|
||||
if (!parseLoopDefinition(filePath)) {
|
||||
return false;
|
||||
}
|
||||
const { frontmatter, body } = parseMdFile(filePath);
|
||||
writeMdFile(filePath, { ...frontmatter, enabled: Boolean(enabled) }, body);
|
||||
return true;
|
||||
};
|
||||
|
||||
const walkLoopMdFiles = (rootDir) => {
|
||||
if (!rootDir || !fs.existsSync(rootDir)) {
|
||||
return [];
|
||||
|
||||
@@ -81,6 +81,35 @@ export const registerScheduledTaskRoutes = (app, dependencies) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/projects/:projectId/scheduled-tasks/:taskId/loop-file', 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 task = await scheduledTaskService.setLoopEnabled(projectID, taskID, req.body?.enabled);
|
||||
return res.json({ task });
|
||||
} catch (error) {
|
||||
if (error?.statusCode) return res.status(error.statusCode).json({ error: error.message });
|
||||
console.error('[ScheduledTasks] failed to update loop file:', error);
|
||||
return res.status(500).json({ error: 'Failed to update loop file' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/projects/:projectId/scheduled-tasks/:taskId/loop-file', 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 {
|
||||
return res.json({ tasks: await scheduledTaskService.removeLoopFile(projectID, taskID) });
|
||||
} catch (error) {
|
||||
if (error?.statusCode) return res.status(error.statusCode).json({ error: error.message });
|
||||
console.error('[ScheduledTasks] failed to delete loop file:', error);
|
||||
return res.status(500).json({ error: 'Failed to delete loop file' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/projects/:projectId/scheduled-tasks/:taskId/run', async (req, res) => {
|
||||
const projectID = parseProjectID(req);
|
||||
const taskID = parseTaskID(req);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { OpenChamberControlError } from '../openchamber-control/error.js';
|
||||
import { setLoopFileEnabled } from './loops.js';
|
||||
|
||||
const asNonEmptyString = (value) => {
|
||||
if (typeof value !== 'string') return null;
|
||||
@@ -53,6 +54,47 @@ export const createScheduledTaskService = (dependencies) => {
|
||||
return scheduledTasksRuntime.syncProject(projectID);
|
||||
};
|
||||
|
||||
const findLoopTask = async (projectID, taskID) => {
|
||||
await findProjectByID(projectID);
|
||||
const normalizedTaskID = asNonEmptyString(taskID);
|
||||
if (!normalizedTaskID) throw new OpenChamberControlError('taskId is required', 400);
|
||||
const tasks = await scheduledTasksRuntime.syncProject(projectID);
|
||||
const task = tasks.find((entry) => entry?.id === normalizedTaskID) || null;
|
||||
if (!task) throw new OpenChamberControlError('Task not found', 404);
|
||||
if (!task.loopFile) throw new OpenChamberControlError('Task is not managed by a loop file', 400);
|
||||
if (!fs.existsSync(task.loopFile)) throw new OpenChamberControlError('Loop file not found', 404);
|
||||
return task;
|
||||
};
|
||||
|
||||
const setLoopEnabled = async (projectID, taskID, enabled) => {
|
||||
if (typeof enabled !== 'boolean') {
|
||||
throw new OpenChamberControlError('enabled must be a boolean', 400);
|
||||
}
|
||||
const task = await findLoopTask(projectID, taskID);
|
||||
try {
|
||||
if (!setLoopFileEnabled(task.loopFile, enabled)) {
|
||||
throw new OpenChamberControlError('Loop file must be valid before changing its enabled state', 400);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof OpenChamberControlError) throw error;
|
||||
const message = error instanceof Error ? error.message : 'Failed to update loop file';
|
||||
throw new OpenChamberControlError(message, 500);
|
||||
}
|
||||
const tasks = await scheduledTasksRuntime.syncProject(projectID);
|
||||
return tasks.find((entry) => entry.id === taskID) || null;
|
||||
};
|
||||
|
||||
const removeLoopFile = async (projectID, taskID) => {
|
||||
const task = await findLoopTask(projectID, taskID);
|
||||
try {
|
||||
fs.unlinkSync(task.loopFile);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to delete loop file';
|
||||
throw new OpenChamberControlError(message, 500);
|
||||
}
|
||||
return scheduledTasksRuntime.syncProject(projectID);
|
||||
};
|
||||
|
||||
const upsert = async (projectID, taskInput) => {
|
||||
await findProjectByID(projectID);
|
||||
if (!taskInput || typeof taskInput !== 'object') {
|
||||
@@ -154,6 +196,8 @@ export const createScheduledTaskService = (dependencies) => {
|
||||
remove,
|
||||
run,
|
||||
setEnabled,
|
||||
setLoopEnabled,
|
||||
removeLoopFile,
|
||||
status,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { mkdtemp, rm, writeFile } from 'fs/promises';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
|
||||
import { createScheduledTaskService } from './service.js';
|
||||
import { registerScheduledTaskRoutes } from './routes.js';
|
||||
|
||||
const createService = (overrides = {}) => {
|
||||
const projectConfigRuntime = {
|
||||
@@ -64,6 +65,140 @@ describe('scheduled-task service list', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('scheduled-task loop-file mutations', () => {
|
||||
it('updates only enabled in loop frontmatter and reconciles the task', async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-loop-toggle-'));
|
||||
try {
|
||||
const loopFilePath = path.join(tempRoot, 'daily.md');
|
||||
await writeFile(loopFilePath, `---
|
||||
name: daily-digest
|
||||
schedule: "0 9 * * *"
|
||||
enabled: true
|
||||
model: openai/gpt-5
|
||||
custom: keep-me
|
||||
---
|
||||
|
||||
Run the digest.
|
||||
`, 'utf8');
|
||||
const currentTask = { ...loopTask, loopFile: loopFilePath };
|
||||
const updatedTask = { ...currentTask, enabled: false };
|
||||
const syncProject = vi.fn()
|
||||
.mockResolvedValueOnce([currentTask])
|
||||
.mockResolvedValueOnce([updatedTask]);
|
||||
const { service } = createService({ scheduledTasksRuntime: { syncProject } });
|
||||
|
||||
await expect(service.setLoopEnabled('project-test', currentTask.id, false)).resolves.toEqual(updatedTask);
|
||||
|
||||
const content = await readFile(loopFilePath, 'utf8');
|
||||
expect(content).toContain('enabled: false');
|
||||
expect(content).toContain('custom: keep-me');
|
||||
expect(content).toContain('Run the digest.');
|
||||
expect(syncProject).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('deletes the authoritative loop file and reconciles the task away', async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-loop-remove-'));
|
||||
try {
|
||||
const loopFilePath = path.join(tempRoot, 'daily.md');
|
||||
await writeFile(loopFilePath, 'loop', 'utf8');
|
||||
const currentTask = { ...loopTask, loopFile: loopFilePath };
|
||||
const syncProject = vi.fn()
|
||||
.mockResolvedValueOnce([currentTask])
|
||||
.mockResolvedValueOnce([]);
|
||||
const { service } = createService({ scheduledTasksRuntime: { syncProject } });
|
||||
|
||||
await expect(service.removeLoopFile('project-test', currentTask.id)).resolves.toEqual([]);
|
||||
await expect(readFile(loopFilePath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
expect(syncProject).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not rewrite a malformed loop when toggling', async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-loop-invalid-'));
|
||||
try {
|
||||
const loopFilePath = path.join(tempRoot, 'daily.md');
|
||||
const malformed = '---\nname: daily-digest\n---\nRun.\n';
|
||||
await writeFile(loopFilePath, malformed, 'utf8');
|
||||
const currentTask = { ...loopTask, loopFile: loopFilePath };
|
||||
const syncProject = vi.fn(async () => [currentTask]);
|
||||
const { service } = createService({ scheduledTasksRuntime: { syncProject } });
|
||||
|
||||
await expect(service.setLoopEnabled('project-test', currentTask.id, false)).rejects.toMatchObject({ statusCode: 400 });
|
||||
await expect(readFile(loopFilePath, 'utf8')).resolves.toBe(malformed);
|
||||
expect(syncProject).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('scheduled-task loop-file routes', () => {
|
||||
const createResponse = () => ({
|
||||
statusCode: 200,
|
||||
payload: null,
|
||||
status(code) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload) {
|
||||
this.payload = payload;
|
||||
return this;
|
||||
},
|
||||
});
|
||||
|
||||
const captureHandlers = (scheduledTaskService) => {
|
||||
const handlers = new Map();
|
||||
const app = {
|
||||
get: vi.fn(),
|
||||
put: vi.fn(),
|
||||
post: vi.fn(),
|
||||
patch: vi.fn((route, handler) => handlers.set(`PATCH ${route}`, handler)),
|
||||
delete: vi.fn((route, handler) => handlers.set(`DELETE ${route}`, handler)),
|
||||
};
|
||||
registerScheduledTaskRoutes(app, {
|
||||
scheduledTaskService,
|
||||
readSettingsFromDiskMigrated: vi.fn(),
|
||||
sanitizeProjects: vi.fn(),
|
||||
projectConfigRuntime: {},
|
||||
scheduledTasksRuntime: {},
|
||||
getOpenChamberEventClients: () => new Set(),
|
||||
writeSseEvent: vi.fn(),
|
||||
});
|
||||
return handlers;
|
||||
};
|
||||
|
||||
it('routes loop enabled changes through the loop-file service', async () => {
|
||||
const setLoopEnabled = vi.fn(async () => ({ ...loopTask, enabled: false }));
|
||||
const handlers = captureHandlers({ setLoopEnabled });
|
||||
const handler = handlers.get('PATCH /api/projects/:projectId/scheduled-tasks/:taskId/loop-file');
|
||||
const res = createResponse();
|
||||
|
||||
await handler({ params: { projectId: 'project-test', taskId: loopTask.id }, body: { enabled: false } }, res);
|
||||
|
||||
expect(setLoopEnabled).toHaveBeenCalledWith('project-test', loopTask.id, false);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.payload.task.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('routes loop deletion through the loop-file service', async () => {
|
||||
const removeLoopFile = vi.fn(async () => []);
|
||||
const handlers = captureHandlers({ removeLoopFile });
|
||||
const handler = handlers.get('DELETE /api/projects/:projectId/scheduled-tasks/:taskId/loop-file');
|
||||
const res = createResponse();
|
||||
|
||||
await handler({ params: { projectId: 'project-test', taskId: loopTask.id } }, res);
|
||||
|
||||
expect(removeLoopFile).toHaveBeenCalledWith('project-test', loopTask.id);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.payload).toEqual({ tasks: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('scheduled-task service remove', () => {
|
||||
it('rejects deleting a loop-sourced task while its loop file still exists', async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-loop-delete-'));
|
||||
|
||||
Reference in New Issue
Block a user