Ctrl K
ComunidadGuías…

test/jobs-once.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Scheduled one-time jobs: `run_at` (and `once` with a schedule, for old
// callers) makes one card with a start time; the dispatcher waits for it; a
// server start converts pending once schedules; the restart sweep of spent
// one-date schedules; archiving by hand (the owner's Archive, Billion's
// close_job on a To do card is in billion-board-tools.test.js).
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { config, sessions, CONFIG_PATH, CONFIG_DIR } from '../server/state.js';
import { loadConfig } from '../server/config.js';
import {
  addJob, fireSchedules, allJobs, boardSettings, postJobForAgent, editJobForAgent,
  archiveJob, retireSpentSchedules, convertOnceSchedules, updateJob, releaseJobHold,
} from '../server/jobs.js';
import { createJob, parseRunAt, isOneDateCron, spentOneDateSchedules, isJobDue, selectDispatchableJobs } from '../lib/jobs.js';

const REPO = mkdtempSync(join(tmpdir(), 'a007-once-'));
const noop = () => {};

beforeEach(() => {
  config.repos = [{ path: REPO }];
  config.jobs = [];
  config.jobBoard = null;
  boardSettings();
  sessions.clear();
});

describe('parseRunAt', () => {
  const now = new Date(2026, 9, 1, 9, 0).getTime();   // 1 Oct 2026, 09:00 local

  it('turns a date-time into that minute as an instant, local time', () => {
    expect(parseRunAt('2026-10-24T10:30:45', now)).toEqual({ runAt: new Date(2026, 9, 24, 10, 30).toISOString() });
  });

  it('refuses a time gone by, more than a year out, or not a date', () => {
    expect(parseRunAt('2026-09-24T10:00', now).error).toMatch(/already gone by/);
    expect(parseRunAt('2027-12-01T10:00', now).error).toMatch(/more than a year/);
    expect(parseRunAt('next tuesday', now).error).toMatch(/not a date-time/);
    expect(parseRunAt(42, now).error).toMatch(/ISO date-time/);
  });
});

describe('isOneDateCron / spentOneDateSchedules', () => {
  it('knows the one-date shape: fixed minute, hour, day and month, any weekday', () => {
    expect(isOneDateCron('0 10 24 9 *')).toBe(true);
    expect(isOneDateCron('30 10 1 10 *')).toBe(true);
    expect(isOneDateCron('0 10 * 9 *')).toBe(false);
    expect(isOneDateCron('0 10 24 9 1')).toBe(false);
    expect(isOneDateCron('0 9 1,15 * *')).toBe(false);
    expect(isOneDateCron('@yearly')).toBe(false);
  });

  it('finds the ones in To do that have already run, and leaves the rest', () => {
    const job = (over) => ({ ...createJob({ title: 't', repoPath: REPO, schedule: '0 10 24 9 *' }).job, ...over });
    const spent = job({ runCount: 1, lastRunAt: '2026-09-24T10:00:00Z' });
    const notYet = job({});
    const weekly = { ...createJob({ title: 't', repoPath: REPO, schedule: '0 9 * * 1' }).job, runCount: 3 };
    const archived = job({ runCount: 1, state: 'done' });
    expect(spentOneDateSchedules([spent, notYet, weekly, archived])).toEqual([spent]);
  });
});

describe('a scheduled job', () => {
  const inDays = d => new Date(Date.now() + d * 86_400_000);

  it('is posted with run_at as one one-time card with a start time: no schedule, no run card', () => {
    const at = inDays(3);
    const r = postJobForAgent({ title: 'Follow up with Edward', repo: REPO, runAt: at.toISOString() }, noop);
    expect(r.error).toBeUndefined();
    expect(r.job).toMatchObject({ type: 'one-time', schedule: null, nextRunAt: null, requiresPr: true });
    expect(Math.abs(Date.parse(r.job.runAt) - at.getTime())).toBeLessThan(60_000);
    expect(fireSchedules(noop, { now: at.getTime() + 60_000 })).toEqual([]);
    expect(allJobs()).toHaveLength(1);
  });

  it('is what an old caller\'s once: true with a one-date schedule makes too', () => {
    const r = postJobForAgent({ title: 'old caller', repo: REPO, schedule: '0 10 24 9 *', once: true }, noop);
    expect(r.job).toMatchObject({ type: 'one-time', schedule: null });
    expect(new Date(r.job.runAt).getDate()).toBe(24);
    expect(new Date(r.job.runAt).getHours()).toBe(10);
  });

  it('waits in To do until its start time, then the dispatcher takes the same card', () => {
    const { job } = addJob({ title: 'later', repoPath: REPO, runAt: inDays(1).toISOString() }, noop);
    expect(isJobDue(job)).toBe(false);
    expect(selectDispatchableJobs(allJobs())).toEqual([]);
    const due = Date.parse(job.runAt);
    expect(selectDispatchableJobs(allJobs(), { now: due })).toEqual([job]);
  });

  it('Run now lets it go at once', () => {
    const { job } = addJob({ title: 'later', repoPath: REPO, runAt: inDays(1).toISOString() }, noop);
    releaseJobHold(job.id, noop);
    expect(job.runAt).toBeNull();
    expect(selectDispatchableJobs(allJobs())).toEqual([job]);
  });

  it('is edited by moving its time, cleared by Now, and made a schedule by Recurring', () => {
    const { job } = addJob({ title: 'x', repoPath: REPO, runAt: inDays(1).toISOString() }, noop);
    const later = inDays(5);
    const r = editJobForAgent({ id: job.id, runAt: later.toISOString() }, noop);
    expect(r.changed).toContain('run_at');
    expect(Math.abs(Date.parse(job.runAt) - later.getTime())).toBeLessThan(60_000);
    // the form: Scheduled with the time left alone sends no runAt
    expect(updateJob(job.id, { type: 'one-time', title: 'y' }, noop).job.runAt).toBe(job.runAt);
    expect(updateJob(job.id, { type: 'one-time', runAt: null }, noop).job.runAt).toBeNull();
    updateJob(job.id, { runAt: inDays(2).toISOString() }, noop);
    updateJob(job.id, { type: 'scheduled', schedule: '0 9 * * 1' }, noop);
    expect(job).toMatchObject({ type: 'scheduled', runAt: null });
  });

  it('refuses run_at with a schedule, once on a card with no schedule, a non-boolean once, and a bad time', () => {
    const later = inDays(1).toISOString();
    expect(postJobForAgent({ title: 'x', repo: REPO, runAt: later, schedule: '0 9 * * *' }, noop).error).toMatch(/not both/);
    expect(postJobForAgent({ title: 'x', repo: REPO, once: true }, noop).error).toMatch(/once needs a schedule/);
    expect(postJobForAgent({ title: 'x', repo: REPO, schedule: '0 9 * * *', once: 'yes' }, noop).error).toMatch(/true or false/);
    expect(postJobForAgent({ title: 'x', repo: REPO, runAt: '2020-01-01T09:00' }, noop).error).toMatch(/gone by/);
    expect(allJobs()).toHaveLength(0);
  });

  it('a recurring schedule is untouched: it posts a run, re-arms and stays in To do', () => {
    const { job } = addJob({ title: 'weekly', repoPath: REPO, schedule: '0 9 * * 1' }, noop);
    const due = Date.parse(job.nextRunAt);
    expect(fireSchedules(noop, { now: due })).toHaveLength(1);
    expect(job.state).toBe('todo');
    expect(Date.parse(job.nextRunAt)).toBeGreaterThan(due);
  });
});

describe('converting once schedules on a server start', () => {
  it('turns a pending once schedule into the same card with runAt, and leaves the rest alone', () => {
    const due = new Date(Date.now() + 86_400_000).toISOString();
    const pending = {
      ...createJob({ title: 'Oct 2 check-in', detail: 'd', repoPath: REPO, schedule: '0 10 2 10 *', agent: 'codex', postedByBillion: true, postedByName: 'Bill' }).job,
      once: true, nextRunAt: due, attachments: [{ name: 'a.txt', path: '/x/a.txt' }], permissionMode: 'plan',
    };
    const archived = { ...createJob({ title: 'fired', repoPath: REPO, schedule: '0 10 24 9 *' }).job, once: true, state: 'done', runCount: 1 };
    const run = { ...createJob({ title: 'its run', repoPath: REPO }).job, scheduleId: archived.id };
    const weekly = createJob({ title: 'weekly', repoPath: REPO, schedule: '0 9 * * 1' }).job;
    config.jobs.push(pending, archived, run, weekly);
    const before = { ...pending };
    const lines = [];
    expect(convertOnceSchedules(noop, { log: l => lines.push(l) })).toEqual([pending]);
    expect(pending).toMatchObject({
      id: before.id, title: before.title, detail: 'd', repoPath: REPO, agent: 'codex', model: null,
      requiresPr: false, attachments: before.attachments, permissionMode: 'plan', postedByBillion: true, postedByName: 'Bill',
      type: 'one-time', schedule: null, nextRunAt: null, runAt: due, state: 'todo',
    });
    expect(pending).not.toHaveProperty('once');
    expect(lines).toHaveLength(1);
    expect(lines[0]).toMatch(/Oct 2 check-in/);
    expect(archived).toMatchObject({ state: 'done', type: 'scheduled', once: true });
    expect(run.scheduleId).toBe(archived.id);
    expect(weekly).toMatchObject({ type: 'scheduled', schedule: '0 9 * * 1' });
  });
});

describe('archiving', () => {
  it('files a To do card to Finished with a note, keeping it', () => {
    const { job } = addJob({ title: 'not needed', repoPath: REPO, schedule: '0 9 * * 1' }, noop);
    const r = archiveJob(job.id, { reason: 'Archived from the Jobs tab', by: 'Bill' }, noop);
    expect(r.error).toBeUndefined();
    expect(job.state).toBe('done');
    expect(job.archivedBy).toBe('Bill');
    expect(job.archivedReason).toBe('Archived from the Jobs tab');
    expect(job.nextRunAt).toBeNull();
    expect(allJobs()).toContain(job);
    expect(fireSchedules(noop, { now: Date.now() + 30 * 86_400_000 })).toEqual([]);
  });

  it('refuses a card that has left To do', () => {
    const { job } = addJob({ title: 'running', repoPath: REPO }, noop);
    job.state = 'in-progress';
    expect(archiveJob(job.id, {}, noop).error).toMatch(/only a To do card/);
  });

  it('archives spent one-date schedules on a server start, logging each', () => {
    const { job: spent } = addJob({ title: 'Edward Jordan: one conditional follow-up September 24', repoPath: REPO, schedule: '0 10 24 9 *' }, noop);
    Object.assign(spent, { runCount: 1, lastRunAt: '2026-09-24T10:00:00Z' });
    const { job: waiting } = addJob({ title: 'not yet', repoPath: REPO, schedule: '0 10 24 12 *' }, noop);
    const lines = [];
    retireSpentSchedules(noop, { log: line => lines.push(line) });
    expect(spent.state).toBe('done');
    expect(spent.archivedReason).toMatch(/already run/);
    expect(waiting.state).toBe('todo');
    expect(lines).toHaveLength(1);
    expect(lines[0]).toMatch(/Edward Jordan/);
  });
});

describe('an archived schedule across a restart', () => {
  afterEach(() => { try { rmSync(CONFIG_PATH); } catch {} });

  it('stays archived, rather than going back to To do', () => {
    mkdirSync(CONFIG_DIR, { recursive: true });
    writeFileSync(CONFIG_PATH, JSON.stringify({
      version: 1, repos: [], orphans: [], activeSessions: [],
      jobs: [{ id: 's1', title: 'once', type: 'scheduled', schedule: '0 10 24 9 *', once: true, repoPath: '/r', state: 'done', archivedAt: '2026-09-24T10:00:00Z' }],
    }));
    loadConfig();
    expect(config.jobs[0].state).toBe('done');
  });
});