Files
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

209 lines
6.5 KiB
JavaScript

import { describe, expect, it, vi } from 'vitest';
import os from 'os';
import path from 'path';
import { mkdtemp, rm, mkdir, writeFile } from 'fs/promises';
import {
computeNextRunAt,
expandCommandGoalObjective,
formatScheduledSessionTitle,
parseScheduledCommandPrompt,
createScheduledTasksRuntime,
} from './runtime.js';
import { createProjectConfigRuntime } from '../projects/project-config.js';
describe('scheduled-tasks runtime helpers', () => {
it('computes next daily run in timezone', () => {
const nowUtc = Date.UTC(2025, 0, 1, 8, 0, 0);
const next = computeNextRunAt({
enabled: true,
schedule: {
kind: 'daily',
times: ['09:30'],
timezone: 'UTC',
},
}, nowUtc);
expect(next).toBe(Date.UTC(2025, 0, 1, 9, 30, 0));
});
it('computes weekly next run using weekdays', () => {
// Monday 2025-01-06 10:00:00 UTC
const nowUtc = Date.UTC(2025, 0, 6, 10, 0, 0);
const next = computeNextRunAt({
enabled: true,
schedule: {
kind: 'weekly',
times: ['09:00'],
weekdays: [1, 3],
timezone: 'UTC',
},
}, nowUtc);
// Wednesday 2025-01-08 09:00:00 UTC
expect(next).toBe(Date.UTC(2025, 0, 8, 9, 0, 0));
});
it('picks nearest time from multiple daily times', () => {
const nowUtc = Date.UTC(2025, 0, 1, 9, 20, 0);
const next = computeNextRunAt({
enabled: true,
schedule: {
kind: 'daily',
times: ['09:15', '09:45', '18:00'],
timezone: 'UTC',
},
}, nowUtc);
expect(next).toBe(Date.UTC(2025, 0, 1, 9, 45, 0));
});
it('computes one-time next run for future date', () => {
const nowUtc = Date.UTC(2026, 3, 15, 10, 0, 0);
const next = computeNextRunAt({
enabled: true,
schedule: {
kind: 'once',
date: '2026-04-16',
time: '13:30',
timezone: 'UTC',
},
}, nowUtc);
expect(next).toBe(Date.UTC(2026, 3, 16, 13, 30, 0));
});
it('returns null for past one-time schedule', () => {
const nowUtc = Date.UTC(2026, 3, 16, 14, 0, 0);
const next = computeNextRunAt({
enabled: true,
schedule: {
kind: 'once',
date: '2026-04-16',
time: '13:30',
timezone: 'UTC',
},
}, nowUtc);
expect(next).toBeNull();
});
it('formats session title with timestamp suffix', () => {
const title = formatScheduledSessionTitle({
name: 'Morning Sync',
schedule: { timezone: 'UTC' },
}, Date.UTC(2025, 2, 10, 7, 5, 0));
expect(title).toBe('Morning Sync 2025-03-10 07:05');
});
it('parses slash command prompt for scheduled command mode', () => {
expect(parseScheduledCommandPrompt('/review src/components')).toEqual({
command: 'review',
arguments: 'src/components',
});
});
it('returns null when prompt is not a slash command', () => {
expect(parseScheduledCommandPrompt('Summarize open issues')).toBeNull();
expect(parseScheduledCommandPrompt('/')).toBeNull();
});
it('expands command arguments into the goal objective', () => {
expect(expandCommandGoalObjective(
'Run the issue pipeline for $ARGUMENTS. Verify $ARGUMENTS is represented by the PR.',
'LIN-123 --draft',
)).toBe('Run the issue pipeline for LIN-123 --draft. Verify LIN-123 --draft is represented by the PR.');
expect(expandCommandGoalObjective(undefined, 'LIN-123')).toBeNull();
expect(expandCommandGoalObjective('Move $1 to $2', '"src old" dist extra')).toBe('Move src old to dist extra');
expect(expandCommandGoalObjective('Review the requested scope.', 'auth module'))
.toBe('Review the requested scope.\n\nauth module');
});
});
describe('scheduled-tasks runtime syncProject wiring', () => {
const createTempProject = async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-runtime-loop-'));
const repoPath = path.join(tempRoot, 'repo');
await mkdir(path.join(repoPath, '.agents', 'loops'), { recursive: true });
return {
tempRoot,
repoPath,
cleanup: async () => {
await rm(tempRoot, { recursive: true, force: true });
},
};
};
const createProjectConfig = async (tempRoot) => createProjectConfigRuntime({
fsPromises: await import('fs/promises'),
path,
projectsDirPath: path.join(tempRoot, 'config'),
createTaskID: () => 'task-fixed-id',
});
const createRuntimeDeps = (overrides = {}) => ({
buildOpenCodeUrl: () => 'http://localhost',
getOpenCodeAuthHeaders: () => ({}),
waitForOpenCodeReady: async () => {},
...overrides,
});
it('reconciles discovered loops when the project path is known', async () => {
const { tempRoot, repoPath, cleanup } = await createTempProject();
try {
await writeFile(path.join(repoPath, '.agents', 'loops', 'daily.md'), `---
name: daily
schedule: "0 9 * * *"
enabled: true
model: openai/gpt-5
---
Run daily.
`, 'utf8');
const projectConfigRuntime = await createProjectConfig(tempRoot);
const runtime = createScheduledTasksRuntime({
...createRuntimeDeps(),
projectConfigRuntime,
listProjects: async () => [{ id: 'proj', path: repoPath }],
});
await runtime.syncProject('proj');
const tasks = await projectConfigRuntime.listScheduledTasks('proj');
expect(tasks).toHaveLength(1);
expect(tasks[0].id).toBe('loop:project:daily');
expect(tasks[0].loopFile).toBe(path.join(repoPath, '.agents', 'loops', 'daily.md'));
// syncTaskSchedule computed and persisted the next run for the enabled task.
expect(tasks[0].state.nextRunAt).toBeGreaterThan(0);
} finally {
await cleanup();
}
});
it('falls back to plain listing when the project path cannot be resolved', async () => {
const { tempRoot, cleanup } = await createTempProject();
try {
const projectConfigRuntime = await createProjectConfig(tempRoot);
const reconcileSpy = vi.spyOn(projectConfigRuntime, 'reconcileLoopTasks');
const listSpy = vi.spyOn(projectConfigRuntime, 'listScheduledTasks');
const runtime = createScheduledTasksRuntime({
...createRuntimeDeps(),
projectConfigRuntime,
// Project not registered -> ensureProjectPath cannot resolve a path.
listProjects: async () => [],
});
await runtime.syncProject('proj');
expect(reconcileSpy).not.toHaveBeenCalled();
expect(listSpy).toHaveBeenCalledWith('proj');
expect(await projectConfigRuntime.listScheduledTasks('proj')).toEqual([]);
reconcileSpy.mockRestore();
listSpy.mockRestore();
} finally {
await cleanup();
}
});
});