Files
waggle-os/packages/core/tests/cron-store.test.ts
Oleg Maslov b20b138fe4 moving
2026-09-02 10:14:22 +02:00

274 lines
11 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { MindDB } from '@waggle/hive-mind-core';
import { CronStore, type CreateScheduleInput, type SavePendingActionInput } from '../src/cron-store.js';
describe('CronStore', () => {
let tmpDir: string;
let db: MindDB;
let store: CronStore;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-cron-'));
db = new MindDB(path.join(tmpDir, 'test.mind'));
store = new CronStore(db);
});
afterEach(() => {
db.close();
fs.rmSync(tmpDir, {
recursive: true,
force: true,
maxRetries: 10,
retryDelay: 100,
});
});
function makeInput(overrides?: Partial<CreateScheduleInput>): CreateScheduleInput {
return {
name: 'Daily backup',
cronExpr: '0 9 * * *', // every day at 9am
jobType: 'memory_consolidation',
...overrides,
};
}
it('create with valid cron computes next_run_at in the future', () => {
const schedule = store.create(makeInput());
expect(schedule.id).toBeGreaterThan(0);
expect(schedule.name).toBe('Daily backup');
expect(schedule.cron_expr).toBe('0 9 * * *');
expect(schedule.job_type).toBe('memory_consolidation');
expect(schedule.enabled).toBe(1);
expect(schedule.next_run_at).toBeTruthy();
// next_run_at should be in the future
expect(new Date(schedule.next_run_at!).getTime()).toBeGreaterThan(Date.now());
});
it('create with invalid cron expression throws', () => {
expect(() => store.create(makeInput({ cronExpr: 'not a cron' }))).toThrow();
});
it('create agent_task without workspaceId throws', () => {
expect(() =>
store.create(makeInput({ jobType: 'agent_task' })),
).toThrow(/workspace/i);
});
it('create loop job type round-trips with its job_config', () => {
const schedule = store.create(makeInput({
jobType: 'loop',
jobConfig: { prompt: 'Summarize what changed in this workspace.' },
}));
expect(schedule.job_type).toBe('loop');
expect(JSON.parse(schedule.job_config).prompt).toBe('Summarize what changed in this workspace.');
});
it('create loop without workspaceId succeeds (loops can run on the personal mind)', () => {
const schedule = store.create(makeInput({ jobType: 'loop' }));
expect(schedule.job_type).toBe('loop');
expect(schedule.workspace_id).toBeNull();
});
it('create agent_task with workspaceId succeeds', () => {
const schedule = store.create(makeInput({
jobType: 'agent_task',
workspaceId: 'ws-123',
}));
expect(schedule.job_type).toBe('agent_task');
expect(schedule.workspace_id).toBe('ws-123');
});
it('list returns schedules ordered by name', () => {
store.create(makeInput({ name: 'Zebra task' }));
store.create(makeInput({ name: 'Alpha task' }));
store.create(makeInput({ name: 'Middle task' }));
const list = store.list();
expect(list).toHaveLength(3);
expect(list[0].name).toBe('Alpha task');
expect(list[1].name).toBe('Middle task');
expect(list[2].name).toBe('Zebra task');
});
it('getById returns the schedule when found', () => {
const created = store.create(makeInput({ name: 'Findable' }));
const found = store.getById(created.id);
expect(found).toBeDefined();
expect(found!.name).toBe('Findable');
});
it('getById returns undefined when not found', () => {
const found = store.getById(99999);
expect(found).toBeUndefined();
});
it('update changes name and enabled', () => {
const created = store.create(makeInput());
store.update(created.id, { name: 'Renamed', enabled: false });
const updated = store.getById(created.id)!;
expect(updated.name).toBe('Renamed');
expect(updated.enabled).toBe(0);
});
it('update cronExpr recomputes next_run_at', () => {
const created = store.create(makeInput({ cronExpr: '0 9 * * *' }));
const originalNext = created.next_run_at;
// Change to every minute — next_run_at should change
store.update(created.id, { cronExpr: '*/1 * * * *' });
const updated = store.getById(created.id)!;
expect(updated.cron_expr).toBe('*/1 * * * *');
expect(updated.next_run_at).toBeTruthy();
// The new next_run_at should differ (different schedule)
// Both should be valid ISO dates
expect(new Date(updated.next_run_at!).getTime()).toBeGreaterThan(0);
});
it('delete removes the schedule', () => {
const created = store.create(makeInput());
store.delete(created.id);
expect(store.getById(created.id)).toBeUndefined();
});
it('getDue returns only enabled past-due schedules', () => {
const raw = db.getDatabase();
// Create two schedules: one past-due, one future
store.create(makeInput({ name: 'Future job' }));
// Manually insert a past-due schedule
raw.prepare(`
INSERT INTO cron_schedules (name, cron_expr, job_type, job_config, enabled, next_run_at, created_at)
VALUES (?, ?, ?, ?, 1, datetime('now', '-1 hour'), datetime('now'))
`).run('Past due job', '0 9 * * *', 'memory_consolidation', '{}');
const due = store.getDue();
expect(due).toHaveLength(1);
expect(due[0].name).toBe('Past due job');
});
it('getDue excludes disabled schedules', () => {
const raw = db.getDatabase();
// Insert a past-due but disabled schedule
raw.prepare(`
INSERT INTO cron_schedules (name, cron_expr, job_type, job_config, enabled, next_run_at, created_at)
VALUES (?, ?, ?, ?, 0, datetime('now', '-1 hour'), datetime('now'))
`).run('Disabled job', '0 9 * * *', 'memory_consolidation', '{}');
const due = store.getDue();
expect(due).toHaveLength(0);
});
it('markRun updates last_run_at and recomputes next_run_at', () => {
const created = store.create(makeInput({ cronExpr: '0 9 * * *' }));
expect(created.last_run_at).toBeNull();
store.markRun(created.id);
const updated = store.getById(created.id)!;
expect(updated.last_run_at).toBeTruthy();
expect(updated.next_run_at).toBeTruthy();
// next_run_at should be in the future
expect(new Date(updated.next_run_at!).getTime()).toBeGreaterThan(Date.now());
});
it('pruneExecutionHistory deletes only rows older than the cutoff', () => {
const created = store.create(makeInput());
// A fresh row (executed_at = now) must survive the prune.
store.recordExecution(created.id, created.name, { success: true });
// A back-dated row beyond the 30-day retention must go.
db.getDatabase().prepare(`
INSERT INTO cron_execution_history (schedule_id, schedule_name, executed_at, success)
VALUES (?, ?, datetime('now', '-40 days'), 1)
`).run(created.id, created.name);
expect(store.getExecutionHistory(created.id)).toHaveLength(2);
const deleted = store.pruneExecutionHistory(30);
expect(deleted).toBe(1);
const remaining = store.getExecutionHistory(created.id);
expect(remaining).toHaveLength(1);
expect(remaining[0].success).toBe(1);
});
it('countExecutionsToday counts only today, only this schedule (#17 daily cap)', () => {
const created = store.create(makeInput());
const other = store.create(makeInput({ name: 'Other job' }));
store.recordExecution(created.id, created.name, { success: true });
store.recordExecution(created.id, created.name, { success: false });
store.recordExecution(other.id, other.name, { success: true });
// Yesterday's row must not count.
db.getDatabase().prepare(`
INSERT INTO cron_execution_history (schedule_id, schedule_name, executed_at, success)
VALUES (?, ?, datetime('now', '-1 day'), 1)
`).run(created.id, created.name);
expect(store.countExecutionsToday(created.id)).toBe(2);
expect(store.countExecutionsToday(other.id)).toBe(1);
});
describe('pending_actions (L2 held-action queue)', () => {
function held(over?: Partial<SavePendingActionInput>): SavePendingActionInput {
return {
id: 'pa-1', workspaceId: null, source: 'loop:1', toolName: 'send_email',
argsJson: JSON.stringify({ to: 'x@y.z', subject: 'hi' }), summary: 'Send follow-up',
riskLevel: 'medium', approvalClass: 'elevated', ...over,
};
}
it('saves and lists held actions, filtered by status', () => {
store.savePendingAction(held());
store.savePendingAction(held({ id: 'pa-2', toolName: 'write_file' }));
const heldRows = store.listPendingActions('held');
expect(heldRows).toHaveLength(2);
expect(heldRows.map(r => r.tool_name).sort()).toEqual(['send_email', 'write_file']);
expect(store.listPendingActions('executed')).toHaveLength(0);
});
it('claimPendingAction is an atomic idempotency gate (double-claim no-ops)', () => {
store.savePendingAction(held());
const first = store.claimPendingAction('pa-1', 'approved', '2026-06-29T10:00:00Z');
expect(first?.status).toBe('approved');
expect(first?.decided_at).toBe('2026-06-29T10:00:00Z');
// A second claim (double-approve / approve-after-deny) wins nothing.
expect(store.claimPendingAction('pa-1', 'approved', '2026-06-29T10:05:00Z')).toBeUndefined();
expect(store.claimPendingAction('pa-1', 'denied', '2026-06-29T10:05:00Z')).toBeUndefined();
// Unknown id → undefined.
expect(store.claimPendingAction('nope', 'approved', '2026-06-29T10:00:00Z')).toBeUndefined();
});
it('updatePendingActionResult records the terminal outcome after a claim', () => {
store.savePendingAction(held());
store.claimPendingAction('pa-1', 'approved', '2026-06-29T10:00:00Z');
store.updatePendingActionResult('pa-1', { status: 'executed', resultSummary: 'sent', executedAt: '2026-06-29T10:01:00Z' });
const row = store.getPendingAction('pa-1');
expect(row?.status).toBe('executed');
expect(row?.result_summary).toBe('sent');
expect(row?.executed_at).toBe('2026-06-29T10:01:00Z');
});
it('persists held actions across a DB reopen (durable queue)', () => {
store.savePendingAction(held());
db.close();
db = new MindDB(path.join(tmpDir, 'test.mind'));
store = new CronStore(db);
const rows = store.listPendingActions('held');
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe('pa-1');
});
it('expireStalePendingActions flips past-due held rows to expired', () => {
store.savePendingAction(held({ expiresAt: '2000-01-01T00:00:00Z' })); // long past
store.savePendingAction(held({ id: 'pa-2', expiresAt: '2999-01-01T00:00:00Z' })); // future
expect(store.expireStalePendingActions()).toBe(1);
expect(store.listPendingActions('held').map(r => r.id)).toEqual(['pa-2']);
expect(store.listPendingActions('expired').map(r => r.id)).toEqual(['pa-1']);
});
});
});