Files
openchamber/packages/web/server/lib/projects/project-config.test.js
T
Serhii Dziupin 2fcfe511a5 feat(tasks): support markdown scheduled-task loops in .agents/loops
Adds markdown-based scheduled-task definitions ("loops") discovered from
.agents/loops/*.md (project scope, ancestor directories up to the
worktree root) and ~/.agents/loops/*.md (user scope), mirroring the
skills discovery pattern.

File format: YAML frontmatter (name, schedule cron, enabled, model as
provider/model, optional agent/timezone) plus the markdown body as the
execution prompt. Discovery and parsing live in
scheduled-tasks/loops.js; project-config gains reconcileLoopTasks which
runs inside the project write lock on every syncProject:
- identity by task name; a loop takes over a matching task, preserving
  its id and runtime state (markdown wins on conflict with JSON)
- tasks whose loopFile is gone are unscheduled; JSON tasks are never
  removed
- new loops are created under deterministic loop:<scope>:<name> ids
- project scope shadows user scope on name collisions
- malformed files are skipped with a warning and never block valid ones

Runtime state stays in the project config/state store; it is never
written to the markdown files. Module documentation updated with the
file format and reconciliation rules.

Fixes #2583
2026-08-05 14:11:28 +03:00

333 lines
12 KiB
JavaScript

import { describe, expect, it, vi } from 'vitest';
import os from 'os';
import path from 'path';
import { mkdtemp, rm, readFile, writeFile } from 'fs/promises';
import { createProjectConfigRuntime } from './project-config.js';
const createRuntime = async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-scheduled-project-config-'));
const runtime = createProjectConfigRuntime({
fsPromises: await import('fs/promises'),
path,
projectsDirPath: tempRoot,
createTaskID: () => 'task-fixed-id',
});
return {
runtime,
cleanup: async () => {
await rm(tempRoot, { recursive: true, force: true });
},
};
};
describe('project-config runtime', () => {
it('creates and persists a scheduled task', async () => {
const { runtime, cleanup } = await createRuntime();
try {
const result = await runtime.upsertScheduledTask('project-test', {
name: 'Nightly digest',
enabled: true,
schedule: {
kind: 'daily',
time: '09:30',
timezone: 'UTC',
},
execution: {
prompt: 'Summarize repository changes',
providerID: 'openai',
modelID: 'gpt-4.1',
},
});
expect(result.created).toBe(true);
expect(result.task.id).toBe('task-fixed-id');
const reloaded = await runtime.listScheduledTasks('project-test');
expect(reloaded).toHaveLength(1);
expect(reloaded[0].name).toBe('Nightly digest');
expect(reloaded[0].schedule.timezone).toBe('UTC');
expect(reloaded[0].schedule.times).toEqual(['09:30']);
} finally {
await cleanup();
}
});
it('rejects invalid cron expressions', async () => {
const { runtime, cleanup } = await createRuntime();
try {
await expect(runtime.upsertScheduledTask('project-test', {
name: 'Invalid cron task',
enabled: true,
schedule: {
kind: 'cron',
cron: 'invalid cron',
timezone: 'UTC',
},
execution: {
prompt: 'Run checks',
providerID: 'openai',
modelID: 'gpt-4.1',
},
})).rejects.toThrow('schedule.cron is invalid');
} finally {
await cleanup();
}
});
it('preserves unknown project config keys when writing scheduled tasks', async () => {
const { runtime, cleanup } = await createRuntime();
try {
const projectID = 'path_preserve';
const filePath = path.join(runtime.resolveProjectConfigPath(projectID));
await writeFile(
filePath,
JSON.stringify({
projectNotes: 'hello notes',
projectTodos: [{ id: 't1', text: 'buy milk', completed: false, createdAt: 1 }],
projectActions: [{ id: 'a1', name: 'Run', command: 'bun run dev' }],
projectActionsPrimaryId: 'a1',
'setup-worktree': ['bun install'],
projectPlanFiles: [{ id: 'p1', path: '/tmp/plans/p1.md', createdAt: 2 }],
projectPath: '/tmp/demo',
}, null, 2),
'utf8',
);
await runtime.upsertScheduledTask(projectID, {
name: 'nightly',
enabled: true,
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
execution: { prompt: 'run', providerID: 'openai', modelID: 'gpt-4.1' },
});
const raw = JSON.parse(await readFile(filePath, 'utf8'));
expect(raw.projectNotes).toBe('hello notes');
expect(raw.projectTodos).toEqual([{ id: 't1', text: 'buy milk', completed: false, createdAt: 1 }]);
expect(raw.projectActions).toHaveLength(1);
expect(raw.projectActionsPrimaryId).toBe('a1');
expect(raw['setup-worktree']).toEqual(['bun install']);
expect(raw.projectPlanFiles).toEqual([{ id: 'p1', path: '/tmp/plans/p1.md', createdAt: 2 }]);
expect(raw.projectPath).toBe('/tmp/demo');
expect(raw.scheduledTasks).toHaveLength(1);
expect(raw.version).toBe(1);
} finally {
await cleanup();
}
});
it('preserves scheduled task state timestamps when listing tasks', async () => {
const { runtime, cleanup } = await createRuntime();
try {
const projectID = 'timestamp_preserve';
const filePath = path.join(runtime.resolveProjectConfigPath(projectID));
await writeFile(
filePath,
JSON.stringify({
scheduledTasks: [{
id: 'task-existing',
name: 'nightly',
enabled: true,
schedule: { kind: 'daily', times: ['09:00'], timezone: 'UTC' },
execution: { prompt: 'run', providerID: 'openai', modelID: 'gpt-4.1' },
state: { createdAt: 10, updatedAt: 20, lastStatus: 'idle' },
}],
}, null, 2),
'utf8',
);
const first = await runtime.listScheduledTasks(projectID);
const second = await runtime.listScheduledTasks(projectID);
expect(first[0].state.createdAt).toBe(10);
expect(first[0].state.updatedAt).toBe(20);
expect(second[0].state.updatedAt).toBe(20);
} finally {
await cleanup();
}
});
it('accepts one-time schedule with date and time', async () => {
const { runtime, cleanup } = await createRuntime();
try {
const result = await runtime.upsertScheduledTask('project-test', {
name: 'One-time review',
enabled: true,
schedule: {
kind: 'once',
date: '2026-04-20',
time: '13:45',
timezone: 'Europe/Kyiv',
},
execution: {
prompt: 'Create a release summary',
providerID: 'openai',
modelID: 'gpt-4.1',
},
});
expect(result.task.schedule.kind).toBe('once');
expect(result.task.schedule.date).toBe('2026-04-20');
expect(result.task.schedule.time).toBe('13:45');
expect(result.task.schedule.timezone).toBe('Europe/Kyiv');
} finally {
await cleanup();
}
});
});
describe('project-config loop reconciliation', () => {
const loop = (name, overrides = {}) => ({
scope: 'project',
filePath: `/repo/.agents/loops/${name}.md`,
definition: {
name,
enabled: true,
schedule: { kind: 'cron', cron: '0 9 * * *', timezone: 'UTC' },
execution: {
prompt: `Loop prompt for ${name}`,
providerID: 'openai',
modelID: 'gpt-4.1',
},
...overrides,
},
});
it('creates tasks for discovered loops with deterministic ids', async () => {
const { runtime, cleanup } = await createRuntime();
try {
const tasks = await runtime.reconcileLoopTasks('project-test', [
loop('daily-digest'),
loop('weekly-report'),
]);
expect(tasks).toHaveLength(2);
const digest = tasks.find((task) => task.name === 'daily-digest');
expect(digest.id).toBe('loop:project:daily-digest');
expect(digest.schedule.cron).toBe('0 9 * * *');
expect(digest.execution.providerID).toBe('openai');
expect(digest.loopFile).toBe('/repo/.agents/loops/daily-digest.md');
const reloaded = await runtime.listScheduledTasks('project-test');
expect(reloaded).toHaveLength(2);
expect(reloaded[0].state.createdAt).toBeGreaterThan(0);
} finally {
await cleanup();
}
});
it('adopts an existing task by name, preserving id and state, and persists state across reconciles', async () => {
const { runtime, cleanup } = await createRuntime();
try {
const created = await runtime.upsertScheduledTask('project-test', {
name: 'daily-digest',
enabled: true,
schedule: { kind: 'daily', time: '09:30', timezone: 'UTC' },
execution: { prompt: 'JSON prompt', providerID: 'openai', modelID: 'gpt-4.1' },
});
const first = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
const adopted = first.find((task) => task.id === created.task.id);
expect(adopted).toBeDefined();
expect(adopted.id).toBe(created.task.id);
expect(adopted.name).toBe('daily-digest');
expect(adopted.schedule.kind).toBe('cron');
expect(adopted.schedule.cron).toBe('0 9 * * *');
expect(adopted.execution.prompt).toBe('Loop prompt for daily-digest');
expect(adopted.loopFile).toBe('/repo/.agents/loops/daily-digest.md');
const state = adopted.state;
await runtime.updateScheduledTaskState('project-test', adopted.id, {
nextRunAt: 123456,
lastRunAt: 111,
lastStatus: 'success',
});
const second = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
const again = second.find((task) => task.id === created.task.id);
expect(again.id).toBe(created.task.id);
expect(again.state.nextRunAt).toBe(123456);
expect(again.state.lastRunAt).toBe(111);
expect(again.state.lastStatus).toBe('success');
expect(again.loopFile).toBe('/repo/.agents/loops/daily-digest.md');
} finally {
await cleanup();
}
});
it('unschedules a loop-sourced task when its file is removed', async () => {
const { runtime, cleanup } = await createRuntime();
try {
await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
const tasks = await runtime.reconcileLoopTasks('project-test', []);
expect(tasks).toHaveLength(0);
expect(await runtime.listScheduledTasks('project-test')).toHaveLength(0);
} finally {
await cleanup();
}
});
it('leaves JSON-configured tasks untouched when no loop matches', async () => {
const { runtime, cleanup } = await createRuntime();
try {
const created = await runtime.upsertScheduledTask('project-test', {
name: 'json-only',
enabled: true,
schedule: { kind: 'daily', time: '08:00', timezone: 'UTC' },
execution: { prompt: 'JSON prompt', providerID: 'openai', modelID: 'gpt-4.1' },
});
const tasks = await runtime.reconcileLoopTasks('project-test', [loop('loop-only')]);
expect(tasks).toHaveLength(2);
expect(tasks.find((task) => task.id === created.task.id)).toBeDefined();
expect(tasks.find((task) => task.name === 'loop-only')).toBeDefined();
} finally {
await cleanup();
}
});
it('does not remove a JSON task that merely shares a loop name after the loop is gone... keeps it when never adopted', async () => {
// A JSON task that was never driven by a loop file (no loopFile marker)
// must survive reconciles even when a loop with the same name existed
// only in a previous reconcile round — but once a loop adopted it, the
// file is authoritative and removing the file unschedules the task.
const { runtime, cleanup } = await createRuntime();
try {
const created = await runtime.upsertScheduledTask('project-test', {
name: 'daily-digest',
enabled: true,
schedule: { kind: 'daily', time: '09:30', timezone: 'UTC' },
execution: { prompt: 'JSON prompt', providerID: 'openai', modelID: 'gpt-4.1' },
});
// First reconcile adopts the task (loopFile marker set).
await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]);
// Loop file removed -> task unscheduled.
const afterRemoval = await runtime.reconcileLoopTasks('project-test', []);
expect(afterRemoval.find((task) => task.id === created.task.id)).toBeUndefined();
} finally {
await cleanup();
}
});
it('skips invalid loop definitions without blocking valid ones', async () => {
const { runtime, cleanup } = await createRuntime();
try {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
try {
const tasks = await runtime.reconcileLoopTasks('project-test', [
loop('bad-loop', { schedule: { kind: 'cron', cron: 'not a cron', timezone: 'UTC' } }),
loop('good-loop'),
]);
expect(tasks.map((task) => task.name)).toEqual(['good-loop']);
expect(warn).toHaveBeenCalled();
} finally {
warn.mockRestore();
}
} finally {
await cleanup();
}
});
});