From b004f036699f9ee6b0b671250662d6794fbf47a1 Mon Sep 17 00:00:00 2001 From: Copilot Date: Tue, 17 Mar 2026 17:18:42 +0200 Subject: [PATCH 1/2] feat: generic provider-agnostic scheduler (#296) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a unified .squad/schedule.json manifest with provider adapters that replace scattered cron jobs, polling scripts, and manual triggers. New files: - packages/squad-sdk/src/runtime/scheduler.ts — schema types, validation, trigger evaluation (cron/interval/event/startup), task execution with retry, state persistence, LocalPollingProvider, GitHubActionsProvider - packages/squad-cli/src/cli/commands/schedule.ts — CLI subcommands: list, run , init, status - packages/squad-sdk/templates/schedule.json — default template - test/scheduler.test.ts — 51 tests covering validation, triggers, providers, state, and file I/O - .changeset/squad-scheduler.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/squad-scheduler.md | 6 + packages/squad-cli/src/cli-entry.ts | 9 + .../squad-cli/src/cli/commands/schedule.ts | 243 +++++++ packages/squad-sdk/package.json | 4 + packages/squad-sdk/src/runtime/scheduler.ts | 567 ++++++++++++++++ packages/squad-sdk/templates/schedule.json | 19 + test/scheduler.test.ts | 619 ++++++++++++++++++ 7 files changed, 1467 insertions(+) create mode 100644 .changeset/squad-scheduler.md create mode 100644 packages/squad-cli/src/cli/commands/schedule.ts create mode 100644 packages/squad-sdk/src/runtime/scheduler.ts create mode 100644 packages/squad-sdk/templates/schedule.json create mode 100644 test/scheduler.test.ts diff --git a/.changeset/squad-scheduler.md b/.changeset/squad-scheduler.md new file mode 100644 index 000000000..c008a8920 --- /dev/null +++ b/.changeset/squad-scheduler.md @@ -0,0 +1,6 @@ +--- +"@bradygaster/squad-sdk": minor +"@bradygaster/squad-cli": minor +--- + +Generic, provider-agnostic scheduler for Squad (#296) — unified schedule.json manifest with LocalPolling and GitHubActions provider adapters, CLI commands (list, run, init, status), schema validation, and cron/interval trigger evaluation. diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index ffd3524d8..af9dcbe47 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -187,6 +187,8 @@ async function main(): Promise { console.log(` --watch (rebuild on change)`); console.log(` ${BOLD}aspire${RESET} Launch .NET Aspire dashboard for observability`); console.log(` Flags: --docker (force Docker), --port (dashboard port)`); + console.log(` ${BOLD}schedule${RESET} Manage scheduled tasks`); + console.log(` Usage: schedule list | run | init | status`); console.log(` ${BOLD}rc${RESET} Start Remote Control bridge (phone/browser → Copilot)`); console.log(` Usage: rc [--tunnel] [--port ] [--path ]`); console.log(` ${BOLD}copilot-bridge${RESET} Check Copilot ACP stdio compatibility`); @@ -534,6 +536,13 @@ async function main(): Promise { return; } + if (cmd === 'schedule') { + const { runSchedule } = await import('./cli/commands/schedule.js'); + const subcommand = args[1] || 'list'; + await runSchedule(process.cwd(), subcommand, args.slice(2)); + return; + } + if (cmd === 'upstream') { const { upstreamCommand } = await import('./cli/commands/upstream.js'); await upstreamCommand(args.slice(1)); diff --git a/packages/squad-cli/src/cli/commands/schedule.ts b/packages/squad-cli/src/cli/commands/schedule.ts new file mode 100644 index 000000000..6e22bd63b --- /dev/null +++ b/packages/squad-cli/src/cli/commands/schedule.ts @@ -0,0 +1,243 @@ +/** + * Schedule command — manage Squad scheduled tasks (#296) + * + * Subcommands: + * squad schedule list — show configured schedules + * squad schedule run — manually trigger a scheduled task + * squad schedule init — create a default schedule.json + * squad schedule status — show last run times and next due + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { detectSquadDir } from '../core/detect-squad-dir.js'; +import { success, warn, info, secondary, BOLD, RESET, GREEN, YELLOW, RED, GRAY, DIM } from '../core/output.js'; +import { fatal } from '../core/errors.js'; + +import type { ScheduleManifest, ScheduleState, ScheduleEntry, ScheduleProvider } from '@bradygaster/squad-sdk/runtime/scheduler'; + +// Re-export so tests can mock through this module +async function loadSchedulerModule() { + return import('@bradygaster/squad-sdk/runtime/scheduler'); +} + +function resolveSchedulePath(cwd: string): string { + const squadInfo = detectSquadDir(cwd); + return path.join(squadInfo.path, 'schedule.json'); +} + +function resolveStatePath(cwd: string): string { + const squadInfo = detectSquadDir(cwd); + return path.join(squadInfo.path, '.schedule-state.json'); +} + +/** + * Entry point for `squad schedule` subcommands. + */ +export async function runSchedule(cwd: string, subcommand: string, args: string[]): Promise { + switch (subcommand) { + case 'list': + await scheduleList(cwd); + break; + case 'run': { + const id = args[0]; + if (!id) { + fatal('Usage: squad schedule run '); + } + await scheduleRun(cwd, id); + break; + } + case 'init': + await scheduleInit(cwd); + break; + case 'status': + await scheduleStatus(cwd); + break; + default: + fatal( + `Unknown schedule subcommand: ${subcommand}\n` + + ` Available: list, run , init, status`, + ); + } +} + +// ============================================================================ +// Subcommand: list +// ============================================================================ + +async function scheduleList(cwd: string): Promise { + const schedulePath = resolveSchedulePath(cwd); + const mod = await loadSchedulerModule(); + let manifest: ScheduleManifest; + + try { + manifest = await mod.parseSchedule(schedulePath); + } catch { + fatal(`No schedule.json found — run 'squad schedule init' to create one`); + } + + if (manifest.schedules.length === 0) { + info('No schedules configured.'); + return; + } + + console.log(`\n${BOLD}Configured Schedules${RESET} (${manifest.schedules.length}):\n`); + + for (const entry of manifest.schedules) { + const status = entry.enabled ? `${GREEN}●${RESET} enabled` : `${GRAY}○${RESET} disabled`; + const trigger = formatTrigger(entry); + console.log(` ${BOLD}${entry.id}${RESET} — ${entry.name}`); + console.log(` ${status} │ ${trigger} │ ${entry.task.type}:${entry.task.ref}`); + console.log(` providers: ${entry.providers.join(', ')}`); + if (entry.retry) { + console.log(` retry: ${entry.retry.maxRetries}× (backoff ${entry.retry.backoffSeconds}s)`); + } + console.log(''); + } +} + +// ============================================================================ +// Subcommand: run +// ============================================================================ + +async function scheduleRun(cwd: string, id: string): Promise { + const schedulePath = resolveSchedulePath(cwd); + const statePath = resolveStatePath(cwd); + const mod = await loadSchedulerModule(); + + let manifest: ScheduleManifest; + try { + manifest = await mod.parseSchedule(schedulePath); + } catch { + fatal(`No schedule.json found — run 'squad schedule init' to create one`); + } + + const entry = manifest.schedules.find(s => s.id === id); + if (!entry) { + fatal(`Schedule '${id}' not found. Run 'squad schedule list' to see available schedules.`); + } + + info(`Running schedule: ${entry.name} (${entry.id})...`); + + const provider = new mod.LocalPollingProvider(); + const state = await mod.loadState(statePath); + + state.runs[id] = { + lastRun: new Date().toISOString(), + status: 'running', + }; + await mod.saveState(statePath, state); + + const result = await mod.executeTask(entry, provider); + + state.runs[id] = { + lastRun: new Date().toISOString(), + status: result.success ? 'success' : 'failure', + error: result.error, + }; + await mod.saveState(statePath, state); + + if (result.success) { + success(`${entry.name} completed`); + if (result.output) { + secondary(` ${result.output}`); + } + } else { + console.error(`${RED}✗${RESET} ${entry.name} failed: ${result.error ?? 'unknown error'}`); + } +} + +// ============================================================================ +// Subcommand: init +// ============================================================================ + +async function scheduleInit(cwd: string): Promise { + const schedulePath = resolveSchedulePath(cwd); + const mod = await loadSchedulerModule(); + + if (fs.existsSync(schedulePath)) { + warn(`schedule.json already exists at ${schedulePath}`); + return; + } + + const squadInfo = detectSquadDir(cwd); + if (!fs.existsSync(squadInfo.path)) { + fs.mkdirSync(squadInfo.path, { recursive: true }); + } + + const template = mod.defaultScheduleTemplate(); + fs.writeFileSync(schedulePath, JSON.stringify(template, null, 2) + '\n', 'utf8'); + success(`Created ${path.relative(cwd, schedulePath)}`); + info(`Edit it to configure your scheduled tasks, then run 'squad schedule list' to verify.`); +} + +// ============================================================================ +// Subcommand: status +// ============================================================================ + +async function scheduleStatus(cwd: string): Promise { + const schedulePath = resolveSchedulePath(cwd); + const statePath = resolveStatePath(cwd); + const mod = await loadSchedulerModule(); + + let manifest: ScheduleManifest; + try { + manifest = await mod.parseSchedule(schedulePath); + } catch { + fatal(`No schedule.json found — run 'squad schedule init' to create one`); + } + + const state = await mod.loadState(statePath); + const now = new Date(); + + console.log(`\n${BOLD}Schedule Status${RESET} (${now.toISOString()})\n`); + + for (const entry of manifest.schedules) { + const run = state.runs[entry.id]; + const statusIcon = !run + ? `${GRAY}–${RESET} never run` + : run.status === 'success' + ? `${GREEN}✓${RESET} success` + : run.status === 'running' + ? `${YELLOW}⟳${RESET} running` + : `${RED}✗${RESET} failure`; + + const lastRunStr = run ? `last: ${run.lastRun}` : 'last: –'; + const enabledStr = entry.enabled ? '' : ` ${DIM}(disabled)${RESET}`; + + console.log(` ${BOLD}${entry.id}${RESET}${enabledStr}`); + console.log(` ${statusIcon} │ ${lastRunStr}`); + if (run?.error) { + console.log(` ${RED}error: ${run.error}${RESET}`); + } + console.log(''); + } + + // Show which are due + const due = mod.evaluateSchedule(manifest, state, now); + if (due.length > 0) { + console.log(`${YELLOW}⚡${RESET} ${due.length} schedule(s) due now: ${due.map(d => d.id).join(', ')}`); + } else { + secondary('No schedules due at this time.'); + } +} + +// ============================================================================ +// Helpers +// ============================================================================ + +function formatTrigger(entry: ScheduleEntry): string { + const trigger = entry.trigger; + switch (trigger.type) { + case 'cron': + return `cron: ${trigger.cron}`; + case 'interval': + return `every ${trigger.intervalSeconds}s`; + case 'event': + return `on: ${trigger.event}`; + case 'startup': + return 'on startup'; + default: + return 'unknown trigger'; + } +} diff --git a/packages/squad-sdk/package.json b/packages/squad-sdk/package.json index f36c63776..3912a969c 100644 --- a/packages/squad-sdk/package.json +++ b/packages/squad-sdk/package.json @@ -146,6 +146,10 @@ "types": "./dist/runtime/event-bus-ws-bridge.d.ts", "import": "./dist/runtime/event-bus-ws-bridge.js" }, + "./runtime/scheduler": { + "types": "./dist/runtime/scheduler.d.ts", + "import": "./dist/runtime/scheduler.js" + }, "./runtime/constants": { "types": "./dist/runtime/constants.d.ts", "import": "./dist/runtime/constants.js" diff --git a/packages/squad-sdk/src/runtime/scheduler.ts b/packages/squad-sdk/src/runtime/scheduler.ts new file mode 100644 index 000000000..ece2b0538 --- /dev/null +++ b/packages/squad-sdk/src/runtime/scheduler.ts @@ -0,0 +1,567 @@ +/** + * Scheduler — Generic, provider-agnostic scheduler for Squad (#296) + * + * Unified schedule manifest + provider adapters that replace scattered + * cron jobs, polling scripts, and manual triggers with a single + * `.squad/schedule.json` configuration file. + * + * Provider model: + * - LocalPollingProvider — evaluates schedule in ralph-watch loop + * - GitHubActionsProvider — generates/updates workflow files from schedule + * - Custom providers via ScheduleProvider interface + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import fs from 'node:fs'; +import path from 'node:path'; + +// ============================================================================ +// Schedule Schema Types +// ============================================================================ + +export interface ScheduleManifest { + version: number; + schedules: ScheduleEntry[]; +} + +export interface ScheduleEntry { + id: string; + name: string; + enabled: boolean; + trigger: TriggerConfig; + task: TaskConfig; + providers: string[]; + retry?: RetryConfig; +} + +export type TriggerConfig = + | CronTrigger + | IntervalTrigger + | EventTrigger + | StartupTrigger; + +export interface CronTrigger { + type: 'cron'; + cron: string; +} + +export interface IntervalTrigger { + type: 'interval'; + intervalSeconds: number; +} + +export interface EventTrigger { + type: 'event'; + event: string; +} + +export interface StartupTrigger { + type: 'startup'; +} + +export interface TaskConfig { + type: 'workflow' | 'script' | 'copilot' | 'webhook'; + ref: string; + args?: Record; +} + +export interface RetryConfig { + maxRetries: number; + backoffSeconds: number; +} + +// ============================================================================ +// Execution State +// ============================================================================ + +export interface ScheduleState { + /** Map of schedule id → last run info */ + runs: Record; +} + +export interface RunRecord { + lastRun: string; + nextDue?: string; + status: 'success' | 'failure' | 'running'; + error?: string; +} + +export interface TaskResult { + success: boolean; + output?: string; + error?: string; +} + +// ============================================================================ +// Provider Interface +// ============================================================================ + +export interface ScheduleProvider { + readonly name: string; + execute(entry: ScheduleEntry): Promise; + /** Optional: generate platform-native config (e.g. GitHub Actions workflow) */ + generate?(manifest: ScheduleManifest, outDir: string): Promise; +} + +// ============================================================================ +// Validation +// ============================================================================ + +export class ScheduleValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ScheduleValidationError'; + } +} + +const VALID_TRIGGER_TYPES = ['cron', 'interval', 'event', 'startup'] as const; +const VALID_TASK_TYPES = ['workflow', 'script', 'copilot', 'webhook'] as const; + +/** + * Validate a raw object against the ScheduleManifest schema. + * Throws ScheduleValidationError on invalid input. + */ +export function validateManifest(data: unknown): ScheduleManifest { + if (data === null || typeof data !== 'object') { + throw new ScheduleValidationError('Schedule manifest must be a JSON object'); + } + + const obj = data as Record; + + if (typeof obj.version !== 'number' || obj.version < 1) { + throw new ScheduleValidationError('Schedule manifest requires a positive integer "version" field'); + } + + if (!Array.isArray(obj.schedules)) { + throw new ScheduleValidationError('Schedule manifest requires a "schedules" array'); + } + + const seenIds = new Set(); + for (let i = 0; i < obj.schedules.length; i++) { + const entry = obj.schedules[i] as Record; + validateEntry(entry, i, seenIds); + } + + return data as ScheduleManifest; +} + +function validateEntry(entry: unknown, index: number, seenIds: Set): void { + if (entry === null || typeof entry !== 'object') { + throw new ScheduleValidationError(`schedules[${index}] must be an object`); + } + + const e = entry as Record; + const prefix = `schedules[${index}]`; + + if (typeof e.id !== 'string' || e.id.length === 0) { + throw new ScheduleValidationError(`${prefix}.id must be a non-empty string`); + } + if (seenIds.has(e.id)) { + throw new ScheduleValidationError(`Duplicate schedule id: "${e.id}"`); + } + seenIds.add(e.id); + + if (typeof e.name !== 'string' || e.name.length === 0) { + throw new ScheduleValidationError(`${prefix}.name must be a non-empty string`); + } + if (typeof e.enabled !== 'boolean') { + throw new ScheduleValidationError(`${prefix}.enabled must be a boolean`); + } + + // Trigger validation + if (e.trigger === null || typeof e.trigger !== 'object') { + throw new ScheduleValidationError(`${prefix}.trigger must be an object`); + } + const trigger = e.trigger as Record; + if (!VALID_TRIGGER_TYPES.includes(trigger.type as typeof VALID_TRIGGER_TYPES[number])) { + throw new ScheduleValidationError( + `${prefix}.trigger.type must be one of: ${VALID_TRIGGER_TYPES.join(', ')}`, + ); + } + if (trigger.type === 'cron' && (typeof trigger.cron !== 'string' || trigger.cron.length === 0)) { + throw new ScheduleValidationError(`${prefix}.trigger.cron must be a non-empty string`); + } + if (trigger.type === 'interval') { + if (typeof trigger.intervalSeconds !== 'number' || trigger.intervalSeconds <= 0) { + throw new ScheduleValidationError(`${prefix}.trigger.intervalSeconds must be a positive number`); + } + } + if (trigger.type === 'event' && (typeof trigger.event !== 'string' || trigger.event.length === 0)) { + throw new ScheduleValidationError(`${prefix}.trigger.event must be a non-empty string`); + } + + // Task validation + if (e.task === null || typeof e.task !== 'object') { + throw new ScheduleValidationError(`${prefix}.task must be an object`); + } + const task = e.task as Record; + if (!VALID_TASK_TYPES.includes(task.type as typeof VALID_TASK_TYPES[number])) { + throw new ScheduleValidationError( + `${prefix}.task.type must be one of: ${VALID_TASK_TYPES.join(', ')}`, + ); + } + if (typeof task.ref !== 'string' || task.ref.length === 0) { + throw new ScheduleValidationError(`${prefix}.task.ref must be a non-empty string`); + } + + // Providers validation + if (!Array.isArray(e.providers) || e.providers.length === 0) { + throw new ScheduleValidationError(`${prefix}.providers must be a non-empty array of strings`); + } + for (const p of e.providers) { + if (typeof p !== 'string' || p.length === 0) { + throw new ScheduleValidationError(`${prefix}.providers must contain only non-empty strings`); + } + } + + // Optional retry validation + if (e.retry !== undefined) { + if (e.retry === null || typeof e.retry !== 'object') { + throw new ScheduleValidationError(`${prefix}.retry must be an object`); + } + const retry = e.retry as Record; + if (typeof retry.maxRetries !== 'number' || retry.maxRetries < 0) { + throw new ScheduleValidationError(`${prefix}.retry.maxRetries must be a non-negative number`); + } + if (typeof retry.backoffSeconds !== 'number' || retry.backoffSeconds <= 0) { + throw new ScheduleValidationError(`${prefix}.retry.backoffSeconds must be a positive number`); + } + } +} + +// ============================================================================ +// Schedule Operations +// ============================================================================ + +/** + * Parse and validate a schedule.json file from disk. + */ +export async function parseSchedule(filePath: string): Promise { + let raw: string; + try { + raw = await readFile(filePath, 'utf8'); + } catch (err) { + throw new ScheduleValidationError( + `Cannot read schedule file: ${filePath} — ${(err as Error).message}`, + ); + } + + let data: unknown; + try { + data = JSON.parse(raw); + } catch { + throw new ScheduleValidationError(`Invalid JSON in schedule file: ${filePath}`); + } + + return validateManifest(data); +} + +/** + * Evaluate which schedules are due now, based on trigger config and state. + * Returns a list of entries that should be executed. + */ +export function evaluateSchedule( + manifest: ScheduleManifest, + state: ScheduleState, + now: Date = new Date(), +): ScheduleEntry[] { + const due: ScheduleEntry[] = []; + + for (const entry of manifest.schedules) { + if (!entry.enabled) continue; + + const run = state.runs[entry.id]; + if (run?.status === 'running') continue; + + if (isDue(entry.trigger, run, now)) { + due.push(entry); + } + } + + return due; +} + +function isDue(trigger: TriggerConfig, run: RunRecord | undefined, now: Date): boolean { + switch (trigger.type) { + case 'startup': + // Due if never run + return run === undefined; + + case 'interval': { + if (!run) return true; + const lastRun = new Date(run.lastRun); + const elapsed = (now.getTime() - lastRun.getTime()) / 1000; + return elapsed >= trigger.intervalSeconds; + } + + case 'cron': + return isCronDue(trigger.cron, run, now); + + case 'event': + // Event triggers are fired externally, not by polling + return false; + + default: + return false; + } +} + +/** + * Minimal cron evaluation for 5-field cron expressions. + * Supports: minute hour day-of-month month day-of-week + * Wildcard (*) and specific values only (no ranges/lists for simplicity). + */ +export function isCronDue(cron: string, run: RunRecord | undefined, now: Date): boolean { + const parts = cron.trim().split(/\s+/); + if (parts.length !== 5) return false; + + const [minField, hourField, domField, monthField, dowField] = parts; + const minute = now.getMinutes(); + const hour = now.getHours(); + const dom = now.getDate(); + const month = now.getMonth() + 1; // 1-12 + const dow = now.getDay(); // 0-6 (Sun-Sat) + + if (!cronFieldMatches(minField!, minute)) return false; + if (!cronFieldMatches(hourField!, hour)) return false; + if (!cronFieldMatches(domField!, dom)) return false; + if (!cronFieldMatches(monthField!, month)) return false; + if (!cronFieldMatches(dowField!, dow)) return false; + + // Don't run again within the same minute + if (run) { + const lastRun = new Date(run.lastRun); + if ( + lastRun.getFullYear() === now.getFullYear() && + lastRun.getMonth() === now.getMonth() && + lastRun.getDate() === now.getDate() && + lastRun.getHours() === now.getHours() && + lastRun.getMinutes() === now.getMinutes() + ) { + return false; + } + } + + return true; +} + +function cronFieldMatches(field: string, value: number): boolean { + if (field === '*') return true; + // Support */N step syntax + if (field.startsWith('*/')) { + const step = parseInt(field.slice(2), 10); + if (isNaN(step) || step <= 0) return false; + return value % step === 0; + } + // Support comma-separated values + const values = field.split(',').map(v => parseInt(v, 10)); + return values.includes(value); +} + +// ============================================================================ +// Task Execution +// ============================================================================ + +/** + * Execute a scheduled task using the specified provider. + * Includes retry logic if configured. + */ +export async function executeTask( + entry: ScheduleEntry, + provider: ScheduleProvider, +): Promise { + const maxRetries = entry.retry?.maxRetries ?? 0; + const backoffSeconds = entry.retry?.backoffSeconds ?? 1; + + let lastResult: TaskResult = { success: false, error: 'No attempt made' }; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + if (attempt > 0) { + const delay = backoffSeconds * Math.pow(2, attempt - 1) * 1000; + await new Promise(resolve => setTimeout(resolve, delay)); + } + try { + lastResult = await provider.execute(entry); + if (lastResult.success) return lastResult; + } catch (err) { + lastResult = { success: false, error: (err as Error).message }; + } + } + + return lastResult; +} + +// ============================================================================ +// State Persistence +// ============================================================================ + +/** + * Load schedule state from disk. Returns empty state if file doesn't exist. + */ +export async function loadState(statePath: string): Promise { + try { + const raw = await readFile(statePath, 'utf8'); + return JSON.parse(raw) as ScheduleState; + } catch { + return { runs: {} }; + } +} + +/** + * Save schedule state to disk. + */ +export async function saveState(statePath: string, state: ScheduleState): Promise { + await writeFile(statePath, JSON.stringify(state, null, 2) + '\n', 'utf8'); +} + +// ============================================================================ +// Built-in Providers +// ============================================================================ + +/** + * LocalPollingProvider — evaluates schedule in the ralph-watch loop. + * Executes tasks as local processes or stubs. + */ +export class LocalPollingProvider implements ScheduleProvider { + readonly name = 'local-polling'; + + async execute(entry: ScheduleEntry): Promise { + switch (entry.task.type) { + case 'script': { + try { + const { execSync } = await import('node:child_process'); + const output = execSync(entry.task.ref, { + encoding: 'utf8', + timeout: 60_000, + }); + return { success: true, output: output.trim() }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } + } + case 'workflow': + return { + success: true, + output: `Workflow ${entry.task.ref} triggered (local stub — use github-actions provider for real dispatch)`, + }; + case 'copilot': + return { + success: true, + output: `Copilot task ${entry.task.ref} dispatched (stub)`, + }; + case 'webhook': { + try { + const resp = await fetch(entry.task.ref, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ scheduleId: entry.id, timestamp: new Date().toISOString() }), + }); + return { + success: resp.ok, + output: `HTTP ${resp.status} ${resp.statusText}`, + error: resp.ok ? undefined : `HTTP ${resp.status}`, + }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } + } + default: + return { success: false, error: `Unknown task type: ${(entry.task as TaskConfig).type}` }; + } + } +} + +/** + * GitHubActionsProvider — generates workflow YAML files from schedule manifest. + */ +export class GitHubActionsProvider implements ScheduleProvider { + readonly name = 'github-actions'; + + async execute(entry: ScheduleEntry): Promise { + // GitHub Actions execution is handled by the platform itself. + // This provider's main value is generate(), not execute(). + return { + success: true, + output: `GitHub Actions handles execution of ${entry.task.ref} natively`, + }; + } + + async generate(manifest: ScheduleManifest, outDir: string): Promise { + const generated: string[] = []; + + for (const entry of manifest.schedules) { + if (!entry.enabled) continue; + if (!entry.providers.includes('github-actions')) continue; + if (entry.trigger.type !== 'cron' && entry.trigger.type !== 'interval') continue; + + const cronExpr = entry.trigger.type === 'cron' + ? entry.trigger.cron + : intervalToCron(entry.trigger.intervalSeconds); + + const workflowName = `squad-schedule-${entry.id}.yml`; + const workflowPath = path.join(outDir, workflowName); + + const yaml = [ + `# Auto-generated by Squad Scheduler from schedule.json`, + `# Schedule: ${entry.name} (${entry.id})`, + `name: "Squad: ${entry.name}"`, + ``, + `on:`, + ` schedule:`, + ` - cron: '${cronExpr}'`, + ` workflow_dispatch: {}`, + ``, + `jobs:`, + ` run:`, + ` runs-on: ubuntu-latest`, + ` steps:`, + ` - uses: actions/checkout@v4`, + ` - name: Run scheduled task`, + ` run: echo "Executing ${entry.id} — ${entry.task.type}:${entry.task.ref}"`, + ].join('\n') + '\n'; + + const dir = path.dirname(workflowPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(workflowPath, yaml, 'utf8'); + generated.push(workflowPath); + } + + return generated; + } +} + +/** + * Convert an interval in seconds to the nearest cron expression. + */ +function intervalToCron(seconds: number): string { + const minutes = Math.max(1, Math.round(seconds / 60)); + if (minutes < 60) return `*/${minutes} * * * *`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `0 */${hours} * * *`; + return '0 0 * * *'; // daily fallback +} + +// ============================================================================ +// Default Template +// ============================================================================ + +/** + * Default schedule.json template for `squad schedule init`. + */ +export function defaultScheduleTemplate(): ScheduleManifest { + return { + version: 1, + schedules: [ + { + id: 'ralph-heartbeat', + name: 'Ralph Heartbeat', + enabled: true, + trigger: { type: 'interval', intervalSeconds: 300 }, + task: { type: 'workflow', ref: '.github/workflows/squad-heartbeat.yml' }, + providers: ['local-polling', 'github-actions'], + }, + ], + }; +} diff --git a/packages/squad-sdk/templates/schedule.json b/packages/squad-sdk/templates/schedule.json new file mode 100644 index 000000000..8f3648f7b --- /dev/null +++ b/packages/squad-sdk/templates/schedule.json @@ -0,0 +1,19 @@ +{ + "version": 1, + "schedules": [ + { + "id": "ralph-heartbeat", + "name": "Ralph Heartbeat", + "enabled": true, + "trigger": { + "type": "interval", + "intervalSeconds": 300 + }, + "task": { + "type": "workflow", + "ref": ".github/workflows/squad-heartbeat.yml" + }, + "providers": ["local-polling", "github-actions"] + } + ] +} diff --git a/test/scheduler.test.ts b/test/scheduler.test.ts new file mode 100644 index 000000000..f9f3ac88c --- /dev/null +++ b/test/scheduler.test.ts @@ -0,0 +1,619 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { + validateManifest, + parseSchedule, + evaluateSchedule, + executeTask, + isCronDue, + loadState, + saveState, + defaultScheduleTemplate, + LocalPollingProvider, + GitHubActionsProvider, + ScheduleValidationError, +} from '../packages/squad-sdk/src/runtime/scheduler.js'; +import type { + ScheduleManifest, + ScheduleEntry, + ScheduleState, + ScheduleProvider, + RunRecord, + TaskResult, +} from '../packages/squad-sdk/src/runtime/scheduler.js'; + +// ============================================================================ +// Test Helpers +// ============================================================================ + +function validManifest(overrides?: Partial): ScheduleManifest { + return { + version: 1, + schedules: [ + { + id: 'test-task', + name: 'Test Task', + enabled: true, + trigger: { type: 'interval', intervalSeconds: 60 }, + task: { type: 'script', ref: 'echo hello' }, + providers: ['local-polling'], + }, + ], + ...overrides, + }; +} + +function validEntry(overrides?: Partial): ScheduleEntry { + return { + id: 'test-task', + name: 'Test Task', + enabled: true, + trigger: { type: 'interval', intervalSeconds: 60 }, + task: { type: 'script', ref: 'echo hello' }, + providers: ['local-polling'], + ...overrides, + }; +} + +function emptyState(): ScheduleState { + return { runs: {} }; +} + +// ============================================================================ +// Schema Validation Tests +// ============================================================================ + +describe('Scheduler: Schema Validation', () => { + it('should accept a valid manifest', () => { + const manifest = validManifest(); + expect(() => validateManifest(manifest)).not.toThrow(); + const result = validateManifest(manifest); + expect(result.version).toBe(1); + expect(result.schedules).toHaveLength(1); + }); + + it('should reject null input', () => { + expect(() => validateManifest(null)).toThrow(ScheduleValidationError); + expect(() => validateManifest(null)).toThrow('must be a JSON object'); + }); + + it('should reject non-object input', () => { + expect(() => validateManifest('not an object')).toThrow(ScheduleValidationError); + expect(() => validateManifest(42)).toThrow(ScheduleValidationError); + }); + + it('should reject missing version field', () => { + expect(() => validateManifest({ schedules: [] })).toThrow('positive integer "version"'); + }); + + it('should reject version < 1', () => { + expect(() => validateManifest({ version: 0, schedules: [] })).toThrow('positive integer "version"'); + }); + + it('should reject missing schedules array', () => { + expect(() => validateManifest({ version: 1 })).toThrow('"schedules" array'); + }); + + it('should reject entry with empty id', () => { + const manifest = validManifest({ + schedules: [{ ...validEntry(), id: '' }], + }); + expect(() => validateManifest(manifest)).toThrow('id must be a non-empty string'); + }); + + it('should reject duplicate schedule ids', () => { + const manifest = validManifest({ + schedules: [validEntry({ id: 'dup' }), validEntry({ id: 'dup', name: 'Other' })], + }); + expect(() => validateManifest(manifest)).toThrow('Duplicate schedule id: "dup"'); + }); + + it('should reject entry missing enabled boolean', () => { + const entry = { ...validEntry(), enabled: 'yes' } as unknown; + expect(() => validateManifest({ version: 1, schedules: [entry] })).toThrow( + 'enabled must be a boolean', + ); + }); + + it('should reject invalid trigger type', () => { + const entry = { ...validEntry(), trigger: { type: 'magic' } } as unknown; + expect(() => validateManifest({ version: 1, schedules: [entry] })).toThrow( + 'trigger.type must be one of', + ); + }); + + it('should reject cron trigger without cron string', () => { + const entry = validEntry({ trigger: { type: 'cron', cron: '' } }); + expect(() => validateManifest({ version: 1, schedules: [entry] })).toThrow( + 'trigger.cron must be a non-empty string', + ); + }); + + it('should reject interval trigger with non-positive value', () => { + const entry = validEntry({ + trigger: { type: 'interval', intervalSeconds: -5 }, + }); + expect(() => validateManifest({ version: 1, schedules: [entry] })).toThrow( + 'trigger.intervalSeconds must be a positive number', + ); + }); + + it('should reject invalid task type', () => { + const entry = { + ...validEntry(), + task: { type: 'invalid', ref: 'x' }, + } as unknown; + expect(() => validateManifest({ version: 1, schedules: [entry] })).toThrow( + 'task.type must be one of', + ); + }); + + it('should reject task with empty ref', () => { + const entry = validEntry({ + task: { type: 'script', ref: '' }, + }); + expect(() => validateManifest({ version: 1, schedules: [entry] })).toThrow( + 'task.ref must be a non-empty string', + ); + }); + + it('should reject empty providers array', () => { + const entry = validEntry({ providers: [] }); + expect(() => validateManifest({ version: 1, schedules: [entry] })).toThrow( + 'providers must be a non-empty array', + ); + }); + + it('should accept valid retry config', () => { + const entry = validEntry({ + retry: { maxRetries: 3, backoffSeconds: 5 }, + }); + const result = validateManifest({ version: 1, schedules: [entry] }); + expect(result.schedules[0]!.retry).toEqual({ maxRetries: 3, backoffSeconds: 5 }); + }); + + it('should reject retry with negative maxRetries', () => { + const entry = validEntry({ + retry: { maxRetries: -1, backoffSeconds: 5 }, + }); + expect(() => validateManifest({ version: 1, schedules: [entry] })).toThrow( + 'retry.maxRetries must be a non-negative number', + ); + }); + + it('should accept an empty schedules array', () => { + const result = validateManifest({ version: 1, schedules: [] }); + expect(result.schedules).toHaveLength(0); + }); + + it('should accept all trigger types', () => { + const schedules = [ + validEntry({ id: 'cron', trigger: { type: 'cron', cron: '*/5 * * * *' } }), + validEntry({ id: 'interval', trigger: { type: 'interval', intervalSeconds: 30 } }), + validEntry({ id: 'event', trigger: { type: 'event', event: 'push' } }), + validEntry({ id: 'startup', trigger: { type: 'startup' } }), + ]; + const result = validateManifest({ version: 1, schedules }); + expect(result.schedules).toHaveLength(4); + }); + + it('should accept all task types', () => { + const schedules = [ + validEntry({ id: 'wf', task: { type: 'workflow', ref: '.github/workflows/x.yml' } }), + validEntry({ id: 'sc', task: { type: 'script', ref: 'echo hi' } }), + validEntry({ id: 'cp', task: { type: 'copilot', ref: 'review' } }), + validEntry({ id: 'wh', task: { type: 'webhook', ref: 'https://example.com/hook' } }), + ]; + const result = validateManifest({ version: 1, schedules }); + expect(result.schedules).toHaveLength(4); + }); +}); + +// ============================================================================ +// Trigger Evaluation Tests +// ============================================================================ + +describe('Scheduler: Trigger Evaluation', () => { + it('should return all enabled entries when never run (interval)', () => { + const manifest = validManifest(); + const state = emptyState(); + const due = evaluateSchedule(manifest, state); + expect(due).toHaveLength(1); + expect(due[0]!.id).toBe('test-task'); + }); + + it('should skip disabled entries', () => { + const manifest = validManifest({ + schedules: [validEntry({ enabled: false })], + }); + const due = evaluateSchedule(manifest, emptyState()); + expect(due).toHaveLength(0); + }); + + it('should skip entries that are currently running', () => { + const manifest = validManifest(); + const state: ScheduleState = { + runs: { + 'test-task': { lastRun: new Date().toISOString(), status: 'running' }, + }, + }; + const due = evaluateSchedule(manifest, state); + expect(due).toHaveLength(0); + }); + + it('should return interval entry when enough time has elapsed', () => { + const now = new Date('2025-01-15T12:05:00Z'); + const lastRun = new Date('2025-01-15T12:00:00Z'); // 5 minutes ago = 300s + const manifest = validManifest({ + schedules: [validEntry({ trigger: { type: 'interval', intervalSeconds: 300 } })], + }); + const state: ScheduleState = { + runs: { 'test-task': { lastRun: lastRun.toISOString(), status: 'success' } }, + }; + const due = evaluateSchedule(manifest, state, now); + expect(due).toHaveLength(1); + }); + + it('should not return interval entry when not enough time has elapsed', () => { + const now = new Date('2025-01-15T12:02:00Z'); + const lastRun = new Date('2025-01-15T12:00:00Z'); // 2 minutes ago = 120s + const manifest = validManifest({ + schedules: [validEntry({ trigger: { type: 'interval', intervalSeconds: 300 } })], + }); + const state: ScheduleState = { + runs: { 'test-task': { lastRun: lastRun.toISOString(), status: 'success' } }, + }; + const due = evaluateSchedule(manifest, state, now); + expect(due).toHaveLength(0); + }); + + it('should return startup entry only when never run', () => { + const entry = validEntry({ trigger: { type: 'startup' } }); + const manifest = validManifest({ schedules: [entry] }); + + // Never run → due + expect(evaluateSchedule(manifest, emptyState())).toHaveLength(1); + + // Already run → not due + const state: ScheduleState = { + runs: { 'test-task': { lastRun: new Date().toISOString(), status: 'success' } }, + }; + expect(evaluateSchedule(manifest, state)).toHaveLength(0); + }); + + it('should not return event trigger entries (they are externally fired)', () => { + const entry = validEntry({ trigger: { type: 'event', event: 'push' } }); + const manifest = validManifest({ schedules: [entry] }); + expect(evaluateSchedule(manifest, emptyState())).toHaveLength(0); + }); +}); + +// ============================================================================ +// Cron Evaluation Tests +// ============================================================================ + +describe('Scheduler: Cron Evaluation', () => { + it('should match wildcard cron (* * * * *) to any time', () => { + const now = new Date('2025-06-15T14:30:00Z'); + expect(isCronDue('* * * * *', undefined, now)).toBe(true); + }); + + it('should match specific minute', () => { + const now = new Date('2025-06-15T14:30:00Z'); // minute=30 + expect(isCronDue('30 * * * *', undefined, now)).toBe(true); + expect(isCronDue('15 * * * *', undefined, now)).toBe(false); + }); + + it('should match step syntax (*/5)', () => { + const at0 = new Date('2025-06-15T14:00:00Z'); // minute=0 → 0%5=0 ✓ + const at3 = new Date('2025-06-15T14:03:00Z'); // minute=3 → 3%5≠0 ✗ + const at10 = new Date('2025-06-15T14:10:00Z'); // minute=10 → 10%5=0 ✓ + expect(isCronDue('*/5 * * * *', undefined, at0)).toBe(true); + expect(isCronDue('*/5 * * * *', undefined, at3)).toBe(false); + expect(isCronDue('*/5 * * * *', undefined, at10)).toBe(true); + }); + + it('should match comma-separated values', () => { + const at15 = new Date('2025-06-15T14:15:00Z'); + const at45 = new Date('2025-06-15T14:45:00Z'); + const at20 = new Date('2025-06-15T14:20:00Z'); + expect(isCronDue('15,45 * * * *', undefined, at15)).toBe(true); + expect(isCronDue('15,45 * * * *', undefined, at45)).toBe(true); + expect(isCronDue('15,45 * * * *', undefined, at20)).toBe(false); + }); + + it('should not run again within the same minute', () => { + const now = new Date('2025-06-15T14:30:00Z'); + const run: RunRecord = { lastRun: '2025-06-15T14:30:15Z', status: 'success' }; + expect(isCronDue('30 * * * *', run, now)).toBe(false); + }); + + it('should reject invalid cron expressions', () => { + const now = new Date('2025-06-15T14:30:00Z'); + expect(isCronDue('bad cron', undefined, now)).toBe(false); + expect(isCronDue('* *', undefined, now)).toBe(false); + }); +}); + +// ============================================================================ +// Task Execution Tests +// ============================================================================ + +describe('Scheduler: Task Execution', () => { + it('should execute with provider and return result', async () => { + const mockProvider: ScheduleProvider = { + name: 'mock', + execute: vi.fn().mockResolvedValue({ success: true, output: 'done' }), + }; + + const entry = validEntry(); + const result = await executeTask(entry, mockProvider); + + expect(result.success).toBe(true); + expect(result.output).toBe('done'); + expect(mockProvider.execute).toHaveBeenCalledWith(entry); + }); + + it('should retry on failure when retry is configured', async () => { + const mockProvider: ScheduleProvider = { + name: 'mock', + execute: vi + .fn() + .mockResolvedValueOnce({ success: false, error: 'fail 1' }) + .mockResolvedValueOnce({ success: false, error: 'fail 2' }) + .mockResolvedValueOnce({ success: true, output: 'ok' }), + }; + + const entry = validEntry({ + retry: { maxRetries: 2, backoffSeconds: 0.01 }, + }); + const result = await executeTask(entry, mockProvider); + + expect(result.success).toBe(true); + expect(mockProvider.execute).toHaveBeenCalledTimes(3); + }); + + it('should return last failure if all retries exhausted', async () => { + const mockProvider: ScheduleProvider = { + name: 'mock', + execute: vi.fn().mockResolvedValue({ success: false, error: 'always fails' }), + }; + + const entry = validEntry({ + retry: { maxRetries: 1, backoffSeconds: 0.01 }, + }); + const result = await executeTask(entry, mockProvider); + + expect(result.success).toBe(false); + expect(result.error).toBe('always fails'); + expect(mockProvider.execute).toHaveBeenCalledTimes(2); // initial + 1 retry + }); + + it('should handle provider throwing an exception', async () => { + const mockProvider: ScheduleProvider = { + name: 'mock', + execute: vi.fn().mockRejectedValue(new Error('boom')), + }; + + const entry = validEntry(); + const result = await executeTask(entry, mockProvider); + + expect(result.success).toBe(false); + expect(result.error).toBe('boom'); + }); +}); + +// ============================================================================ +// Provider Tests +// ============================================================================ + +describe('Scheduler: LocalPollingProvider', () => { + it('should execute script tasks', async () => { + const provider = new LocalPollingProvider(); + const entry = validEntry({ + task: { type: 'script', ref: 'echo hello-from-scheduler' }, + }); + const result = await provider.execute(entry); + expect(result.success).toBe(true); + expect(result.output).toContain('hello-from-scheduler'); + }); + + it('should return stub output for workflow tasks', async () => { + const provider = new LocalPollingProvider(); + const entry = validEntry({ + task: { type: 'workflow', ref: '.github/workflows/test.yml' }, + }); + const result = await provider.execute(entry); + expect(result.success).toBe(true); + expect(result.output).toContain('Workflow'); + }); + + it('should return stub output for copilot tasks', async () => { + const provider = new LocalPollingProvider(); + const entry = validEntry({ + task: { type: 'copilot', ref: 'review' }, + }); + const result = await provider.execute(entry); + expect(result.success).toBe(true); + expect(result.output).toContain('Copilot'); + }); + + it('should handle script execution failure', async () => { + const provider = new LocalPollingProvider(); + const entry = validEntry({ + task: { type: 'script', ref: 'exit 1' }, + }); + const result = await provider.execute(entry); + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); +}); + +describe('Scheduler: GitHubActionsProvider', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should generate workflow YAML for cron triggers', async () => { + const provider = new GitHubActionsProvider(); + const manifest = validManifest({ + schedules: [ + validEntry({ + id: 'daily-check', + trigger: { type: 'cron', cron: '0 9 * * *' }, + providers: ['github-actions'], + }), + ], + }); + + const files = await provider.generate!(manifest, tmpDir); + expect(files).toHaveLength(1); + expect(files[0]).toContain('squad-schedule-daily-check.yml'); + + const content = fs.readFileSync(files[0]!, 'utf8'); + expect(content).toContain("cron: '0 9 * * *'"); + expect(content).toContain('Squad: Test Task'); + expect(content).toContain('Auto-generated by Squad Scheduler'); + }); + + it('should generate workflow YAML for interval triggers (converted to cron)', async () => { + const provider = new GitHubActionsProvider(); + const manifest = validManifest({ + schedules: [ + validEntry({ + id: 'heartbeat', + trigger: { type: 'interval', intervalSeconds: 300 }, + providers: ['github-actions'], + }), + ], + }); + + const files = await provider.generate!(manifest, tmpDir); + expect(files).toHaveLength(1); + + const content = fs.readFileSync(files[0]!, 'utf8'); + expect(content).toContain('*/5 * * * *'); // 300s = 5 minutes + }); + + it('should skip disabled entries and non-github-actions providers', async () => { + const provider = new GitHubActionsProvider(); + const manifest = validManifest({ + schedules: [ + validEntry({ id: 'disabled', enabled: false, providers: ['github-actions'] }), + validEntry({ id: 'local-only', providers: ['local-polling'] }), + validEntry({ + id: 'event-based', + trigger: { type: 'event', event: 'push' }, + providers: ['github-actions'], + }), + ], + }); + + const files = await provider.generate!(manifest, tmpDir); + expect(files).toHaveLength(0); + }); + + it('should return stub result for execute()', async () => { + const provider = new GitHubActionsProvider(); + const result = await provider.execute(validEntry()); + expect(result.success).toBe(true); + expect(result.output).toContain('GitHub Actions'); + }); +}); + +// ============================================================================ +// State Persistence Tests +// ============================================================================ + +describe('Scheduler: State Persistence', () => { + let tmpDir: string; + let statePath: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-state-')); + statePath = path.join(tmpDir, 'state.json'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should return empty state when file does not exist', async () => { + const state = await loadState(path.join(tmpDir, 'nonexistent.json')); + expect(state).toEqual({ runs: {} }); + }); + + it('should round-trip state through save/load', async () => { + const state: ScheduleState = { + runs: { + 'task-1': { lastRun: '2025-01-15T12:00:00Z', status: 'success' }, + 'task-2': { lastRun: '2025-01-15T11:00:00Z', status: 'failure', error: 'timeout' }, + }, + }; + + await saveState(statePath, state); + const loaded = await loadState(statePath); + + expect(loaded).toEqual(state); + }); +}); + +// ============================================================================ +// parseSchedule (file I/O) Tests +// ============================================================================ + +describe('Scheduler: parseSchedule', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'squad-parse-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should parse a valid schedule.json from disk', async () => { + const filePath = path.join(tmpDir, 'schedule.json'); + fs.writeFileSync(filePath, JSON.stringify(validManifest()), 'utf8'); + + const manifest = await parseSchedule(filePath); + expect(manifest.version).toBe(1); + expect(manifest.schedules).toHaveLength(1); + }); + + it('should throw on missing file', async () => { + await expect(parseSchedule(path.join(tmpDir, 'missing.json'))).rejects.toThrow( + ScheduleValidationError, + ); + }); + + it('should throw on invalid JSON', async () => { + const filePath = path.join(tmpDir, 'bad.json'); + fs.writeFileSync(filePath, 'not json!!!', 'utf8'); + + await expect(parseSchedule(filePath)).rejects.toThrow('Invalid JSON'); + }); +}); + +// ============================================================================ +// Default Template Tests +// ============================================================================ + +describe('Scheduler: Default Template', () => { + it('should produce a valid manifest', () => { + const template = defaultScheduleTemplate(); + expect(() => validateManifest(template)).not.toThrow(); + expect(template.version).toBe(1); + expect(template.schedules).toHaveLength(1); + expect(template.schedules[0]!.id).toBe('ralph-heartbeat'); + }); +}); From f9b3c42406ff6579eae81a4ab7786ec37d9858fa Mon Sep 17 00:00:00 2001 From: Copilot Date: Tue, 17 Mar 2026 17:47:26 +0200 Subject: [PATCH 2/2] docs: add documentation for generic scheduler (#296) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/blog/2026-03-17-generic-scheduler.md | 306 ++++++++++++++++++++ docs/features/generic-scheduler.md | 326 ++++++++++++++++++++++ 2 files changed, 632 insertions(+) create mode 100644 docs/blog/2026-03-17-generic-scheduler.md create mode 100644 docs/features/generic-scheduler.md diff --git a/docs/blog/2026-03-17-generic-scheduler.md b/docs/blog/2026-03-17-generic-scheduler.md new file mode 100644 index 000000000..696bf3ae6 --- /dev/null +++ b/docs/blog/2026-03-17-generic-scheduler.md @@ -0,0 +1,306 @@ +--- +title: "Generic Scheduler: Unified Task Orchestration" +date: 2026-03-17 +author: "Squad (Copilot)" +wave: null +tags: [squad, scheduler, automation, cron, tasks] +status: published +hero: "Stop scattering cron jobs, polling scripts, and manual triggers. Define all recurring squad tasks in one place and let Squad run them locally or on GitHub Actions." +--- + +# Generic Scheduler: Unified Task Orchestration + +> _One config file. Local polling or GitHub Actions. Cron, interval, event-driven, or startup triggers. Squad runs your tasks reliably._ + +## The Problem + +Squads need to run recurring tasks: +- Ralph monitors work every 5 minutes +- Upstream sync runs every 6 hours +- Daily reports generate at 9am +- Deployment notifications trigger on push + +Today, these are scattered across different systems: +- Some use `cron` on a local machine (breaks if machine stops) +- Some use GitHub Actions (hard to run locally, different syntax) +- Some use custom polling scripts (duplicated logic everywhere) +- Some are manual (easy to forget) + +No unified way to orchestrate, monitor, or test. + +## How It Works + +### One Config, Two Runtimes + +Define your schedule once in `.squad/schedule.json`: + +```json +{ + "schedules": [ + { + "id": "ralph-heartbeat", + "trigger": { "type": "interval", "intervalSeconds": 300 }, + "task": { "type": "script", "command": "squad ralph watch --duration 25s" }, + "providers": ["local-polling", "github-actions"] + } + ] +} +``` + +Run it **locally**: +```bash +squad schedule watch +``` + +Or **in GitHub Actions** (auto-generated): +```bash +squad schedule init-ci +``` + +Same config, both runtimes work. + +## Real-World Example + +### A Squad's Daily Routine + +**Morning**: Deploy latest code +```json +{ + "id": "deploy-staging", + "trigger": { "type": "cron", "expression": "0 8 * * MON-FRI" }, + "task": { "type": "workflow", "ref": ".github/workflows/deploy-staging.yml" }, + "providers": ["github-actions"] +} +``` + +**Every hour**: Check upstream for updates +```json +{ + "id": "sync-upstream", + "trigger": { "type": "cron", "expression": "0 * * * *" }, + "task": { "type": "script", "command": "squad upstream sync" }, + "providers": ["github-actions"] +} +``` + +**Every 5 minutes**: Monitor work queue (local only) +```json +{ + "id": "ralph-heartbeat", + "trigger": { "type": "interval", "intervalSeconds": 300 }, + "task": { "type": "script", "command": "squad ralph watch --duration 25s" }, + "providers": ["local-polling"] +} +``` + +**At startup**: Initialize metrics +```json +{ + "id": "init-metrics", + "trigger": { "type": "startup" }, + "task": { "type": "script", "command": "mkdir -p .squad/metrics" }, + "providers": ["local-polling"] +} +``` + +### Workflow + +**Morning at 8am**: +- GitHub Actions triggers `deploy-staging` +- Deployment completes, logs available in GitHub UI + +**Throughout the day**: +- Every hour, `sync-upstream` runs to check for parent changes +- Every 5 minutes (if you're running `squad schedule watch`), Ralph checks work + +**Failure scenario**: +- A sync attempt fails (network issue) +- Scheduler waits 30 seconds (backoff) +- Retries automatically +- Logs to `.squad/.schedule-state.json` + +## Trigger Types + +### Interval +Run every N seconds: +```json +{ "type": "interval", "intervalSeconds": 300 } // Every 5 minutes +``` + +### Cron +Standard cron syntax: +```json +{ "type": "cron", "expression": "0 8 * * MON-FRI" } // 8am Mon-Fri +``` + +Common patterns: +- `"0 * * * *"` — Every hour +- `"0 0 * * *"` — Daily at midnight +- `"0 */6 * * *"` — Every 6 hours +- `"*/5 * * * *"` — Every 5 minutes (GitHub Actions only) + +### Event +Run when something happens: +```json +{ "type": "event", "eventName": "session:complete" } +``` + +Built-in events: +- `session:start` — Squad initializes +- `session:complete` — Squad finishes +- `agent:milestone` — Agent reaches checkpoint +- `error:uncaught` — Error occurs + +### Startup +Run once when squad initializes: +```json +{ "type": "startup" } +``` + +## Task Types + +### Script +Run a shell command: +```json +{ "type": "script", "command": "squad upstream sync --auto-pr" } +``` + +### Workflow +Trigger a GitHub Actions workflow: +```json +{ "type": "workflow", "ref": ".github/workflows/daily-report.yml", "inputs": { "format": "markdown" } } +``` + +### Copilot +Run a Copilot agent task: +```json +{ "type": "copilot", "agent": "ralph", "prompt": "summarize work status" } +``` + +### Webhook +POST to a URL: +```json +{ "type": "webhook", "url": "https://api.example.com/hooks/squad-task", "method": "POST" } +``` + +## Providers + +### Local Polling +Run in-process when you run `squad schedule watch`: +```json +{ "providers": ["local-polling"] } +``` + +Best for development and testing. Runs until you stop it. + +### GitHub Actions +Automatically generate GitHub Actions workflows: +```json +{ "providers": ["github-actions"] } +``` + +Best for production. Runs 24/7 on GitHub's infrastructure. Squad generates `.github/workflows/schedule-{id}.yml` files. + +## Configuration Reference + +```json +{ + "schedules": [ + { + "id": "unique-identifier", + "name": "Human-readable name", + "description": "What this task does", + "enabled": true, + "trigger": { + "type": "interval|cron|event|startup", + ... + }, + "task": { + "type": "script|workflow|copilot|webhook", + ... + }, + "providers": ["local-polling", "github-actions"], + "retry": { + "maxRetries": 2, + "backoffSeconds": 5 + }, + "tags": ["monitoring", "core"], + "timeout": 300 + } + ] +} +``` + +## Monitoring + +Check schedule status anytime: +```bash +squad schedule status +``` + +Output shows: +- Last run time and result +- Next scheduled run time +- Total runs and failure count +- Current status (running, waiting, error) + +View detailed logs: +```bash +squad schedule logs ralph-heartbeat +``` + +## Error Handling + +Schedules include configurable retry: + +```json +{ + "retry": { + "maxRetries": 2, + "backoffSeconds": 5 + } +} +``` + +On failure: +1. Wait 5 seconds (backoff) +2. Retry attempt 1 +3. Wait 10 seconds (exponential backoff) +4. Retry attempt 2 +5. If still failing, log error and continue + +## Use Cases + +### Monitoring +```bash +squad schedule init ralph-monitor --trigger interval:300 +``` + +Ralph checks work every 5 minutes. + +### CI/CD Integration +```bash +squad schedule init deploy --trigger "cron:0 8 * * *" --task workflow:.github/workflows/deploy.yml +``` + +Deploy every morning at 8am. + +### Syncing +```bash +squad schedule init sync-upstream --trigger "cron:0 * * * *" --providers github-actions +``` + +Sync from parent every hour (GitHub Actions only). + +### Notifications +```bash +squad schedule init slack-report --trigger "cron:0 9 * * MON-FRI" --task webhook:https://hooks.slack.com/... +``` + +Send daily standup to Slack on weekday mornings. + +## See Also + +- [Persistent Ralph](/features/persistent-ralph) — Monitor agent activity +- [Upstream Auto-Sync](/features/upstream-sync) — Sync squads on schedule +- [Cross-Squad Orchestration](/features/cross-squad-orchestration) — Delegate recurring work diff --git a/docs/features/generic-scheduler.md b/docs/features/generic-scheduler.md new file mode 100644 index 000000000..f7095462e --- /dev/null +++ b/docs/features/generic-scheduler.md @@ -0,0 +1,326 @@ +# Generic Scheduler + +**Try this to see all scheduled tasks:** +``` +squad schedule list +``` + +**Try this to check next run times:** +``` +squad schedule status +``` + +**Try this to trigger a task manually:** +``` +squad schedule run ralph-heartbeat +``` + +The generic scheduler unifies cron jobs, polling loops, and manual triggers. Define all recurring squad tasks in one place and let Squad orchestrate them. + +--- + +## What the Generic Scheduler Does + +The generic scheduler gives squads a **unified way to schedule and run recurring tasks**: + +1. **Define** tasks in `.squad/schedule.json` (cron, interval, event-driven, startup) +2. **Trigger** locally via `squad schedule run` or automatically via polling +3. **Monitor** with `squad schedule status` to see run history and next run times +4. **Recover** from failures with configurable retry and backoff +5. **Scale** to GitHub Actions for headless/CI environments + +No more scattered cron jobs, polling scripts, or manual triggers. + +## Quick Start + +### Initialize a Schedule + +Create a default `.squad/schedule.json`: +```bash +squad schedule init +``` + +This creates: +```json +{ + "schedules": [ + { + "id": "ralph-heartbeat", + "name": "Ralph Work Monitor", + "enabled": true, + "trigger": { "type": "interval", "intervalSeconds": 300 }, + "task": { "type": "script", "command": "squad ralph watch --duration 30s" }, + "providers": ["local-polling"], + "retry": { "maxRetries": 1, "backoffSeconds": 5 } + } + ] +} +``` + +### List All Schedules + +```bash +squad schedule list +``` + +Output: +``` +ID Name Trigger Status +ralph-heartbeat Ralph Work Monitor Every 5min Enabled +upstream-sync Upstream Sync Every 6h Enabled +ci-daily-report Daily CI Report Mon-Fri 8am Enabled +``` + +### Check Status + +```bash +squad schedule status +``` + +Output: +``` +ralph-heartbeat + Last Run: 2026-03-17 14:25:30 (Success) + Next Run: 2026-03-17 14:30:30 (in 4min) + +upstream-sync + Last Run: 2026-03-17 12:00:00 (Success) + Next Run: 2026-03-17 18:00:00 (in 4h) +``` + +### Run Manually + +Trigger a task immediately: +```bash +squad schedule run ralph-heartbeat +``` + +## Schedule Configuration + +The schedule manifest lives in `.squad/schedule.json`: + +```json +{ + "schedules": [ + { + "id": "unique-id", + "name": "Human-readable name", + "description": "What this task does", + "enabled": true, + "trigger": { ... }, + "task": { ... }, + "providers": ["local-polling"], + "retry": { "maxRetries": 2, "backoffSeconds": 5 }, + "tags": ["monitoring", "core"] + } + ] +} +``` + +### Trigger Types + +#### Interval +Run every N seconds: +```json +{ "type": "interval", "intervalSeconds": 300 } +``` + +#### Cron +Use standard cron syntax: +```json +{ "type": "cron", "expression": "0 8 * * MON-FRI" } +``` + +#### Event +Run when something happens: +```json +{ "type": "event", "eventName": "session:complete" } +``` + +#### Startup +Run once when squad initializes: +```json +{ "type": "startup" } +``` + +### Task Types + +#### Script +Run a shell command: +```json +{ "type": "script", "command": "squad upstream sync" } +``` + +#### Workflow +Trigger a GitHub Actions workflow: +```json +{ "type": "workflow", "ref": ".github/workflows/daily-report.yml" } +``` + +#### Copilot +Run a Copilot agent task: +```json +{ "type": "copilot", "agent": "ralph", "prompt": "check work queue" } +``` + +#### Webhook +POST to a URL: +```json +{ "type": "webhook", "url": "https://example.com/hooks/squad-task" } +``` + +### Providers + +#### Local Polling +Run in-process when `squad` is running: +```json +{ "providers": ["local-polling"] } +``` + +Use this for development and testing. Runs until you stop the squad CLI. + +#### GitHub Actions +Automatically generate and trigger GitHub Actions workflows: +```json +{ "providers": ["github-actions"] } +``` + +Use this for production. Squad generates `.github/workflows/schedule-{id}.yml` and GitHub runs it on schedule. + +## Real-World Example + +### Multi-Task Schedule + +```json +{ + "schedules": [ + { + "id": "ralph-monitor", + "name": "Ralph Work Monitor", + "trigger": { "type": "interval", "intervalSeconds": 300 }, + "task": { "type": "script", "command": "squad ralph watch --duration 25s" }, + "providers": ["local-polling", "github-actions"], + "retry": { "maxRetries": 2, "backoffSeconds": 10 } + }, + { + "id": "upstream-sync", + "name": "Upstream Sync", + "trigger": { "type": "cron", "expression": "0 */6 * * *" }, + "task": { "type": "script", "command": "squad upstream sync --auto-pr" }, + "providers": ["github-actions"], + "retry": { "maxRetries": 1, "backoffSeconds": 30 } + }, + { + "id": "daily-report", + "name": "Daily Status Report", + "trigger": { "type": "cron", "expression": "0 9 * * MON-FRI" }, + "task": { "type": "workflow", "ref": ".github/workflows/daily-report.yml" }, + "providers": ["github-actions"] + } + ] +} +``` + +This schedule: +- Monitors work every 5 minutes (local + GHA) +- Syncs upstreams every 6 hours (GHA only, no local polling) +- Generates a daily report on weekday mornings (GHA only) + +## Local vs. GitHub Actions + +### Local Polling (`squad schedule`) + +Run while working locally: +```bash +cd my-squad +squad schedule watch +``` + +The scheduler runs in your terminal until you exit. Tasks execute in-process. Perfect for development. + +### GitHub Actions + +For production and headless environments, Squad generates workflows: + +```bash +squad schedule init-ci +``` + +This creates `.github/workflows/squad-schedule-*.yml` files. GitHub Actions runs them on your schedule. + +Advantages: +- Runs 24/7 (no local machine needed) +- Logs available in GitHub UI +- Built-in error notifications +- No setup beyond git push + +## State & History + +Squad maintains schedule state in `.squad/.schedule-state.json`: + +```json +{ + "ralph-monitor": { + "lastRun": "2026-03-17T14:25:30Z", + "lastStatus": "success", + "lastOutput": "10 issues found, 3 stale", + "nextRun": "2026-03-17T14:30:30Z", + "runCount": 1847, + "failCount": 3 + } +} +``` + +This is auto-managed. You can inspect it but shouldn't edit it directly. + +## Use Cases + +### Monitoring Loop +Monitor work queue every 5 minutes: +```bash +squad schedule init ralph-monitor +``` + +### Hourly Syncs +Keep multiple squads synchronized: +```bash +squad upstream sync --interval hourly +``` + +### Daily Reports +Generate daily team status reports: +```bash +squad schedule run daily-report +``` + +### Event Hooks +Trigger on session complete: +```bash +squad schedule new downstream-notify --event session:complete +``` + +## Error Handling + +Schedules include retry logic: + +```json +{ + "retry": { + "maxRetries": 2, + "backoffSeconds": 5 + } +} +``` + +If a task fails: +1. Wait 5 seconds +2. Retry (first attempt) +3. Wait 10 seconds (exponential backoff) +4. Retry (second attempt) +5. If still failing, log error and skip until next trigger + +## See Also + +- [Upstream Auto-Sync](/features/upstream-sync) — Use scheduler to sync automatically +- [Persistent Ralph](/features/persistent-ralph) — Monitor squad work continuously +- [Cross-Squad Orchestration](/features/cross-squad-orchestration) — Delegate across squads