mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-04 22:51:08 +00:00
Merge pull request #2731 from QwenLM/feat/in-session-cron-loops
feat(cron): add in-session loop scheduling with cron tools
This commit is contained in:
commit
76d64c9464
60 changed files with 3110 additions and 41 deletions
|
|
@ -58,6 +58,7 @@ import type {
|
|||
import { type AgentEventEmitter, AgentEventType } from './agent-events.js';
|
||||
import { AgentStatistics, type AgentStatsSummary } from './agent-statistics.js';
|
||||
import { AgentTool } from '../../tools/agent.js';
|
||||
import { ToolNames } from '../../tools/tool-names.js';
|
||||
import { DEFAULT_QWEN_MODEL } from '../../config/models.js';
|
||||
import { type ContextState, templateString } from './agent-headless.js';
|
||||
|
||||
|
|
@ -273,6 +274,15 @@ export class AgentCore {
|
|||
const toolRegistry = this.runtimeContext.getToolRegistry();
|
||||
const toolsList: FunctionDeclaration[] = [];
|
||||
|
||||
// Tools excluded from subagents: AgentTool (prevent recursion) and
|
||||
// cron tools (session-scoped, should only be used by the main session).
|
||||
const excludedFromSubagents = new Set<string>([
|
||||
AgentTool.Name,
|
||||
ToolNames.CRON_CREATE,
|
||||
ToolNames.CRON_LIST,
|
||||
ToolNames.CRON_DELETE,
|
||||
]);
|
||||
|
||||
if (this.toolConfig) {
|
||||
const asStrings = this.toolConfig.tools.filter(
|
||||
(t): t is string => typeof t === 'string',
|
||||
|
|
@ -286,11 +296,13 @@ export class AgentCore {
|
|||
toolsList.push(
|
||||
...toolRegistry
|
||||
.getFunctionDeclarations()
|
||||
.filter((t) => t.name !== AgentTool.Name),
|
||||
.filter((t) => !(t.name && excludedFromSubagents.has(t.name))),
|
||||
);
|
||||
} else {
|
||||
toolsList.push(
|
||||
...toolRegistry.getFunctionDeclarationsFiltered(asStrings),
|
||||
...toolRegistry.getFunctionDeclarationsFiltered(
|
||||
asStrings.filter((name) => !excludedFromSubagents.has(name)),
|
||||
),
|
||||
);
|
||||
}
|
||||
toolsList.push(...onlyInlineDecls);
|
||||
|
|
@ -299,7 +311,7 @@ export class AgentCore {
|
|||
toolsList.push(
|
||||
...toolRegistry
|
||||
.getFunctionDeclarations()
|
||||
.filter((t) => t.name !== AgentTool.Name),
|
||||
.filter((t) => !(t.name && excludedFromSubagents.has(t.name))),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import {
|
|||
type FileEncodingType,
|
||||
} from '../services/fileSystemService.js';
|
||||
import { GitService } from '../services/gitService.js';
|
||||
import { CronScheduler } from '../services/cronScheduler.js';
|
||||
|
||||
// Tools
|
||||
import { AskUserQuestionTool } from '../tools/askUserQuestion.js';
|
||||
|
|
@ -63,6 +64,9 @@ import { WebFetchTool } from '../tools/web-fetch.js';
|
|||
import { WebSearchTool } from '../tools/web-search/index.js';
|
||||
import { WriteFileTool } from '../tools/write-file.js';
|
||||
import { LspTool } from '../tools/lsp.js';
|
||||
import { CronCreateTool } from '../tools/cron-create.js';
|
||||
import { CronListTool } from '../tools/cron-list.js';
|
||||
import { CronDeleteTool } from '../tools/cron-delete.js';
|
||||
import type { LspClient } from '../lsp/types.js';
|
||||
|
||||
// Other modules
|
||||
|
|
@ -368,6 +372,7 @@ export interface ConfigParameters {
|
|||
maxSessionTurns?: number;
|
||||
sessionTokenLimit?: number;
|
||||
experimentalZedIntegration?: boolean;
|
||||
cronEnabled?: boolean;
|
||||
listExtensions?: boolean;
|
||||
overrideExtensions?: string[];
|
||||
allowedMcpServers?: string[];
|
||||
|
|
@ -530,6 +535,7 @@ export class Config {
|
|||
private readonly usageStatisticsEnabled: boolean;
|
||||
private geminiClient!: GeminiClient;
|
||||
private baseLlmClient!: BaseLlmClient;
|
||||
private cronScheduler: CronScheduler | null = null;
|
||||
private readonly fileFiltering: {
|
||||
respectGitIgnore: boolean;
|
||||
respectQwenIgnore: boolean;
|
||||
|
|
@ -557,6 +563,7 @@ export class Config {
|
|||
|
||||
private readonly cliVersion?: string;
|
||||
private readonly experimentalZedIntegration: boolean = false;
|
||||
private readonly cronEnabled: boolean = false;
|
||||
private readonly chatRecordingEnabled: boolean;
|
||||
private readonly loadMemoryFromIncludeDirectories: boolean = false;
|
||||
private readonly importFormat: 'tree' | 'flat';
|
||||
|
|
@ -679,6 +686,7 @@ export class Config {
|
|||
this.sessionTokenLimit = params.sessionTokenLimit ?? -1;
|
||||
this.experimentalZedIntegration =
|
||||
params.experimentalZedIntegration ?? false;
|
||||
this.cronEnabled = params.cronEnabled ?? false;
|
||||
this.listExtensions = params.listExtensions ?? false;
|
||||
this.overrideExtensions = params.overrideExtensions;
|
||||
this.noBrowser = params.noBrowser ?? false;
|
||||
|
|
@ -1687,6 +1695,19 @@ export class Config {
|
|||
return this.geminiClient;
|
||||
}
|
||||
|
||||
getCronScheduler(): CronScheduler {
|
||||
if (!this.cronScheduler) {
|
||||
this.cronScheduler = new CronScheduler();
|
||||
}
|
||||
return this.cronScheduler;
|
||||
}
|
||||
|
||||
isCronEnabled(): boolean {
|
||||
// Cron is experimental and opt-in: enabled via settings or env var
|
||||
if (process.env['QWEN_CODE_ENABLE_CRON'] === '1') return true;
|
||||
return this.cronEnabled;
|
||||
}
|
||||
|
||||
getEnableRecursiveFileSearch(): boolean {
|
||||
return this.fileFiltering.enableRecursiveFileSearch;
|
||||
}
|
||||
|
|
@ -2204,6 +2225,13 @@ export class Config {
|
|||
await registerCoreTool(LspTool, this);
|
||||
}
|
||||
|
||||
// Register cron tools unless disabled
|
||||
if (this.isCronEnabled()) {
|
||||
await registerCoreTool(CronCreateTool, this);
|
||||
await registerCoreTool(CronListTool, this);
|
||||
await registerCoreTool(CronDeleteTool, this);
|
||||
}
|
||||
|
||||
if (!options?.skipDiscovery) {
|
||||
await registry.discoverAllTools();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,6 +92,8 @@ export enum SendMessageType {
|
|||
ToolResult = 'toolResult',
|
||||
Retry = 'retry',
|
||||
Hook = 'hook',
|
||||
/** Cron-fired prompt. Behaves like UserQuery but skips UserPromptSubmit hook. */
|
||||
Cron = 'cron',
|
||||
}
|
||||
|
||||
export interface SendMessageOptions {
|
||||
|
|
@ -472,6 +474,7 @@ export class GeminiClient {
|
|||
const messageBus = this.config.getMessageBus();
|
||||
if (
|
||||
messageType !== SendMessageType.Retry &&
|
||||
messageType !== SendMessageType.Cron &&
|
||||
hooksEnabled &&
|
||||
messageBus &&
|
||||
this.config.hasHooksForEvent('UserPromptSubmit')
|
||||
|
|
@ -516,7 +519,10 @@ export class GeminiClient {
|
|||
}
|
||||
}
|
||||
|
||||
if (messageType === SendMessageType.UserQuery) {
|
||||
if (
|
||||
messageType === SendMessageType.UserQuery ||
|
||||
messageType === SendMessageType.Cron
|
||||
) {
|
||||
this.loopDetector.reset(prompt_id);
|
||||
this.lastPromptId = prompt_id;
|
||||
|
||||
|
|
@ -614,7 +620,10 @@ export class GeminiClient {
|
|||
|
||||
// append system reminders to the request
|
||||
let requestToSent = await flatMapTextParts(request, async (text) => [text]);
|
||||
if (messageType === SendMessageType.UserQuery) {
|
||||
if (
|
||||
messageType === SendMessageType.UserQuery ||
|
||||
messageType === SendMessageType.Cron
|
||||
) {
|
||||
const systemReminders = [];
|
||||
|
||||
// add subagent system reminder if there are subagents
|
||||
|
|
|
|||
|
|
@ -97,12 +97,16 @@ export * from './tools/tool-registry.js';
|
|||
export * from './tools/web-fetch.js';
|
||||
export * from './tools/web-search/index.js';
|
||||
export * from './tools/write-file.js';
|
||||
export * from './tools/cron-create.js';
|
||||
export * from './tools/cron-list.js';
|
||||
export * from './tools/cron-delete.js';
|
||||
|
||||
// ============================================================================
|
||||
// Services
|
||||
// ============================================================================
|
||||
|
||||
export * from './services/chatRecordingService.js';
|
||||
export * from './services/cronScheduler.js';
|
||||
export * from './services/fileDiscoveryService.js';
|
||||
export * from './services/fileSystemService.js';
|
||||
export * from './services/gitService.js';
|
||||
|
|
|
|||
286
packages/core/src/services/cronScheduler.test.ts
Normal file
286
packages/core/src/services/cronScheduler.test.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { CronScheduler, type CronJob } from './cronScheduler.js';
|
||||
|
||||
describe('CronScheduler', () => {
|
||||
let scheduler: CronScheduler;
|
||||
|
||||
beforeEach(() => {
|
||||
scheduler = new CronScheduler();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
scheduler.destroy();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates a job with valid fields', () => {
|
||||
const job = scheduler.create('*/5 * * * *', 'test prompt', true);
|
||||
expect(job.id).toHaveLength(8);
|
||||
expect(job.cronExpr).toBe('*/5 * * * *');
|
||||
expect(job.prompt).toBe('test prompt');
|
||||
expect(job.recurring).toBe(true);
|
||||
expect(job.createdAt).toBeGreaterThan(0);
|
||||
expect(job.expiresAt).toBeGreaterThan(job.createdAt);
|
||||
});
|
||||
|
||||
it('creates one-shot jobs with zero jitter', () => {
|
||||
const job = scheduler.create('*/1 * * * *', 'once', false);
|
||||
expect(job.jitterMs).toBe(0);
|
||||
});
|
||||
|
||||
it('enforces max 50 jobs', () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
scheduler.create('*/1 * * * *', `job-${i}`, true);
|
||||
}
|
||||
expect(() => scheduler.create('*/1 * * * *', 'job-51', true)).toThrow(
|
||||
'Maximum number of cron jobs (50) reached',
|
||||
);
|
||||
});
|
||||
|
||||
it('generates unique IDs', () => {
|
||||
const ids = new Set<string>();
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const job = scheduler.create('*/1 * * * *', `job-${i}`, true);
|
||||
ids.add(job.id);
|
||||
}
|
||||
expect(ids.size).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('removes an existing job', () => {
|
||||
const job = scheduler.create('*/1 * * * *', 'test', true);
|
||||
expect(scheduler.delete(job.id)).toBe(true);
|
||||
expect(scheduler.list()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns false for non-existent job', () => {
|
||||
expect(scheduler.delete('nonexistent')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('returns empty array when no jobs', () => {
|
||||
expect(scheduler.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns all jobs', () => {
|
||||
scheduler.create('*/1 * * * *', 'a', true);
|
||||
scheduler.create('*/2 * * * *', 'b', false);
|
||||
const jobs = scheduler.list();
|
||||
expect(jobs).toHaveLength(2);
|
||||
expect(jobs.map((j) => j.prompt).sort()).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('size', () => {
|
||||
it('tracks job count', () => {
|
||||
expect(scheduler.size).toBe(0);
|
||||
const job = scheduler.create('*/1 * * * *', 'a', true);
|
||||
expect(scheduler.size).toBe(1);
|
||||
scheduler.delete(job.id);
|
||||
expect(scheduler.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tick', () => {
|
||||
it('fires callback when a job matches', () => {
|
||||
const fired: CronJob[] = [];
|
||||
scheduler.start((job) => fired.push(job));
|
||||
|
||||
// Use every-minute cron so jitter is tiny (max ~6s for 1-min period)
|
||||
scheduler.create('*/1 * * * *', 'match', true);
|
||||
|
||||
// Tick at 10:30:59 — past any jitter for a 1-min period job
|
||||
const date = new Date(2025, 0, 15, 10, 30, 59);
|
||||
scheduler.tick(date);
|
||||
|
||||
expect(fired).toHaveLength(1);
|
||||
expect(fired[0]!.prompt).toBe('match');
|
||||
});
|
||||
|
||||
it('does not fire when no match', () => {
|
||||
const fired: CronJob[] = [];
|
||||
scheduler.start((job) => fired.push(job));
|
||||
|
||||
scheduler.create('30 10 * * *', 'no match', true);
|
||||
|
||||
// Tick at 10:31 — should not fire
|
||||
scheduler.tick(new Date(2025, 0, 15, 10, 31, 0));
|
||||
expect(fired).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not double-fire in same minute', () => {
|
||||
const fired: CronJob[] = [];
|
||||
scheduler.start((job) => fired.push(job));
|
||||
|
||||
scheduler.create('*/1 * * * *', 'once per minute', true);
|
||||
|
||||
// Both ticks in second 59 — past jitter for a 1-min period job
|
||||
const date1 = new Date(2025, 0, 15, 10, 30, 59);
|
||||
const date2 = new Date(2025, 0, 15, 10, 30, 59, 500);
|
||||
scheduler.tick(date1);
|
||||
scheduler.tick(date2);
|
||||
|
||||
expect(fired).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('removes one-shot jobs after firing', () => {
|
||||
const fired: CronJob[] = [];
|
||||
scheduler.start((job) => fired.push(job));
|
||||
|
||||
// One-shot: jitter is 0, so second 1 is fine
|
||||
scheduler.create('30 10 * * *', 'one-shot', false);
|
||||
|
||||
scheduler.tick(new Date(2025, 0, 15, 10, 30, 1));
|
||||
expect(fired).toHaveLength(1);
|
||||
expect(scheduler.list()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps recurring jobs after firing', () => {
|
||||
const fired: CronJob[] = [];
|
||||
scheduler.start((job) => fired.push(job));
|
||||
|
||||
scheduler.create('*/1 * * * *', 'recurring', true);
|
||||
|
||||
// Tick at second 59 — past any jitter for a 1-min period job
|
||||
scheduler.tick(new Date(2025, 0, 15, 10, 30, 59));
|
||||
expect(fired).toHaveLength(1);
|
||||
expect(scheduler.list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('removes expired jobs', () => {
|
||||
scheduler.start(() => {});
|
||||
|
||||
const job = scheduler.create('*/1 * * * *', 'expire me', true);
|
||||
// Tick far in the future (past expiry)
|
||||
const farFuture = new Date(job.expiresAt + 1000);
|
||||
scheduler.tick(farFuture);
|
||||
|
||||
expect(scheduler.list()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('fires in next minute after first fire', () => {
|
||||
const fired: CronJob[] = [];
|
||||
scheduler.start((job) => fired.push(job));
|
||||
|
||||
// Every minute
|
||||
scheduler.create('* * * * *', 'every minute', true);
|
||||
|
||||
scheduler.tick(new Date(2025, 0, 15, 10, 30, 59));
|
||||
expect(fired).toHaveLength(1);
|
||||
|
||||
// Next minute
|
||||
scheduler.tick(new Date(2025, 0, 15, 10, 31, 59));
|
||||
expect(fired).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('fires recurring jobs after the matching minute when positive jitter delays them', () => {
|
||||
const fired: CronJob[] = [];
|
||||
scheduler.start((job) => fired.push(job));
|
||||
|
||||
const job = scheduler.create('0 * * * *', 'hourly delayed', true);
|
||||
job.jitterMs = 6 * 60 * 1000;
|
||||
|
||||
scheduler.tick(new Date(2025, 0, 15, 10, 5, 59));
|
||||
expect(fired).toHaveLength(0);
|
||||
|
||||
scheduler.tick(new Date(2025, 0, 15, 10, 6, 0));
|
||||
expect(fired).toHaveLength(1);
|
||||
expect(fired[0]!.prompt).toBe('hourly delayed');
|
||||
});
|
||||
|
||||
it('fires one-shot jobs before the matching minute when negative jitter advances them', () => {
|
||||
const fired: CronJob[] = [];
|
||||
scheduler.start((job) => fired.push(job));
|
||||
|
||||
const job = scheduler.create('30 10 * * *', 'oneshot early', false);
|
||||
job.jitterMs = -30 * 1000;
|
||||
|
||||
scheduler.tick(new Date(2025, 0, 15, 10, 29, 29));
|
||||
expect(fired).toHaveLength(0);
|
||||
|
||||
scheduler.tick(new Date(2025, 0, 15, 10, 29, 30));
|
||||
expect(fired).toHaveLength(1);
|
||||
expect(fired[0]!.prompt).toBe('oneshot early');
|
||||
});
|
||||
});
|
||||
|
||||
describe('start/stop', () => {
|
||||
it('starts and stops without error', () => {
|
||||
scheduler.start(() => {});
|
||||
expect(scheduler.running).toBe(true);
|
||||
scheduler.stop();
|
||||
expect(scheduler.running).toBe(false);
|
||||
});
|
||||
|
||||
it('does not fire after stop', () => {
|
||||
const fired: CronJob[] = [];
|
||||
scheduler.start((job) => fired.push(job));
|
||||
scheduler.stop();
|
||||
|
||||
scheduler.create('30 10 * * *', 'no fire', true);
|
||||
scheduler.tick(new Date(2025, 0, 15, 10, 30, 1));
|
||||
|
||||
// tick still works manually, but onFire is cleared
|
||||
expect(fired).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('start is idempotent', () => {
|
||||
scheduler.start(() => {});
|
||||
scheduler.start(() => {}); // should not throw or create duplicate timers
|
||||
expect(scheduler.running).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExitSummary', () => {
|
||||
it('returns null when no jobs', () => {
|
||||
expect(scheduler.getExitSummary()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns summary with single job', () => {
|
||||
scheduler.create('*/5 * * * *', 'check the build', true);
|
||||
const summary = scheduler.getExitSummary()!;
|
||||
expect(summary).toContain('1 active loop cancelled:');
|
||||
expect(summary).toContain('Every 5 minutes');
|
||||
expect(summary).toContain('check the build');
|
||||
});
|
||||
|
||||
it('returns summary with multiple jobs', () => {
|
||||
scheduler.create('*/5 * * * *', 'check the build', true);
|
||||
scheduler.create('*/30 * * * *', 'check PR reviews', true);
|
||||
const summary = scheduler.getExitSummary()!;
|
||||
expect(summary).toContain('2 active loops cancelled:');
|
||||
expect(summary).toContain('check the build');
|
||||
expect(summary).toContain('check PR reviews');
|
||||
});
|
||||
|
||||
it('truncates long prompts', () => {
|
||||
const longPrompt = 'a'.repeat(100);
|
||||
scheduler.create('*/1 * * * *', longPrompt, true);
|
||||
const summary = scheduler.getExitSummary()!;
|
||||
expect(summary).toContain('...');
|
||||
// Should not contain the full 100-char prompt
|
||||
expect(summary).not.toContain(longPrompt);
|
||||
});
|
||||
|
||||
it('returns null after all jobs are deleted', () => {
|
||||
const job = scheduler.create('*/1 * * * *', 'temp', true);
|
||||
scheduler.delete(job.id);
|
||||
expect(scheduler.getExitSummary()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy', () => {
|
||||
it('stops and clears all jobs', () => {
|
||||
scheduler.create('*/1 * * * *', 'a', true);
|
||||
scheduler.create('*/2 * * * *', 'b', true);
|
||||
scheduler.start(() => {});
|
||||
|
||||
scheduler.destroy();
|
||||
|
||||
expect(scheduler.running).toBe(false);
|
||||
expect(scheduler.list()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
282
packages/core/src/services/cronScheduler.ts
Normal file
282
packages/core/src/services/cronScheduler.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
/**
|
||||
* In-session cron scheduler. Jobs live in memory and are gone when the
|
||||
* process exits. Ticks every second, fires callbacks when jobs are due.
|
||||
*/
|
||||
|
||||
import { matches, nextFireTime } from '../utils/cronParser.js';
|
||||
import { humanReadableCron } from '../utils/cronDisplay.js';
|
||||
|
||||
const MAX_JOBS = 50;
|
||||
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
|
||||
// Recurring: up to 10% of period, capped at 15 minutes.
|
||||
const MAX_RECURRING_JITTER_MS = 15 * 60 * 1000;
|
||||
// One-shot: up to 90s early for jobs landing on :00 or :30.
|
||||
const MAX_ONESHOT_JITTER_MS = 90 * 1000;
|
||||
|
||||
export interface CronJob {
|
||||
id: string;
|
||||
cronExpr: string;
|
||||
prompt: string;
|
||||
recurring: boolean;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
lastFiredAt?: number;
|
||||
jitterMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic hash from a string ID, returned as a positive integer.
|
||||
*/
|
||||
function hashId(id: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
hash = (hash * 31 + id.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a deterministic jitter offset from a job ID and its cron period.
|
||||
* Recurring jobs: up to 10% of period, capped at 15 minutes (added after fire time).
|
||||
* One-shot jobs landing on :00 or :30: up to 90s early (subtracted before fire time).
|
||||
* Other one-shot jobs: 0 jitter.
|
||||
*/
|
||||
function computeJitter(
|
||||
id: string,
|
||||
cronExpr: string,
|
||||
recurring: boolean,
|
||||
): number {
|
||||
const hash = hashId(id);
|
||||
|
||||
if (recurring) {
|
||||
// Estimate period by computing two consecutive fire times
|
||||
const now = new Date();
|
||||
try {
|
||||
const first = nextFireTime(cronExpr, now);
|
||||
const second = nextFireTime(cronExpr, first);
|
||||
const periodMs = second.getTime() - first.getTime();
|
||||
const tenPercent = periodMs * 0.1;
|
||||
const maxJitter = Math.min(tenPercent, MAX_RECURRING_JITTER_MS);
|
||||
return hash % Math.max(1, Math.floor(maxJitter));
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// One-shot: apply up to 90s early jitter only when minute is :00 or :30
|
||||
try {
|
||||
const fields = cronExpr.trim().split(/\s+/);
|
||||
const minuteField = fields[0] ?? '';
|
||||
const minuteVal = parseInt(minuteField, 10);
|
||||
if (!isNaN(minuteVal) && (minuteVal === 0 || minuteVal === 30)) {
|
||||
// Negative jitter = fire early
|
||||
return -(hash % MAX_ONESHOT_JITTER_MS);
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let id = '';
|
||||
for (let i = 0; i < 8; i++) {
|
||||
id += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export class CronScheduler {
|
||||
private jobs = new Map<string, CronJob>();
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private onFire: ((job: CronJob) => void) | null = null;
|
||||
|
||||
/**
|
||||
* Creates a new cron job. Returns the created job.
|
||||
* Throws if the max job limit is reached.
|
||||
*/
|
||||
create(cronExpr: string, prompt: string, recurring: boolean): CronJob {
|
||||
if (this.jobs.size >= MAX_JOBS) {
|
||||
throw new Error(
|
||||
`Maximum number of cron jobs (${MAX_JOBS}) reached. Delete some jobs first.`,
|
||||
);
|
||||
}
|
||||
|
||||
const id = generateId();
|
||||
const now = Date.now();
|
||||
const jitterMs = computeJitter(id, cronExpr, recurring);
|
||||
|
||||
const job: CronJob = {
|
||||
id,
|
||||
cronExpr,
|
||||
prompt,
|
||||
recurring,
|
||||
createdAt: now,
|
||||
expiresAt: recurring ? now + THREE_DAYS_MS : Infinity,
|
||||
jitterMs,
|
||||
};
|
||||
|
||||
this.jobs.set(id, job);
|
||||
return job;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a job by ID. Returns true if the job existed.
|
||||
*/
|
||||
delete(id: string): boolean {
|
||||
return this.jobs.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all active jobs.
|
||||
*/
|
||||
list(): CronJob[] {
|
||||
return [...this.jobs.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of active jobs.
|
||||
*/
|
||||
get size(): number {
|
||||
return this.jobs.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the scheduler tick. Calls `onFire` when a job is due.
|
||||
* Only fires when called — does not auto-fire missed intervals.
|
||||
*/
|
||||
start(onFire: (job: CronJob) => void): void {
|
||||
this.onFire = onFire;
|
||||
if (this.timer) return; // already running
|
||||
|
||||
this.timer = setInterval(() => {
|
||||
this.tick();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the scheduler. Does not clear jobs — they remain queryable.
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.onFire = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the scheduler is running.
|
||||
*/
|
||||
get running(): boolean {
|
||||
return this.timer !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual tick — checks all jobs against the current time and fires those
|
||||
* that are due. Exported for testing.
|
||||
*/
|
||||
tick(now?: Date): void {
|
||||
const currentDate = now ?? new Date();
|
||||
const currentMs = currentDate.getTime();
|
||||
|
||||
for (const job of this.jobs.values()) {
|
||||
// Check expiry
|
||||
if (currentMs >= job.expiresAt) {
|
||||
this.jobs.delete(job.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the cron minute whose jittered fire time we might be in.
|
||||
// For positive jitter (recurring) the fire time is *after* the matching
|
||||
// minute, so we look backwards. For negative jitter (one-shot :00/:30)
|
||||
// the fire time is *before* the matching minute, so we look at the
|
||||
// current minute.
|
||||
//
|
||||
// We scan a window of minutes around `now` equal to the absolute jitter
|
||||
// so that a +6 min jitter on an hourly job still finds the :00 match.
|
||||
const absJitter = Math.abs(job.jitterMs);
|
||||
const windowMinutes = Math.ceil(absJitter / 60_000);
|
||||
|
||||
// Build the candidate minute-start at the beginning of the current minute
|
||||
const nowMinuteStart = new Date(currentDate);
|
||||
nowMinuteStart.setSeconds(0, 0);
|
||||
const nowMinuteMs = nowMinuteStart.getTime();
|
||||
|
||||
let matchedMinuteMs: number | null = null;
|
||||
|
||||
// Scan from (now - windowMinutes) to (now) for positive jitter,
|
||||
// or (now) to (now + windowMinutes) for negative jitter.
|
||||
// In practice we scan both directions to keep the code simple.
|
||||
for (let offset = -windowMinutes; offset <= windowMinutes; offset++) {
|
||||
const candidateMs = nowMinuteMs + offset * 60_000;
|
||||
const candidateDate = new Date(candidateMs);
|
||||
if (!matches(job.cronExpr, candidateDate)) continue;
|
||||
|
||||
const fireTimeMs = candidateMs + job.jitterMs;
|
||||
if (currentMs >= fireTimeMs) {
|
||||
// This candidate's jittered fire time has passed — it's a match.
|
||||
// Pick the latest matching minute to avoid re-triggering old ones.
|
||||
if (matchedMinuteMs === null || candidateMs > matchedMinuteMs) {
|
||||
matchedMinuteMs = candidateMs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedMinuteMs === null) {
|
||||
continue; // No matching minute whose jittered time has arrived
|
||||
}
|
||||
|
||||
// Prevent double-firing: compare against the cron minute we last fired for
|
||||
if (
|
||||
job.lastFiredAt !== undefined &&
|
||||
job.lastFiredAt === matchedMinuteMs
|
||||
) {
|
||||
continue; // Already fired for this cron minute
|
||||
}
|
||||
|
||||
// Fire! Record the matched cron minute (not wall-clock time) so the
|
||||
// double-fire guard works when jitter pushes the fire into a later minute.
|
||||
job.lastFiredAt = matchedMinuteMs;
|
||||
|
||||
if (!job.recurring) {
|
||||
this.jobs.delete(job.id);
|
||||
}
|
||||
|
||||
if (this.onFire) {
|
||||
this.onFire(job);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a human-readable summary of active jobs for display on session
|
||||
* exit. Returns null if there are no active jobs.
|
||||
*/
|
||||
getExitSummary(): string | null {
|
||||
if (this.jobs.size === 0) return null;
|
||||
|
||||
const count = this.jobs.size;
|
||||
const lines = [
|
||||
`Session ending. ${count} active loop${count === 1 ? '' : 's'} cancelled:`,
|
||||
];
|
||||
for (const job of this.jobs.values()) {
|
||||
const schedule = humanReadableCron(job.cronExpr);
|
||||
// Truncate long prompts
|
||||
const prompt =
|
||||
job.prompt.length > 60 ? job.prompt.slice(0, 57) + '...' : job.prompt;
|
||||
lines.push(` - [${job.id}] ${schedule}: ${prompt}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all jobs and stops the scheduler.
|
||||
*/
|
||||
destroy(): void {
|
||||
this.stop();
|
||||
this.jobs.clear();
|
||||
}
|
||||
}
|
||||
61
packages/core/src/skills/bundled/loop/SKILL.md
Normal file
61
packages/core/src/skills/bundled/loop/SKILL.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
---
|
||||
name: loop
|
||||
description: Create a recurring loop that runs a prompt on a schedule. Usage - /loop 5m check the build, /loop check the PR every 30m, /loop run tests (defaults to 10m). /loop list to show jobs, /loop clear to cancel all.
|
||||
allowedTools:
|
||||
- cron_create
|
||||
- cron_list
|
||||
- cron_delete
|
||||
---
|
||||
|
||||
# /loop — schedule a recurring prompt
|
||||
|
||||
## Subcommands
|
||||
|
||||
If the input (after stripping the `/loop` prefix) is exactly one of these keywords, run the subcommand instead of scheduling:
|
||||
|
||||
- **`list`** — call CronList and display the results. Done.
|
||||
- **`clear`** — call CronList, then call CronDelete for every job returned. Confirm how many were cancelled. Done.
|
||||
|
||||
Otherwise, parse the input below into `[interval] <prompt…>` and schedule it with CronCreate.
|
||||
|
||||
## Parsing (in priority order)
|
||||
|
||||
1. **Leading token**: if the first whitespace-delimited token matches `^\d+[smhd]$` (e.g. `5m`, `2h`), that's the interval; the rest is the prompt.
|
||||
2. **Trailing "every" clause**: otherwise, if the input ends with `every <N><unit>` or `every <N> <unit-word>` (e.g. `every 20m`, `every 5 minutes`, `every 2 hours`), extract that as the interval and strip it from the prompt. Only match when what follows "every" is a time expression — `check every PR` has no interval.
|
||||
3. **Default**: otherwise, interval is `10m` and the entire input is the prompt.
|
||||
|
||||
If the resulting prompt is empty, show usage `/loop [interval] <prompt>` and stop — do not call CronCreate.
|
||||
|
||||
Examples:
|
||||
|
||||
- `5m /babysit-prs` → interval `5m`, prompt `/babysit-prs` (rule 1)
|
||||
- `check the deploy every 20m` → interval `20m`, prompt `check the deploy` (rule 2)
|
||||
- `run tests every 5 minutes` → interval `5m`, prompt `run tests` (rule 2)
|
||||
- `check the deploy` → interval `10m`, prompt `check the deploy` (rule 3)
|
||||
- `check every PR` → interval `10m`, prompt `check every PR` (rule 3 — "every" not followed by time)
|
||||
- `5m` → empty prompt → show usage
|
||||
|
||||
## Interval → cron
|
||||
|
||||
Supported suffixes: `s` (seconds, rounded up to nearest minute, min 1), `m` (minutes), `h` (hours), `d` (days). Convert:
|
||||
|
||||
| Interval pattern | Cron expression | Notes |
|
||||
| ----------------- | ---------------------- | ----------------------------------------- |
|
||||
| `Nm` where N ≤ 59 | `*/N * * * *` | every N minutes |
|
||||
| `Nm` where N ≥ 60 | `0 */H * * *` | round to hours (H = N/60, must divide 24) |
|
||||
| `Nh` where N ≤ 23 | `0 */N * * *` | every N hours |
|
||||
| `Nd` | `0 0 */N * *` | every N days at midnight local |
|
||||
| `Ns` | treat as `ceil(N/60)m` | cron minimum granularity is 1 minute |
|
||||
|
||||
**If the interval doesn't cleanly divide its unit** (e.g. `7m` → `*/7 * * * *` gives uneven gaps at :56→:00; `90m` → 1.5h which cron can't express), pick the nearest clean interval and tell the user what you rounded to before scheduling.
|
||||
|
||||
## Action
|
||||
|
||||
1. Call CronCreate with:
|
||||
- `cron`: the expression from the table above
|
||||
- `prompt`: the parsed prompt from above, verbatim (slash commands are passed through unchanged)
|
||||
- `recurring`: `true`
|
||||
2. Briefly confirm: what's scheduled, the cron expression, the human-readable cadence, that recurring tasks auto-expire after 3 days, and that they can cancel sooner with CronDelete (include the job ID).
|
||||
3. **Then immediately execute the parsed prompt now** — don't wait for the first cron fire. If it's a slash command, invoke it via the Skill tool; otherwise act on it directly.
|
||||
|
||||
## Input
|
||||
68
packages/core/src/tools/cron-create.test.ts
Normal file
68
packages/core/src/tools/cron-create.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { CronCreateTool } from './cron-create.js';
|
||||
import { CronScheduler } from '../services/cronScheduler.js';
|
||||
|
||||
function makeConfig() {
|
||||
const scheduler = new CronScheduler();
|
||||
return {
|
||||
getCronScheduler: () => scheduler,
|
||||
_scheduler: scheduler,
|
||||
} as unknown as import('../config/config.js').Config & {
|
||||
_scheduler: CronScheduler;
|
||||
};
|
||||
}
|
||||
|
||||
describe('CronCreateTool', () => {
|
||||
let config: ReturnType<typeof makeConfig>;
|
||||
let tool: CronCreateTool;
|
||||
|
||||
beforeEach(() => {
|
||||
config = makeConfig();
|
||||
tool = new CronCreateTool(config);
|
||||
});
|
||||
|
||||
it('has the correct name', () => {
|
||||
expect(tool.name).toBe('cron_create');
|
||||
});
|
||||
|
||||
it('creates a recurring job by default', async () => {
|
||||
const invocation = tool.build({
|
||||
cron: '*/5 * * * *',
|
||||
prompt: 'check status',
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.llmContent).toContain('Scheduled recurring job');
|
||||
expect(result.llmContent).toContain('Auto-expires after 3 days');
|
||||
expect(config._scheduler.list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('creates a one-shot job when recurring=false', async () => {
|
||||
const invocation = tool.build({
|
||||
cron: '*/1 * * * *',
|
||||
prompt: 'once',
|
||||
recurring: false,
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.llmContent).toContain('Scheduled one-shot task');
|
||||
expect(result.llmContent).toContain('fire once then auto-delete');
|
||||
const jobs = config._scheduler.list();
|
||||
expect(jobs).toHaveLength(1);
|
||||
expect(jobs[0]!.recurring).toBe(false);
|
||||
});
|
||||
|
||||
it('returns error for invalid cron expression', async () => {
|
||||
const invocation = tool.build({
|
||||
cron: 'bad cron',
|
||||
prompt: 'fail',
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('validates required params', () => {
|
||||
expect(() => tool.build({ cron: '*/1 * * * *' } as never)).toThrow();
|
||||
expect(() => tool.build({ prompt: 'test' } as never)).toThrow();
|
||||
});
|
||||
});
|
||||
137
packages/core/src/tools/cron-create.ts
Normal file
137
packages/core/src/tools/cron-create.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
/**
|
||||
* cron_create tool — creates a new in-session cron job.
|
||||
*/
|
||||
|
||||
import type { ToolInvocation, ToolResult } from './tools.js';
|
||||
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
|
||||
import { ToolNames, ToolDisplayNames } from './tool-names.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { parseCron } from '../utils/cronParser.js';
|
||||
import { humanReadableCron } from '../utils/cronDisplay.js';
|
||||
|
||||
export interface CronCreateParams {
|
||||
cron: string;
|
||||
prompt: string;
|
||||
recurring?: boolean;
|
||||
}
|
||||
|
||||
class CronCreateInvocation extends BaseToolInvocation<
|
||||
CronCreateParams,
|
||||
ToolResult
|
||||
> {
|
||||
constructor(
|
||||
private config: Config,
|
||||
params: CronCreateParams,
|
||||
) {
|
||||
super(params);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `${this.params.cron}: ${this.params.prompt}`;
|
||||
}
|
||||
|
||||
async execute(): Promise<ToolResult> {
|
||||
const scheduler = this.config.getCronScheduler();
|
||||
const recurring = this.params.recurring !== false;
|
||||
|
||||
try {
|
||||
// Validate cron expression before creating the job
|
||||
parseCron(this.params.cron);
|
||||
|
||||
const job = scheduler.create(
|
||||
this.params.cron,
|
||||
this.params.prompt,
|
||||
recurring,
|
||||
);
|
||||
|
||||
const display = humanReadableCron(job.cronExpr);
|
||||
const returnDisplay = `Scheduled ${job.id} (${display})`;
|
||||
|
||||
let llmContent: string;
|
||||
if (recurring) {
|
||||
llmContent =
|
||||
`Scheduled recurring job ${job.id} (${job.cronExpr}). ` +
|
||||
'Session-only (not written to disk, dies when Qwen Code exits). ' +
|
||||
'Auto-expires after 3 days. Use CronDelete to cancel sooner.';
|
||||
} else {
|
||||
llmContent =
|
||||
`Scheduled one-shot task ${job.id} (${job.cronExpr}). ` +
|
||||
'Session-only (not written to disk, dies when Qwen Code exits). ' +
|
||||
'It will fire once then auto-delete.';
|
||||
}
|
||||
|
||||
return { llmContent, returnDisplay };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
llmContent: `Error creating cron job: ${message}`,
|
||||
returnDisplay: message,
|
||||
error: { message },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class CronCreateTool extends BaseDeclarativeTool<
|
||||
CronCreateParams,
|
||||
ToolResult
|
||||
> {
|
||||
static readonly Name = ToolNames.CRON_CREATE;
|
||||
|
||||
constructor(private config: Config) {
|
||||
super(
|
||||
CronCreateTool.Name,
|
||||
ToolDisplayNames.CRON_CREATE,
|
||||
'Schedule a prompt to be enqueued at a future time. Use for both recurring schedules and one-shot reminders.\n\n' +
|
||||
'Uses standard 5-field cron in the user\'s local timezone: minute hour day-of-month month day-of-week. "0 9 * * *" means 9am local — no timezone conversion needed.\n\n' +
|
||||
'## One-shot tasks (recurring: false)\n\n' +
|
||||
'For "remind me at X" or "at <time>, do Y" requests — fire once then auto-delete.\n' +
|
||||
'Pin minute/hour/day-of-month/month to specific values:\n' +
|
||||
' "remind me at 2:30pm today to check the deploy" → cron: "30 14 <today_dom> <today_month> *", recurring: false\n' +
|
||||
' "tomorrow morning, run the smoke test" → cron: "57 8 <tomorrow_dom> <tomorrow_month> *", recurring: false\n\n' +
|
||||
'## Recurring jobs (recurring: true, the default)\n\n' +
|
||||
'For "every N minutes" / "every hour" / "weekdays at 9am" requests:\n' +
|
||||
' "*/5 * * * *" (every 5 min), "0 * * * *" (hourly), "0 9 * * 1-5" (weekdays at 9am local)\n\n' +
|
||||
'## Avoid the :00 and :30 minute marks when the task allows it\n\n' +
|
||||
'Every user who asks for "9am" gets `0 9`, and every user who asks for "hourly" gets `0 *` — which means requests from across the planet land on the API at the same instant. When the user\'s request is approximate, pick a minute that is NOT 0 or 30:\n' +
|
||||
' "every morning around 9" → "57 8 * * *" or "3 9 * * *" (not "0 9 * * *")\n' +
|
||||
' "hourly" → "7 * * * *" (not "0 * * * *")\n' +
|
||||
' "in an hour or so, remind me to..." → pick whatever minute you land on, don\'t round\n\n' +
|
||||
'Only use minute 0 or 30 when the user names that exact time and clearly means it ("at 9:00 sharp", "at half past", coordinating with a meeting). When in doubt, nudge a few minutes early or late — the user will not notice, and the fleet will.\n\n' +
|
||||
'## Session-only\n\n' +
|
||||
'Jobs live only in this Qwen Code session — nothing is written to disk, and the job is gone when Qwen Code exits.\n\n' +
|
||||
'## Runtime behavior\n\n' +
|
||||
'Jobs only fire while the REPL is idle (not mid-query). The scheduler adds a small deterministic jitter on top of whatever you pick: recurring tasks fire up to 10% of their period late (max 15 min); one-shot tasks landing on :00 or :30 fire up to 90 s early. Picking an off-minute is still the bigger lever.\n\n' +
|
||||
'Recurring tasks auto-expire after 3 days — they fire one final time, then are deleted. This bounds session lifetime. Tell the user about the 3-day limit when scheduling recurring jobs.\n\n' +
|
||||
'Returns a job ID you can pass to CronDelete.',
|
||||
Kind.Other,
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
cron: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Standard 5-field cron expression in local time: "M H DoM Mon DoW" (e.g. "*/5 * * * *" = every 5 minutes, "30 14 28 2 *" = Feb 28 at 2:30pm local once).',
|
||||
},
|
||||
prompt: {
|
||||
type: 'string',
|
||||
description: 'The prompt to enqueue at each fire time.',
|
||||
},
|
||||
recurring: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'true (default) = fire on every cron match until deleted or auto-expired after 3 days. false = fire once at the next match, then auto-delete. Use false for "remind me at X" one-shot requests with pinned minute/hour/dom/month.',
|
||||
},
|
||||
},
|
||||
required: ['cron', 'prompt'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
protected createInvocation(
|
||||
params: CronCreateParams,
|
||||
): ToolInvocation<CronCreateParams, ToolResult> {
|
||||
return new CronCreateInvocation(this.config, params);
|
||||
}
|
||||
}
|
||||
48
packages/core/src/tools/cron-delete.test.ts
Normal file
48
packages/core/src/tools/cron-delete.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { CronDeleteTool } from './cron-delete.js';
|
||||
import { CronScheduler } from '../services/cronScheduler.js';
|
||||
|
||||
function makeConfig() {
|
||||
const scheduler = new CronScheduler();
|
||||
return {
|
||||
getCronScheduler: () => scheduler,
|
||||
_scheduler: scheduler,
|
||||
} as unknown as import('../config/config.js').Config & {
|
||||
_scheduler: CronScheduler;
|
||||
};
|
||||
}
|
||||
|
||||
describe('CronDeleteTool', () => {
|
||||
let config: ReturnType<typeof makeConfig>;
|
||||
let tool: CronDeleteTool;
|
||||
|
||||
beforeEach(() => {
|
||||
config = makeConfig();
|
||||
tool = new CronDeleteTool(config);
|
||||
});
|
||||
|
||||
it('has the correct name', () => {
|
||||
expect(tool.name).toBe('cron_delete');
|
||||
});
|
||||
|
||||
it('deletes an existing job', async () => {
|
||||
const job = config._scheduler.create('*/1 * * * *', 'test', true);
|
||||
|
||||
const invocation = tool.build({ id: job.id });
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.llmContent).toContain('Cancelled job');
|
||||
expect(config._scheduler.list()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns error for non-existent job', async () => {
|
||||
const invocation = tool.build({ id: 'nonexist' });
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.llmContent).toContain('not found');
|
||||
});
|
||||
|
||||
it('validates required params', () => {
|
||||
expect(() => tool.build({} as never)).toThrow();
|
||||
});
|
||||
});
|
||||
79
packages/core/src/tools/cron-delete.ts
Normal file
79
packages/core/src/tools/cron-delete.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* cron_delete tool — deletes an in-session cron job by ID.
|
||||
*/
|
||||
|
||||
import type { ToolInvocation, ToolResult } from './tools.js';
|
||||
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
|
||||
import { ToolNames, ToolDisplayNames } from './tool-names.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
|
||||
export interface CronDeleteParams {
|
||||
id: string;
|
||||
}
|
||||
|
||||
class CronDeleteInvocation extends BaseToolInvocation<
|
||||
CronDeleteParams,
|
||||
ToolResult
|
||||
> {
|
||||
constructor(
|
||||
private config: Config,
|
||||
params: CronDeleteParams,
|
||||
) {
|
||||
super(params);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return this.params.id;
|
||||
}
|
||||
|
||||
async execute(): Promise<ToolResult> {
|
||||
const scheduler = this.config.getCronScheduler();
|
||||
const deleted = scheduler.delete(this.params.id);
|
||||
|
||||
if (deleted) {
|
||||
const llmContent = `Cancelled job ${this.params.id}.`;
|
||||
const returnDisplay = `Cancelled ${this.params.id}`;
|
||||
return { llmContent, returnDisplay };
|
||||
} else {
|
||||
const result = `Job ${this.params.id} not found.`;
|
||||
return {
|
||||
llmContent: result,
|
||||
returnDisplay: result,
|
||||
error: { message: result },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class CronDeleteTool extends BaseDeclarativeTool<
|
||||
CronDeleteParams,
|
||||
ToolResult
|
||||
> {
|
||||
static readonly Name = ToolNames.CRON_DELETE;
|
||||
|
||||
constructor(private config: Config) {
|
||||
super(
|
||||
CronDeleteTool.Name,
|
||||
ToolDisplayNames.CRON_DELETE,
|
||||
'Cancel a cron job previously scheduled with CronCreate. Removes it from the in-memory session store.',
|
||||
Kind.Other,
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Job ID returned by CronCreate.',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
protected createInvocation(
|
||||
params: CronDeleteParams,
|
||||
): ToolInvocation<CronDeleteParams, ToolResult> {
|
||||
return new CronDeleteInvocation(this.config, params);
|
||||
}
|
||||
}
|
||||
49
packages/core/src/tools/cron-list.test.ts
Normal file
49
packages/core/src/tools/cron-list.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { CronListTool } from './cron-list.js';
|
||||
import { CronScheduler } from '../services/cronScheduler.js';
|
||||
|
||||
function makeConfig() {
|
||||
const scheduler = new CronScheduler();
|
||||
return {
|
||||
getCronScheduler: () => scheduler,
|
||||
_scheduler: scheduler,
|
||||
} as unknown as import('../config/config.js').Config & {
|
||||
_scheduler: CronScheduler;
|
||||
};
|
||||
}
|
||||
|
||||
describe('CronListTool', () => {
|
||||
let config: ReturnType<typeof makeConfig>;
|
||||
let tool: CronListTool;
|
||||
|
||||
beforeEach(() => {
|
||||
config = makeConfig();
|
||||
tool = new CronListTool(config);
|
||||
});
|
||||
|
||||
it('has the correct name', () => {
|
||||
expect(tool.name).toBe('cron_list');
|
||||
});
|
||||
|
||||
it('returns empty message when no jobs', async () => {
|
||||
const invocation = tool.build({});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.llmContent).toContain('No active cron jobs');
|
||||
});
|
||||
|
||||
it('lists created jobs', async () => {
|
||||
config._scheduler.create('*/5 * * * *', 'check build', true);
|
||||
config._scheduler.create('*/1 * * * *', 'ping', false);
|
||||
|
||||
const invocation = tool.build({});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.llmContent).toContain(
|
||||
'(recurring) [session-only]: check build',
|
||||
);
|
||||
expect(result.llmContent).toContain('(one-shot) [session-only]: ping');
|
||||
// Two lines, one per job
|
||||
expect(String(result.llmContent).split('\n')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
75
packages/core/src/tools/cron-list.ts
Normal file
75
packages/core/src/tools/cron-list.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* cron_list tool — lists all active in-session cron jobs.
|
||||
*/
|
||||
|
||||
import type { ToolInvocation, ToolResult } from './tools.js';
|
||||
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
|
||||
import { ToolNames, ToolDisplayNames } from './tool-names.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { humanReadableCron } from '../utils/cronDisplay.js';
|
||||
|
||||
export type CronListParams = Record<string, never>;
|
||||
|
||||
class CronListInvocation extends BaseToolInvocation<
|
||||
CronListParams,
|
||||
ToolResult
|
||||
> {
|
||||
constructor(
|
||||
private config: Config,
|
||||
params: CronListParams,
|
||||
) {
|
||||
super(params);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
async execute(): Promise<ToolResult> {
|
||||
const scheduler = this.config.getCronScheduler();
|
||||
const jobs = scheduler.list();
|
||||
|
||||
if (jobs.length === 0) {
|
||||
const result = 'No active cron jobs.';
|
||||
return { llmContent: result, returnDisplay: result };
|
||||
}
|
||||
|
||||
const llmLines = jobs.map((job) => {
|
||||
const type = job.recurring ? 'recurring' : 'one-shot';
|
||||
return `${job.id} — ${job.cronExpr} (${type}) [session-only]: ${job.prompt}`;
|
||||
});
|
||||
const llmContent = llmLines.join('\n');
|
||||
|
||||
const displayLines = jobs.map((job) => `${job.id} ${humanReadableCron(job.cronExpr)}`);
|
||||
const returnDisplay = displayLines.join('\n');
|
||||
|
||||
return { llmContent, returnDisplay };
|
||||
}
|
||||
}
|
||||
|
||||
export class CronListTool extends BaseDeclarativeTool<
|
||||
CronListParams,
|
||||
ToolResult
|
||||
> {
|
||||
static readonly Name = ToolNames.CRON_LIST;
|
||||
|
||||
constructor(private config: Config) {
|
||||
super(
|
||||
CronListTool.Name,
|
||||
ToolDisplayNames.CRON_LIST,
|
||||
'List all cron jobs scheduled via CronCreate in this session.',
|
||||
Kind.Other,
|
||||
{
|
||||
type: 'object',
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
protected createInvocation(
|
||||
params: CronListParams,
|
||||
): ToolInvocation<CronListParams, ToolResult> {
|
||||
return new CronListInvocation(this.config, params);
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,9 @@ export const ToolNames = {
|
|||
LS: 'list_directory',
|
||||
LSP: 'lsp',
|
||||
ASK_USER_QUESTION: 'ask_user_question',
|
||||
CRON_CREATE: 'cron_create',
|
||||
CRON_LIST: 'cron_list',
|
||||
CRON_DELETE: 'cron_delete',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
|
@ -50,6 +53,9 @@ export const ToolDisplayNames = {
|
|||
LS: 'ListFiles',
|
||||
LSP: 'Lsp',
|
||||
ASK_USER_QUESTION: 'AskUserQuestion',
|
||||
CRON_CREATE: 'CronCreate',
|
||||
CRON_LIST: 'CronList',
|
||||
CRON_DELETE: 'CronDelete',
|
||||
} as const;
|
||||
|
||||
// Migration from old tool names to new tool names
|
||||
|
|
|
|||
54
packages/core/src/utils/cronDisplay.ts
Normal file
54
packages/core/src/utils/cronDisplay.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* Human-readable cron display for common recurring patterns.
|
||||
* Falls back to the raw expression for anything non-trivial.
|
||||
*/
|
||||
export function humanReadableCron(cronExpr: string): string {
|
||||
const parts = cronExpr.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return cronExpr;
|
||||
|
||||
const [min, hour, dom, mon, dow] = parts;
|
||||
|
||||
// */N * * * * → Every N minutes
|
||||
if (
|
||||
min!.startsWith('*/') &&
|
||||
hour === '*' &&
|
||||
dom === '*' &&
|
||||
mon === '*' &&
|
||||
dow === '*'
|
||||
) {
|
||||
const n = parseInt(min!.slice(2), 10);
|
||||
if (!isNaN(n)) {
|
||||
return n === 1 ? 'Every minute' : `Every ${n} minutes`;
|
||||
}
|
||||
}
|
||||
|
||||
// 0 */N * * * → Every N hours (or single minute with */N hours)
|
||||
if (
|
||||
/^\d+$/.test(min!) &&
|
||||
hour!.startsWith('*/') &&
|
||||
dom === '*' &&
|
||||
mon === '*' &&
|
||||
dow === '*'
|
||||
) {
|
||||
const n = parseInt(hour!.slice(2), 10);
|
||||
if (!isNaN(n)) {
|
||||
return n === 1 ? 'Every hour' : `Every ${n} hours`;
|
||||
}
|
||||
}
|
||||
|
||||
// M H */N * * → Every N days
|
||||
if (
|
||||
/^\d+$/.test(min!) &&
|
||||
/^\d+$/.test(hour!) &&
|
||||
dom!.startsWith('*/') &&
|
||||
mon === '*' &&
|
||||
dow === '*'
|
||||
) {
|
||||
const n = parseInt(dom!.slice(2), 10);
|
||||
if (!isNaN(n)) {
|
||||
return n === 1 ? 'Every day' : `Every ${n} days`;
|
||||
}
|
||||
}
|
||||
|
||||
return cronExpr;
|
||||
}
|
||||
174
packages/core/src/utils/cronParser.test.ts
Normal file
174
packages/core/src/utils/cronParser.test.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { matches, nextFireTime, parseCron } from './cronParser.js';
|
||||
|
||||
describe('parseCron', () => {
|
||||
it('parses wildcard fields', () => {
|
||||
const fields = parseCron('* * * * *');
|
||||
expect(fields.minute.size).toBe(60);
|
||||
expect(fields.hour.size).toBe(24);
|
||||
expect(fields.dayOfMonth.size).toBe(31);
|
||||
expect(fields.month.size).toBe(12);
|
||||
expect(fields.dayOfWeek.size).toBe(7);
|
||||
});
|
||||
|
||||
it('parses single values', () => {
|
||||
const fields = parseCron('5 14 1 6 3');
|
||||
expect([...fields.minute]).toEqual([5]);
|
||||
expect([...fields.hour]).toEqual([14]);
|
||||
expect([...fields.dayOfMonth]).toEqual([1]);
|
||||
expect([...fields.month]).toEqual([6]);
|
||||
expect([...fields.dayOfWeek]).toEqual([3]);
|
||||
});
|
||||
|
||||
it('parses ranges', () => {
|
||||
const fields = parseCron('1-5 * * * *');
|
||||
expect([...fields.minute].sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
it('parses comma lists', () => {
|
||||
const fields = parseCron('0,15,30,45 * * * *');
|
||||
expect([...fields.minute].sort((a, b) => a - b)).toEqual([0, 15, 30, 45]);
|
||||
});
|
||||
|
||||
it('parses steps', () => {
|
||||
const fields = parseCron('*/15 * * * *');
|
||||
expect([...fields.minute].sort((a, b) => a - b)).toEqual([0, 15, 30, 45]);
|
||||
});
|
||||
|
||||
it('parses range with step', () => {
|
||||
const fields = parseCron('1-10/3 * * * *');
|
||||
expect([...fields.minute].sort((a, b) => a - b)).toEqual([1, 4, 7, 10]);
|
||||
});
|
||||
|
||||
it('throws on wrong number of fields', () => {
|
||||
expect(() => parseCron('* * *')).toThrow('must have exactly 5 fields');
|
||||
expect(() => parseCron('* * * * * *')).toThrow(
|
||||
'must have exactly 5 fields',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on out-of-range values', () => {
|
||||
expect(() => parseCron('60 * * * *')).toThrow('out of bounds');
|
||||
expect(() => parseCron('* 25 * * *')).toThrow('out of bounds');
|
||||
expect(() => parseCron('* * 0 * *')).toThrow('out of bounds');
|
||||
expect(() => parseCron('* * * 13 *')).toThrow('out of bounds');
|
||||
expect(() => parseCron('* * * * 8')).toThrow('out of bounds');
|
||||
});
|
||||
|
||||
it('accepts 7 as Sunday and normalizes to 0', () => {
|
||||
const fields = parseCron('* * * * 7');
|
||||
expect(fields.dayOfWeek.has(0)).toBe(true);
|
||||
expect(fields.dayOfWeek.has(7)).toBe(false);
|
||||
});
|
||||
|
||||
it('throws on invalid step', () => {
|
||||
expect(() => parseCron('*/0 * * * *')).toThrow('Invalid step');
|
||||
});
|
||||
});
|
||||
|
||||
describe('matches', () => {
|
||||
it('matches every-minute cron', () => {
|
||||
const date = new Date(2025, 0, 15, 10, 30); // Jan 15 2025, 10:30
|
||||
expect(matches('* * * * *', date)).toBe(true);
|
||||
});
|
||||
|
||||
it('matches specific minute', () => {
|
||||
const date = new Date(2025, 0, 15, 10, 30);
|
||||
expect(matches('30 * * * *', date)).toBe(true);
|
||||
expect(matches('31 * * * *', date)).toBe(false);
|
||||
});
|
||||
|
||||
it('matches specific hour and minute', () => {
|
||||
const date = new Date(2025, 0, 15, 14, 0);
|
||||
expect(matches('0 14 * * *', date)).toBe(true);
|
||||
expect(matches('0 13 * * *', date)).toBe(false);
|
||||
});
|
||||
|
||||
it('matches day of week', () => {
|
||||
// Jan 15 2025 is a Wednesday (day 3)
|
||||
const date = new Date(2025, 0, 15, 10, 0);
|
||||
expect(matches('* * * * 3', date)).toBe(true);
|
||||
expect(matches('* * * * 1', date)).toBe(false);
|
||||
});
|
||||
|
||||
it('uses OR logic when both day-of-month and day-of-week are constrained', () => {
|
||||
// Jan 15 2025 is a Wednesday (day 3), day-of-month 15
|
||||
const date = new Date(2025, 0, 15, 10, 0);
|
||||
// dom=1 (no match), dow=3 (match) → should match via OR
|
||||
expect(matches('0 10 1 * 3', date)).toBe(true);
|
||||
// dom=15 (match), dow=1 (no match) → should match via OR
|
||||
expect(matches('0 10 15 * 1', date)).toBe(true);
|
||||
// dom=1 (no match), dow=1 (no match) → no match
|
||||
expect(matches('0 10 1 * 1', date)).toBe(false);
|
||||
});
|
||||
|
||||
it('uses AND logic when only one day field is constrained', () => {
|
||||
// Jan 15 2025 is a Wednesday (day 3)
|
||||
const date = new Date(2025, 0, 15, 10, 0);
|
||||
// dom=1, dow=* → AND, dom doesn't match
|
||||
expect(matches('0 10 1 * *', date)).toBe(false);
|
||||
// dom=*, dow=1 → AND, dow doesn't match
|
||||
expect(matches('0 10 * * 1', date)).toBe(false);
|
||||
});
|
||||
|
||||
it('matches every-N-minutes pattern', () => {
|
||||
const date0 = new Date(2025, 0, 15, 10, 0);
|
||||
const date5 = new Date(2025, 0, 15, 10, 5);
|
||||
const date3 = new Date(2025, 0, 15, 10, 3);
|
||||
expect(matches('*/5 * * * *', date0)).toBe(true);
|
||||
expect(matches('*/5 * * * *', date5)).toBe(true);
|
||||
expect(matches('*/5 * * * *', date3)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextFireTime', () => {
|
||||
it('finds next minute for * * * * *', () => {
|
||||
const now = new Date(2025, 0, 15, 10, 30, 15); // 10:30:15
|
||||
const next = nextFireTime('* * * * *', now);
|
||||
expect(next.getHours()).toBe(10);
|
||||
expect(next.getMinutes()).toBe(31);
|
||||
expect(next.getSeconds()).toBe(0);
|
||||
});
|
||||
|
||||
it('finds next match for specific minute', () => {
|
||||
const now = new Date(2025, 0, 15, 10, 30, 0);
|
||||
const next = nextFireTime('45 * * * *', now);
|
||||
expect(next.getHours()).toBe(10);
|
||||
expect(next.getMinutes()).toBe(45);
|
||||
});
|
||||
|
||||
it('rolls to next hour when no match in current hour', () => {
|
||||
const now = new Date(2025, 0, 15, 10, 50, 0);
|
||||
const next = nextFireTime('15 * * * *', now);
|
||||
expect(next.getHours()).toBe(11);
|
||||
expect(next.getMinutes()).toBe(15);
|
||||
});
|
||||
|
||||
it('finds next match for every-5-minutes', () => {
|
||||
const now = new Date(2025, 0, 15, 10, 31, 0);
|
||||
const next = nextFireTime('*/5 * * * *', now);
|
||||
expect(next.getMinutes()).toBe(35);
|
||||
});
|
||||
|
||||
it('finds next match for specific hour', () => {
|
||||
const now = new Date(2025, 0, 15, 10, 30, 0);
|
||||
const next = nextFireTime('0 14 * * *', now);
|
||||
expect(next.getHours()).toBe(14);
|
||||
expect(next.getMinutes()).toBe(0);
|
||||
expect(next.getDate()).toBe(15);
|
||||
});
|
||||
|
||||
it('rolls to next day for past time', () => {
|
||||
const now = new Date(2025, 0, 15, 15, 0, 0); // 3pm
|
||||
const next = nextFireTime('0 9 * * *', now); // 9am daily
|
||||
expect(next.getDate()).toBe(16);
|
||||
expect(next.getHours()).toBe(9);
|
||||
});
|
||||
|
||||
it('returns time strictly after the input', () => {
|
||||
// Even if `after` is exactly on a match minute, next should be the following match
|
||||
const now = new Date(2025, 0, 15, 10, 0, 0); // exactly 10:00:00
|
||||
const next = nextFireTime('*/5 * * * *', now);
|
||||
expect(next.getTime()).toBeGreaterThan(now.getTime());
|
||||
});
|
||||
});
|
||||
186
packages/core/src/utils/cronParser.ts
Normal file
186
packages/core/src/utils/cronParser.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
/**
|
||||
* Minimal 5-field cron expression parser.
|
||||
*
|
||||
* Fields: minute (0-59), hour (0-23), day-of-month (1-31), month (1-12), day-of-week (0-7, 0 and 7=Sun)
|
||||
* Supports: *, single values, steps (asterisk/N), ranges (a-b), comma lists (a,b,c)
|
||||
* No extended syntax (L, W, ?, name aliases).
|
||||
*/
|
||||
|
||||
interface CronFields {
|
||||
minute: Set<number>;
|
||||
hour: Set<number>;
|
||||
dayOfMonth: Set<number>;
|
||||
month: Set<number>;
|
||||
dayOfWeek: Set<number>;
|
||||
/** True when the day-of-month field was literally '*' (unrestricted). */
|
||||
domIsWild: boolean;
|
||||
/** True when the day-of-week field was literally '*' (unrestricted). */
|
||||
dowIsWild: boolean;
|
||||
}
|
||||
|
||||
const FIELD_RANGES: Array<[number, number]> = [
|
||||
[0, 59], // minute
|
||||
[0, 23], // hour
|
||||
[1, 31], // day of month
|
||||
[1, 12], // month
|
||||
[0, 7], // day of week (0 and 7 both mean Sunday)
|
||||
];
|
||||
|
||||
/**
|
||||
* Parses a single cron field into a set of matching values.
|
||||
* Supports: star, single values, steps (star/N), ranges (a-b), comma lists.
|
||||
*/
|
||||
function parseField(field: string, min: number, max: number): Set<number> {
|
||||
const values = new Set<number>();
|
||||
|
||||
for (const part of field.split(',')) {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error(`Empty field segment in "${field}"`);
|
||||
}
|
||||
|
||||
// Handle step: */N or range/N or value/N
|
||||
const stepParts = trimmed.split('/');
|
||||
if (stepParts.length > 2) {
|
||||
throw new Error(`Invalid step expression: "${trimmed}"`);
|
||||
}
|
||||
|
||||
let rangeStart: number;
|
||||
let rangeEnd: number;
|
||||
const base = stepParts[0]!;
|
||||
|
||||
if (base === '*') {
|
||||
rangeStart = min;
|
||||
rangeEnd = max;
|
||||
} else if (base.includes('-')) {
|
||||
const [startStr, endStr] = base.split('-');
|
||||
rangeStart = parseInt(startStr!, 10);
|
||||
rangeEnd = parseInt(endStr!, 10);
|
||||
if (isNaN(rangeStart) || isNaN(rangeEnd)) {
|
||||
throw new Error(`Invalid range: "${base}"`);
|
||||
}
|
||||
if (rangeStart < min || rangeEnd > max || rangeStart > rangeEnd) {
|
||||
throw new Error(`Range ${base} out of bounds [${min}-${max}]`);
|
||||
}
|
||||
} else {
|
||||
const val = parseInt(base, 10);
|
||||
if (isNaN(val) || val < min || val > max) {
|
||||
throw new Error(`Value "${base}" out of bounds [${min}-${max}]`);
|
||||
}
|
||||
rangeStart = val;
|
||||
rangeEnd = val;
|
||||
}
|
||||
|
||||
const step = stepParts.length === 2 ? parseInt(stepParts[1]!, 10) : 1;
|
||||
if (isNaN(step) || step <= 0) {
|
||||
throw new Error(`Invalid step: "${stepParts[1]}"`);
|
||||
}
|
||||
|
||||
for (let i = rangeStart; i <= rangeEnd; i += step) {
|
||||
values.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a 5-field cron expression into structured fields.
|
||||
* Throws on invalid expressions.
|
||||
*/
|
||||
export function parseCron(cronExpr: string): CronFields {
|
||||
const parts = cronExpr.trim().split(/\s+/);
|
||||
if (parts.length !== 5) {
|
||||
throw new Error(
|
||||
`Cron expression must have exactly 5 fields, got ${parts.length}: "${cronExpr}"`,
|
||||
);
|
||||
}
|
||||
|
||||
// Parse day-of-week with range 0-7, then normalize 7 → 0 (both mean Sunday)
|
||||
const dayOfWeek = parseField(
|
||||
parts[4]!,
|
||||
FIELD_RANGES[4]![0],
|
||||
FIELD_RANGES[4]![1],
|
||||
);
|
||||
if (dayOfWeek.has(7)) {
|
||||
dayOfWeek.delete(7);
|
||||
dayOfWeek.add(0);
|
||||
}
|
||||
|
||||
return {
|
||||
minute: parseField(parts[0]!, FIELD_RANGES[0]![0], FIELD_RANGES[0]![1]),
|
||||
hour: parseField(parts[1]!, FIELD_RANGES[1]![0], FIELD_RANGES[1]![1]),
|
||||
dayOfMonth: parseField(parts[2]!, FIELD_RANGES[2]![0], FIELD_RANGES[2]![1]),
|
||||
month: parseField(parts[3]!, FIELD_RANGES[3]![0], FIELD_RANGES[3]![1]),
|
||||
dayOfWeek,
|
||||
domIsWild: parts[2]!.trim() === '*',
|
||||
dowIsWild: parts[4]!.trim() === '*',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given date matches the cron expression.
|
||||
*
|
||||
* Follows vixie-cron day semantics: when both day-of-month and day-of-week
|
||||
* are constrained (neither is `*`), the date matches if EITHER field matches.
|
||||
* When only one is constrained, it must match.
|
||||
*/
|
||||
export function matches(cronExpr: string, date: Date): boolean {
|
||||
const fields = parseCron(cronExpr);
|
||||
|
||||
if (
|
||||
!fields.minute.has(date.getMinutes()) ||
|
||||
!fields.hour.has(date.getHours()) ||
|
||||
!fields.month.has(date.getMonth() + 1)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const domMatch = fields.dayOfMonth.has(date.getDate());
|
||||
const dowMatch = fields.dayOfWeek.has(date.getDay());
|
||||
|
||||
// Vixie-cron: if both day-of-month and day-of-week are restricted,
|
||||
// match if EITHER is satisfied. Otherwise use AND.
|
||||
if (!fields.domIsWild && !fields.dowIsWild) {
|
||||
return domMatch || dowMatch;
|
||||
}
|
||||
|
||||
return domMatch && dowMatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next fire time after `after` for the given cron expression.
|
||||
* Scans forward minute-by-minute (up to ~4 years) to find the next match.
|
||||
*/
|
||||
export function nextFireTime(cronExpr: string, after: Date): Date {
|
||||
const fields = parseCron(cronExpr);
|
||||
|
||||
// Start at the next whole minute after `after`
|
||||
const candidate = new Date(after.getTime());
|
||||
candidate.setSeconds(0, 0);
|
||||
candidate.setMinutes(candidate.getMinutes() + 1);
|
||||
|
||||
// Scan up to 4 years (~2.1M minutes) to avoid infinite loops
|
||||
const maxIterations = 4 * 366 * 24 * 60;
|
||||
|
||||
for (let i = 0; i < maxIterations; i++) {
|
||||
const minuteOk = fields.minute.has(candidate.getMinutes());
|
||||
const hourOk = fields.hour.has(candidate.getHours());
|
||||
const monthOk = fields.month.has(candidate.getMonth() + 1);
|
||||
const domOk = fields.dayOfMonth.has(candidate.getDate());
|
||||
const dowOk = fields.dayOfWeek.has(candidate.getDay());
|
||||
|
||||
// Vixie-cron day semantics: OR when both constrained, AND otherwise
|
||||
const dayOk =
|
||||
!fields.domIsWild && !fields.dowIsWild ? domOk || dowOk : domOk && dowOk;
|
||||
|
||||
if (minuteOk && hourOk && monthOk && dayOk) {
|
||||
return candidate;
|
||||
}
|
||||
candidate.setMinutes(candidate.getMinutes() + 1);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`No matching fire time found within 4 years for: "${cronExpr}"`,
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue