feat(tasks): manage markdown loops from scheduled tasks

This commit is contained in:
Bohdan Triapitsyn
2026-08-09 21:43:16 +03:00
parent ce6912fe40
commit e13b4526c6
20 changed files with 322 additions and 34 deletions
@@ -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-'));