diff --git a/README.md b/README.md index 0b4e7cef..8d068499 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ For local development setup, see the [Development Guide](https://chriswritescode - **Git** — Multi-repo support, SSH authentication, worktrees, unified diffs with line numbers, PR creation - **Files** — Directory browser with tree view, syntax highlighting, create/rename/delete, ZIP download - **Chat** — Real-time streaming (SSE), slash commands, `@file` mentions, Plan/Build modes, Mermaid diagrams +- **Schedules** — Recurring repo jobs with reusable prompts, run history, linked sessions, and markdown-rendered output - **Audio** — Text-to-speech (browser + OpenAI-compatible), speech-to-text (browser + OpenAI-compatible) - **AI** — Model selection, provider config, OAuth for Anthropic/GitHub Copilot, custom agents with system prompts - **MCP** — Local and remote MCP server support with pre-built templates diff --git a/backend/package.json b/backend/package.json index a2205544..e34bfe8d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,6 +20,7 @@ "@opencode-manager/shared": "workspace:*", "archiver": "^7.0.1", "better-auth": "^1.4.17", + "cron-parser": "^5.5.0", "dotenv": "^17.2.3", "eventsource": "^4.1.0", "hono": "^4.11.7", @@ -34,6 +35,7 @@ "@types/bun": "latest", "@types/eventsource": "^3.0.0", "@types/web-push": "^3.6.4", + "@vitest/coverage-v8": "^3.2.4", "@vitest/ui": "^3.2.4", "eslint": "^9.39.1", "typescript-eslint": "^8.45.0", diff --git a/backend/src/db/migrations/007-schedules.ts b/backend/src/db/migrations/007-schedules.ts new file mode 100644 index 00000000..e0f79591 --- /dev/null +++ b/backend/src/db/migrations/007-schedules.ts @@ -0,0 +1,58 @@ +import type { Migration } from '../migration-runner' + +const migration: Migration = { + version: 7, + name: 'schedules', + + up(db) { + db.run(` + CREATE TABLE IF NOT EXISTS schedule_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + interval_minutes INTEGER, + agent_slug TEXT, + prompt TEXT NOT NULL, + model TEXT, + skill_metadata TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_run_at INTEGER, + next_run_at INTEGER + ) + `) + + db.run('CREATE INDEX IF NOT EXISTS idx_schedule_jobs_repo ON schedule_jobs(repo_id)') + db.run('CREATE INDEX IF NOT EXISTS idx_schedule_jobs_next_run ON schedule_jobs(enabled, next_run_at)') + + db.run(` + CREATE TABLE IF NOT EXISTS schedule_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER NOT NULL REFERENCES schedule_jobs(id) ON DELETE CASCADE, + repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, + trigger_source TEXT NOT NULL, + status TEXT NOT NULL, + started_at INTEGER NOT NULL, + finished_at INTEGER, + created_at INTEGER NOT NULL, + session_id TEXT, + session_title TEXT, + log_text TEXT, + response_text TEXT, + error_text TEXT + ) + `) + + db.run('CREATE INDEX IF NOT EXISTS idx_schedule_runs_job ON schedule_runs(job_id, started_at DESC)') + db.run('CREATE INDEX IF NOT EXISTS idx_schedule_runs_repo ON schedule_runs(repo_id, started_at DESC)') + }, + + down(db) { + db.run('DROP TABLE IF EXISTS schedule_runs') + db.run('DROP TABLE IF EXISTS schedule_jobs') + }, +} + +export default migration diff --git a/backend/src/db/migrations/008-schedule-cron-support.ts b/backend/src/db/migrations/008-schedule-cron-support.ts new file mode 100644 index 00000000..948d3246 --- /dev/null +++ b/backend/src/db/migrations/008-schedule-cron-support.ts @@ -0,0 +1,129 @@ +import type { Migration } from '../migration-runner' + +interface ColumnInfo { + name: string + notnull: number + dflt_value: string | null +} + +const migration: Migration = { + version: 8, + name: 'schedule-cron-support', + + up(db) { + const tableInfo = db.prepare('PRAGMA table_info(schedule_jobs)').all() as ColumnInfo[] + const existingColumns = new Set(tableInfo.map((column) => column.name)) + const intervalMinutesColumn = tableInfo.find((column) => column.name === 'interval_minutes') + const scheduleModeColumn = tableInfo.find((column) => column.name === 'schedule_mode') + const hasCronColumns = existingColumns.has('schedule_mode') && existingColumns.has('cron_expression') && existingColumns.has('timezone') + const scheduleModeDefault = scheduleModeColumn?.dflt_value?.replaceAll("'", '') + + if (intervalMinutesColumn?.notnull === 0 && hasCronColumns && scheduleModeDefault === 'interval') { + return + } + + db.run(` + CREATE TABLE schedule_jobs_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + interval_minutes INTEGER, + schedule_mode TEXT NOT NULL DEFAULT 'interval', + cron_expression TEXT, + timezone TEXT, + agent_slug TEXT, + prompt TEXT NOT NULL, + model TEXT, + skill_metadata TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_run_at INTEGER, + next_run_at INTEGER + ) + `) + + db.run(` + INSERT INTO schedule_jobs_new ( + id, repo_id, name, description, enabled, interval_minutes, schedule_mode, cron_expression, timezone, + agent_slug, prompt, model, skill_metadata, created_at, updated_at, last_run_at, next_run_at + ) + SELECT + id, + repo_id, + name, + description, + enabled, + interval_minutes, + ${existingColumns.has('schedule_mode') ? "COALESCE(schedule_mode, 'interval')" : "'interval'"}, + ${existingColumns.has('cron_expression') ? 'cron_expression' : 'NULL'}, + ${existingColumns.has('timezone') ? 'timezone' : 'NULL'}, + agent_slug, + prompt, + model, + skill_metadata, + created_at, + updated_at, + last_run_at, + next_run_at + FROM schedule_jobs + `) + + db.run('DROP TABLE schedule_jobs') + db.run('ALTER TABLE schedule_jobs_new RENAME TO schedule_jobs') + db.run('CREATE INDEX IF NOT EXISTS idx_schedule_jobs_repo ON schedule_jobs(repo_id)') + db.run('CREATE INDEX IF NOT EXISTS idx_schedule_jobs_next_run ON schedule_jobs(enabled, next_run_at)') + }, + + down(db) { + db.run(` + CREATE TABLE schedule_jobs_old ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + interval_minutes INTEGER NOT NULL, + agent_slug TEXT, + prompt TEXT NOT NULL, + model TEXT, + skill_metadata TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_run_at INTEGER, + next_run_at INTEGER + ) + `) + + db.run(` + INSERT INTO schedule_jobs_old ( + id, repo_id, name, description, enabled, interval_minutes, agent_slug, prompt, model, skill_metadata, + created_at, updated_at, last_run_at, next_run_at + ) + SELECT + id, + repo_id, + name, + description, + enabled, + COALESCE(interval_minutes, 60), + agent_slug, + prompt, + model, + skill_metadata, + created_at, + updated_at, + last_run_at, + next_run_at + FROM schedule_jobs + `) + + db.run('DROP TABLE schedule_jobs') + db.run('ALTER TABLE schedule_jobs_old RENAME TO schedule_jobs') + db.run('CREATE INDEX IF NOT EXISTS idx_schedule_jobs_repo ON schedule_jobs(repo_id)') + db.run('CREATE INDEX IF NOT EXISTS idx_schedule_jobs_next_run ON schedule_jobs(enabled, next_run_at)') + }, +} + +export default migration diff --git a/backend/src/db/migrations/index.ts b/backend/src/db/migrations/index.ts index 9a163eb8..4ec5b974 100644 --- a/backend/src/db/migrations/index.ts +++ b/backend/src/db/migrations/index.ts @@ -5,6 +5,8 @@ import migration003 from './003-repos-add-columns' import migration004 from './004-repos-indexes' import migration005 from './005-repos-local-path-prefix' import migration006 from './006-git-token-to-credentials' +import migration007 from './007-schedules' +import migration008 from './008-schedule-cron-support' export const allMigrations: Migration[] = [ migration001, @@ -13,4 +15,6 @@ export const allMigrations: Migration[] = [ migration004, migration005, migration006, + migration007, + migration008, ] diff --git a/backend/src/db/schedules.ts b/backend/src/db/schedules.ts new file mode 100644 index 00000000..0a276171 --- /dev/null +++ b/backend/src/db/schedules.ts @@ -0,0 +1,380 @@ +import type { Database } from 'bun:sqlite' +import { + ScheduleJobSchema, + ScheduleRunSchema, + ScheduleSkillMetadataSchema, + type ScheduleJob, + type ScheduleMode, + type ScheduleRun, + type ScheduleRunStatus, + type ScheduleRunTriggerSource, +} from '@opencode-manager/shared/schemas' +import type { ScheduleJobPersistenceInput } from '../services/schedule-config' + +interface ScheduleJobRow { + id: number + repo_id: number + name: string + description: string | null + enabled: number + schedule_mode: ScheduleMode | null + interval_minutes: number | null + cron_expression: string | null + timezone: string | null + agent_slug: string | null + prompt: string + model: string | null + skill_metadata: string | null + created_at: number + updated_at: number + last_run_at: number | null + next_run_at: number | null +} + +interface ScheduleRunRow { + id: number + job_id: number + repo_id: number + trigger_source: string + status: string + started_at: number + finished_at: number | null + created_at: number + session_id: string | null + session_title: string | null + log_text: string | null + response_text: string | null + error_text: string | null +} + +function parseSkillMetadata(raw: string | null) { + if (!raw) { + return null + } + + try { + const parsed = JSON.parse(raw) + const result = ScheduleSkillMetadataSchema.safeParse(parsed) + return result.success ? result.data : null + } catch { + return null + } +} + +function rowToScheduleJob(row: ScheduleJobRow): ScheduleJob { + return ScheduleJobSchema.parse({ + id: row.id, + repoId: row.repo_id, + name: row.name, + description: row.description, + enabled: Boolean(row.enabled), + scheduleMode: row.schedule_mode ?? 'interval', + intervalMinutes: row.interval_minutes, + cronExpression: row.cron_expression, + timezone: row.timezone, + agentSlug: row.agent_slug, + prompt: row.prompt, + model: row.model, + skillMetadata: parseSkillMetadata(row.skill_metadata), + createdAt: row.created_at, + updatedAt: row.updated_at, + lastRunAt: row.last_run_at, + nextRunAt: row.next_run_at, + }) +} + +function rowToScheduleRun(row: ScheduleRunRow): ScheduleRun { + return ScheduleRunSchema.parse({ + id: row.id, + jobId: row.job_id, + repoId: row.repo_id, + triggerSource: row.trigger_source, + status: row.status, + startedAt: row.started_at, + finishedAt: row.finished_at, + createdAt: row.created_at, + sessionId: row.session_id, + sessionTitle: row.session_title, + logText: row.log_text, + responseText: row.response_text, + errorText: row.error_text, + }) +} + +function serializeSkillMetadata(skillMetadata: ScheduleJobPersistenceInput['skillMetadata']): string | null { + if (!skillMetadata) { + return null + } + + return JSON.stringify(skillMetadata) +} + +export function listScheduleJobsByRepo(db: Database, repoId: number): ScheduleJob[] { + const stmt = db.prepare('SELECT * FROM schedule_jobs WHERE repo_id = ? ORDER BY created_at DESC') + const rows = stmt.all(repoId) as ScheduleJobRow[] + return rows.map(rowToScheduleJob) +} + +export function listDueScheduleJobs(db: Database, now: number, limit: number = 20): ScheduleJob[] { + const stmt = db.prepare(` + SELECT * FROM schedule_jobs + WHERE enabled = 1 AND next_run_at IS NOT NULL AND next_run_at <= ? + ORDER BY next_run_at ASC + LIMIT ? + `) + const rows = stmt.all(now, limit) as ScheduleJobRow[] + return rows.map(rowToScheduleJob) +} + +export function getScheduleJobById(db: Database, repoId: number, jobId: number): ScheduleJob | null { + const stmt = db.prepare('SELECT * FROM schedule_jobs WHERE repo_id = ? AND id = ?') + const row = stmt.get(repoId, jobId) as ScheduleJobRow | undefined + return row ? rowToScheduleJob(row) : null +} + +export function createScheduleJob(db: Database, repoId: number, input: ScheduleJobPersistenceInput): ScheduleJob { + const now = Date.now() + const stmt = db.prepare(` + INSERT INTO schedule_jobs ( + repo_id, name, description, enabled, schedule_mode, interval_minutes, cron_expression, timezone, agent_slug, prompt, model, skill_metadata, + created_at, updated_at, last_run_at, next_run_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + + const result = stmt.run( + repoId, + input.name, + input.description ?? null, + input.enabled ? 1 : 0, + input.scheduleMode, + input.intervalMinutes, + input.cronExpression, + input.timezone, + input.agentSlug ?? null, + input.prompt, + input.model ?? null, + serializeSkillMetadata(input.skillMetadata), + now, + now, + null, + input.nextRunAt, + ) + + const job = getScheduleJobById(db, repoId, Number(result.lastInsertRowid)) + if (!job) { + throw new Error('Failed to load created schedule job') + } + return job +} + +export function updateScheduleJob(db: Database, repoId: number, jobId: number, input: ScheduleJobPersistenceInput): ScheduleJob | null { + const existing = getScheduleJobById(db, repoId, jobId) + if (!existing) { + return null + } + + const now = Date.now() + + const stmt = db.prepare(` + UPDATE schedule_jobs + SET name = ?, description = ?, enabled = ?, schedule_mode = ?, interval_minutes = ?, cron_expression = ?, timezone = ?, agent_slug = ?, prompt = ?, model = ?, skill_metadata = ?, updated_at = ?, next_run_at = ? + WHERE repo_id = ? AND id = ? + `) + + stmt.run( + input.name, + input.description, + input.enabled ? 1 : 0, + input.scheduleMode, + input.intervalMinutes, + input.cronExpression, + input.timezone, + input.agentSlug, + input.prompt, + input.model, + serializeSkillMetadata(input.skillMetadata), + now, + input.nextRunAt, + repoId, + jobId, + ) + + return getScheduleJobById(db, repoId, jobId) +} + +export function deleteScheduleJob(db: Database, repoId: number, jobId: number): boolean { + const stmt = db.prepare('DELETE FROM schedule_jobs WHERE repo_id = ? AND id = ?') + const result = stmt.run(repoId, jobId) + return result.changes > 0 +} + +export function reserveScheduleJobNextRun(db: Database, repoId: number, jobId: number, nextRunAt: number): void { + const stmt = db.prepare('UPDATE schedule_jobs SET next_run_at = ?, updated_at = ? WHERE repo_id = ? AND id = ?') + stmt.run(nextRunAt, Date.now(), repoId, jobId) +} + +export function updateScheduleJobRunState(db: Database, repoId: number, jobId: number, values: { lastRunAt: number; nextRunAt?: number | null }): void { + const stmt = db.prepare('UPDATE schedule_jobs SET last_run_at = ?, next_run_at = ?, updated_at = ? WHERE repo_id = ? AND id = ?') + stmt.run(values.lastRunAt, values.nextRunAt ?? null, Date.now(), repoId, jobId) +} + +export function createScheduleRun( + db: Database, + input: { + jobId: number + repoId: number + triggerSource: ScheduleRunTriggerSource + status: ScheduleRunStatus + startedAt: number + createdAt: number + }, +): ScheduleRun { + const stmt = db.prepare(` + INSERT INTO schedule_runs (job_id, repo_id, trigger_source, status, started_at, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `) + + const result = stmt.run( + input.jobId, + input.repoId, + input.triggerSource, + input.status, + input.startedAt, + input.createdAt, + ) + + const run = getScheduleRunById(db, input.repoId, input.jobId, Number(result.lastInsertRowid)) + if (!run) { + throw new Error('Failed to load created schedule run') + } + return run +} + +export function updateScheduleRun( + db: Database, + repoId: number, + jobId: number, + runId: number, + input: { + status: ScheduleRunStatus + finishedAt: number + sessionId?: string | null + sessionTitle?: string | null + logText?: string | null + responseText?: string | null + errorText?: string | null + }, +): ScheduleRun | null { + const stmt = db.prepare(` + UPDATE schedule_runs + SET status = ?, finished_at = ?, session_id = ?, session_title = ?, log_text = ?, response_text = ?, error_text = ? + WHERE repo_id = ? AND job_id = ? AND id = ? + `) + + stmt.run( + input.status, + input.finishedAt, + input.sessionId ?? null, + input.sessionTitle ?? null, + input.logText ?? null, + input.responseText ?? null, + input.errorText ?? null, + repoId, + jobId, + runId, + ) + + return getScheduleRunById(db, repoId, jobId, runId) +} + +export function updateScheduleRunMetadata( + db: Database, + repoId: number, + jobId: number, + runId: number, + input: { + sessionId?: string | null + sessionTitle?: string | null + logText?: string | null + responseText?: string | null + errorText?: string | null + }, +): ScheduleRun | null { + const existing = getScheduleRunById(db, repoId, jobId, runId) + if (!existing) { + return null + } + + const stmt = db.prepare(` + UPDATE schedule_runs + SET session_id = ?, session_title = ?, log_text = ?, response_text = ?, error_text = ? + WHERE repo_id = ? AND job_id = ? AND id = ? + `) + + stmt.run( + input.sessionId === undefined ? existing.sessionId : input.sessionId, + input.sessionTitle === undefined ? existing.sessionTitle : input.sessionTitle, + input.logText === undefined ? existing.logText : input.logText, + input.responseText === undefined ? existing.responseText : input.responseText, + input.errorText === undefined ? existing.errorText : input.errorText, + repoId, + jobId, + runId, + ) + + return getScheduleRunById(db, repoId, jobId, runId) +} + +export function getScheduleRunById(db: Database, repoId: number, jobId: number, runId: number): ScheduleRun | null { + const stmt = db.prepare('SELECT * FROM schedule_runs WHERE repo_id = ? AND job_id = ? AND id = ?') + const row = stmt.get(repoId, jobId, runId) as ScheduleRunRow | undefined + return row ? rowToScheduleRun(row) : null +} + +export function getRunningScheduleRunByJob(db: Database, repoId: number, jobId: number): ScheduleRun | null { + const stmt = db.prepare(` + SELECT * FROM schedule_runs + WHERE repo_id = ? AND job_id = ? AND status = 'running' + ORDER BY started_at DESC + LIMIT 1 + `) + const row = stmt.get(repoId, jobId) as ScheduleRunRow | undefined + return row ? rowToScheduleRun(row) : null +} + +export function listRunningScheduleRuns(db: Database, limit: number = 100): ScheduleRun[] { + const stmt = db.prepare(` + SELECT * FROM schedule_runs + WHERE status = 'running' + ORDER BY started_at ASC + LIMIT ? + `) + const rows = stmt.all(limit) as ScheduleRunRow[] + return rows.map(rowToScheduleRun) +} + +export function listScheduleRunsByJob(db: Database, repoId: number, jobId: number, limit: number = 20): ScheduleRun[] { + const stmt = db.prepare(` + SELECT + id, + job_id, + repo_id, + trigger_source, + status, + started_at, + finished_at, + created_at, + session_id, + session_title, + NULL AS log_text, + NULL AS response_text, + error_text + FROM schedule_runs + WHERE repo_id = ? AND job_id = ? + ORDER BY started_at DESC + LIMIT ? + `) + const rows = stmt.all(repoId, jobId, limit) as ScheduleRunRow[] + return rows.map(rowToScheduleRun) +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 4fff13ac..b2223fa9 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -42,6 +42,7 @@ import { SettingsService } from './services/settings' import { opencodeServerManager } from './services/opencode-single-server' import { proxyRequest, proxyMcpAuthStart, proxyMcpAuthAuthenticate } from './services/proxy' import { NotificationService } from './services/notification' +import { ScheduleRunner, ScheduleService } from './services/schedules' import { logger } from './utils/logger' import { @@ -210,6 +211,9 @@ try { await opencodeServerManager.start() logger.info(`OpenCode server running on port ${opencodeServerManager.getPort()}`) + const scheduleRunner = new ScheduleRunner(new ScheduleService(db)) + scheduleRunner.start() + await syncAdminFromEnv(auth, db) } catch (error) { logger.error('Failed to initialize workspace:', error) @@ -238,13 +242,13 @@ if (ENV.VAPID.PUBLIC_KEY && ENV.VAPID.PRIVATE_KEY) { app.route('/api/auth', createAuthRoutes(auth)) app.route('/api/auth-info', createAuthInfoRoutes(auth, db)) +app.route('/api/health', createHealthRoutes(db)) app.route('/api/mcp-oauth-proxy', createMcpOauthProxyRoutes(requireAuth)) const protectedApi = new Hono() protectedApi.use('/*', requireAuth) -protectedApi.route('/health', createHealthRoutes(db)) protectedApi.route('/repos', createRepoRoutes(db, gitAuthService)) protectedApi.route('/settings', createSettingsRoutes(db)) protectedApi.route('/files', createFileRoutes()) diff --git a/backend/src/routes/repos.ts b/backend/src/routes/repos.ts index 5d92f97b..aff7a8ae 100644 --- a/backend/src/routes/repos.ts +++ b/backend/src/routes/repos.ts @@ -12,6 +12,7 @@ import { logger } from '../utils/logger' import { getErrorMessage, getStatusCode } from '../utils/error-utils' import { getOpenCodeConfigFilePath, getReposPath } from '@opencode-manager/shared/config/env' import { createRepoGitRoutes } from './repo-git' +import { createScheduleRoutes } from './schedules' import type { GitAuthService } from '../services/git-auth' import path from 'path' @@ -19,6 +20,7 @@ export function createRepoRoutes(database: Database, gitAuthService: GitAuthServ const app = new Hono() app.route('/', createRepoGitRoutes(database, gitAuthService)) + app.route('/:id/schedules', createScheduleRoutes(database)) app.post('/', async (c) => { try { diff --git a/backend/src/routes/schedules.ts b/backend/src/routes/schedules.ts new file mode 100644 index 00000000..d7adf4fb --- /dev/null +++ b/backend/src/routes/schedules.ts @@ -0,0 +1,154 @@ +import { Hono, type Context } from 'hono' +import type { Database } from 'bun:sqlite' +import { + CreateScheduleJobRequestSchema, + UpdateScheduleJobRequestSchema, +} from '@opencode-manager/shared/schemas' +import { ScheduleService, ScheduleServiceError } from '../services/schedules' +import { getErrorMessage } from '../utils/error-utils' +import { logger } from '../utils/logger' + +function parseId(value: string | undefined, label: string): number { + if (!value) { + throw new ScheduleServiceError(`Missing ${label}`, 400) + } + + const parsed = Number.parseInt(value, 10) + if (Number.isNaN(parsed)) { + throw new ScheduleServiceError(`Invalid ${label}`, 400) + } + return parsed +} + +function parseRunListLimit(value: string | undefined): number { + if (value === undefined) { + return 20 + } + + const parsed = parseId(value, 'limit') + if (parsed < 1) { + throw new ScheduleServiceError('Limit must be greater than 0', 400) + } + + return Math.min(parsed, 100) +} + +export function createScheduleRoutes(database: Database) { + const app = new Hono() + const scheduleService = new ScheduleService(database) + + app.get('/', (c) => { + try { + const repoId = parseId(c.req.param('id'), 'repo id') + return c.json({ jobs: scheduleService.listJobs(repoId) }) + } catch (error) { + return handleScheduleError(c, error, 'Failed to list schedules') + } + }) + + app.post('/', async (c) => { + try { + const repoId = parseId(c.req.param('id'), 'repo id') + const body = await c.req.json() + const input = CreateScheduleJobRequestSchema.parse(body) + const job = scheduleService.createJob(repoId, input) + return c.json({ job }, 201) + } catch (error) { + return handleScheduleError(c, error, 'Failed to create schedule') + } + }) + + app.get('/:jobId', (c) => { + try { + const repoId = parseId(c.req.param('id'), 'repo id') + const jobId = parseId(c.req.param('jobId'), 'schedule id') + const job = scheduleService.getJob(repoId, jobId) + if (!job) { + return c.json({ error: 'Schedule not found' }, 404) + } + return c.json({ job }) + } catch (error) { + return handleScheduleError(c, error, 'Failed to get schedule') + } + }) + + app.patch('/:jobId', async (c) => { + try { + const repoId = parseId(c.req.param('id'), 'repo id') + const jobId = parseId(c.req.param('jobId'), 'schedule id') + const body = await c.req.json() + const input = UpdateScheduleJobRequestSchema.parse(body) + const job = scheduleService.updateJob(repoId, jobId, input) + return c.json({ job }) + } catch (error) { + return handleScheduleError(c, error, 'Failed to update schedule') + } + }) + + app.delete('/:jobId', (c) => { + try { + const repoId = parseId(c.req.param('id'), 'repo id') + const jobId = parseId(c.req.param('jobId'), 'schedule id') + scheduleService.deleteJob(repoId, jobId) + return c.json({ success: true }) + } catch (error) { + return handleScheduleError(c, error, 'Failed to delete schedule') + } + }) + + app.post('/:jobId/run', async (c) => { + try { + const repoId = parseId(c.req.param('id'), 'repo id') + const jobId = parseId(c.req.param('jobId'), 'schedule id') + const run = await scheduleService.runJob(repoId, jobId, 'manual') + return c.json({ run }) + } catch (error) { + return handleScheduleError(c, error, 'Failed to run schedule') + } + }) + + app.get('/:jobId/runs', (c) => { + try { + const repoId = parseId(c.req.param('id'), 'repo id') + const jobId = parseId(c.req.param('jobId'), 'schedule id') + const limit = parseRunListLimit(c.req.query('limit')) + return c.json({ runs: scheduleService.listRuns(repoId, jobId, limit) }) + } catch (error) { + return handleScheduleError(c, error, 'Failed to list schedule runs') + } + }) + + app.get('/:jobId/runs/:runId', (c) => { + try { + const repoId = parseId(c.req.param('id'), 'repo id') + const jobId = parseId(c.req.param('jobId'), 'schedule id') + const runId = parseId(c.req.param('runId'), 'run id') + return c.json({ run: scheduleService.getRun(repoId, jobId, runId) }) + } catch (error) { + return handleScheduleError(c, error, 'Failed to get schedule run') + } + }) + + app.post('/:jobId/runs/:runId/cancel', async (c) => { + try { + const repoId = parseId(c.req.param('id'), 'repo id') + const jobId = parseId(c.req.param('jobId'), 'schedule id') + const runId = parseId(c.req.param('runId'), 'run id') + const run = await scheduleService.cancelRun(repoId, jobId, runId) + return c.json({ run }) + } catch (error) { + return handleScheduleError(c, error, 'Failed to cancel schedule run') + } + }) + + return app +} + +function handleScheduleError(c: Context, error: unknown, fallbackMessage: string) { + if (error instanceof ScheduleServiceError) { + return c.json({ error: error.message }, error.status as 400 | 404 | 409 | 500 | 502) + } + + logger.error(fallbackMessage + ':', error) + return c.json({ error: getErrorMessage(error) }, 500) +} diff --git a/backend/src/routes/title.ts b/backend/src/routes/title.ts index 23b957da..cb75f2f0 100644 --- a/backend/src/routes/title.ts +++ b/backend/src/routes/title.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono' import { z } from 'zod' import { logger } from '../utils/logger' import { ENV } from '@opencode-manager/shared/config/env' +import { resolveOpenCodeModel } from '../services/opencode-models' const TitleRequestSchema = z.object({ text: z.string().min(1).max(5000), @@ -9,12 +10,101 @@ const TitleRequestSchema = z.object({ }) const OPENCODE_SERVER_URL = `http://127.0.0.1:${ENV.OPENCODE.PORT}` +const TITLE_POLL_INTERVAL_MS = 1_000 +const TITLE_POLL_TIMEOUT_MS = 30_000 + +interface PromptResponse { + parts?: Array<{ type?: string; text?: string }> +} + +interface SessionMessage { + info?: { + role?: string + time?: { + completed?: number + } + error?: { + name?: string + data?: { + message?: string + } + } + } + parts?: Array<{ type?: string; text?: string }> +} function buildUrl(path: string, directory?: string): string { const url = `${OPENCODE_SERVER_URL}${path}` return directory ? `${url}${url.includes('?') ? '&' : '?'}directory=${encodeURIComponent(directory)}` : url } +function parsePromptResponse(responseText: string): PromptResponse | null { + if (!responseText.trim()) { + return null + } + + try { + return JSON.parse(responseText) as PromptResponse + } catch { + return null + } +} + +function extractText(parts: Array<{ type?: string; text?: string }> | undefined): string { + return (parts ?? []) + .filter((part) => part.type === 'text' && typeof part.text === 'string') + .map((part) => part.text?.replace(/[\s\S]*?<\/think>\s*/g, '').trim() ?? '') + .filter(Boolean) + .join('\n') +} + +function extractTitle(result: PromptResponse | SessionMessage): string { + const text = extractText(result.parts) + const title = text + .split('\n') + .map((line) => line.trim()) + .find((line) => line.length > 0) || '' + + if (title.length > 100) { + return title.substring(0, 97) + '...' + } + + return title +} + +async function waitForTitleResponse(sessionID: string, directory: string): Promise { + const startedAt = Date.now() + + while (Date.now() - startedAt < TITLE_POLL_TIMEOUT_MS) { + const messagesResponse = await fetch(buildUrl(`/session/${sessionID}/message`, directory)) + + if (!messagesResponse.ok) { + const errorText = await messagesResponse.text() + throw new Error(errorText || 'Failed to fetch title generation messages') + } + + const messages = await messagesResponse.json() as SessionMessage[] + const assistantMessage = [...messages] + .reverse() + .find((message) => message.info?.role === 'assistant') + + if (assistantMessage) { + const errorText = assistantMessage.info?.error?.data?.message ?? assistantMessage.info?.error?.name + if (errorText) { + throw new Error(errorText) + } + + if (assistantMessage.info?.time?.completed) { + return assistantMessage + } + } + + await Bun.sleep(TITLE_POLL_INTERVAL_MS) + } + + throw new Error('Timed out waiting for title generation') +} + const TITLE_PROMPT = `You are a title generator. You output ONLY a thread title. Nothing else. @@ -63,15 +153,9 @@ export function createTitleRoutes() { logger.info('Generating session title via LLM', { sessionID, textLength: text.length }) - const configResponse = await fetch(buildUrl('/config', directory)) - if (!configResponse.ok) { - logger.error('Failed to fetch OpenCode config') - return c.json({ error: 'Failed to fetch config' }, 500) - } - const config = await configResponse.json() as { model?: string; small_model?: string } - - const modelStr = config.small_model || (config.model ?? "") - const [providerID, modelID] = modelStr.split('/') + const model = await resolveOpenCodeModel(directory || undefined, { + preferSmallModel: true, + }) const titleSessionResponse = await fetch(buildUrl('/session', directory), { method: 'POST', @@ -98,7 +182,10 @@ export function createTitleRoutes() { text: `${TITLE_PROMPT}\n\nGenerate a title for this conversation:\n\n${text.substring(0, 2000)}\n` } ], - model: { providerID, modelID } + model: { + providerID: model.providerID, + modelID: model.modelID, + } }) }) @@ -108,23 +195,9 @@ export function createTitleRoutes() { return c.json({ error: 'LLM request failed' }, 500) } - const result = await promptResponse.json() as { parts?: Array<{ type: string; text?: string }> } - - let title = '' - if (result.parts) { - const textPart = result.parts.find((p: { type: string }) => p.type === 'text') - if (textPart && 'text' in textPart) { - title = (textPart.text as string) - .replace(/[\s\S]*?<\/think>\s*/g, '') - .split('\n') - .map((line: string) => line.trim()) - .find((line: string) => line.length > 0) || '' - } - } - - if (title && title.length > 100) { - title = title.substring(0, 97) + '...' - } + const promptBody = await promptResponse.text() + const promptResult = parsePromptResponse(promptBody) + const title = extractTitle(promptResult ?? await waitForTitleResponse(titleSessionID, directory)) if (title) { const updateResponse = await fetch(buildUrl(`/session/${sessionID}`, directory), { diff --git a/backend/src/services/opencode-models.ts b/backend/src/services/opencode-models.ts new file mode 100644 index 00000000..406a20d0 --- /dev/null +++ b/backend/src/services/opencode-models.ts @@ -0,0 +1,158 @@ +import { proxyToOpenCodeWithDirectory } from './proxy' + +interface OpenCodeConfigResponse { + model?: string + small_model?: string +} + +interface OpenCodeProviderResponse { + providers?: Array<{ + id: string + models?: Record + }> + default?: Record +} + +export interface ResolvedOpenCodeModel { + providerID: string + modelID: string + model: string +} + +function normalizeModelCandidate(model: string | null | undefined): string | null { + if (!model) { + return null + } + + const normalized = model.trim() + return normalized ? normalized : null +} + +function parseModel(model: string): ResolvedOpenCodeModel | null { + const [providerID, ...modelParts] = model.split('/') + const modelID = modelParts.join('/') + + if (!providerID || !modelID) { + return null + } + + return { + providerID, + modelID, + model: `${providerID}/${modelID}`, + } +} + +function buildAvailableModels(response: OpenCodeProviderResponse): Set { + const availableModels = new Set() + + for (const provider of response.providers ?? []) { + for (const modelID of Object.keys(provider.models ?? {})) { + availableModels.add(`${provider.id}/${modelID}`) + } + } + + return availableModels +} + +function uniqueCandidates(candidates: Array): string[] { + const normalizedCandidates = candidates + .map(normalizeModelCandidate) + .filter((candidate): candidate is string => candidate !== null) + + return [...new Set(normalizedCandidates)] +} + +async function fetchOpenCodeConfig(directory?: string): Promise { + const response = await proxyToOpenCodeWithDirectory('/config', 'GET', directory) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(errorText || 'Failed to fetch OpenCode config') + } + + return await response.json() as OpenCodeConfigResponse +} + +async function fetchOpenCodeProviders(directory?: string): Promise { + const response = await proxyToOpenCodeWithDirectory('/config/providers', 'GET', directory) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(errorText || 'Failed to fetch OpenCode providers') + } + + return await response.json() as OpenCodeProviderResponse +} + +export async function resolveOpenCodeModel( + directory: string | undefined, + options?: { + preferredModel?: string | null + preferSmallModel?: boolean + }, +): Promise { + const [config, providersResponse] = await Promise.all([ + fetchOpenCodeConfig(directory), + fetchOpenCodeProviders(directory), + ]) + + const availableModels = buildAvailableModels(providersResponse) + const defaultModels = providersResponse.default ?? {} + const configCandidates = options?.preferSmallModel + ? [config.small_model, config.model] + : [config.model, config.small_model] + const candidates = uniqueCandidates([options?.preferredModel, ...configCandidates]) + + for (const candidate of candidates) { + if (availableModels.has(candidate)) { + const parsedCandidate = parseModel(candidate) + if (parsedCandidate) { + return parsedCandidate + } + } + + const parsedCandidate = parseModel(candidate) + if (!parsedCandidate) { + continue + } + + const providerDefaultModel = defaultModels[parsedCandidate.providerID] + if (!providerDefaultModel) { + continue + } + + const providerDefault = `${parsedCandidate.providerID}/${providerDefaultModel}` + if (availableModels.has(providerDefault)) { + return { + providerID: parsedCandidate.providerID, + modelID: providerDefaultModel, + model: providerDefault, + } + } + } + + for (const [providerID, modelID] of Object.entries(defaultModels)) { + const model = `${providerID}/${modelID}` + if (availableModels.has(model)) { + return { + providerID, + modelID, + model, + } + } + } + + for (const provider of providersResponse.providers ?? []) { + const firstModelID = Object.keys(provider.models ?? {})[0] + if (firstModelID) { + return { + providerID: provider.id, + modelID: firstModelID, + model: `${provider.id}/${firstModelID}`, + } + } + } + + throw new Error('No configured OpenCode models are available') +} diff --git a/backend/src/services/schedule-config.ts b/backend/src/services/schedule-config.ts new file mode 100644 index 00000000..197c8338 --- /dev/null +++ b/backend/src/services/schedule-config.ts @@ -0,0 +1,156 @@ +import { CronExpressionParser } from 'cron-parser' +import type { + CreateScheduleJobRequest, + ScheduleJob, + ScheduleMode, + ScheduleSkillMetadata, + UpdateScheduleJobRequest, +} from '@opencode-manager/shared/types' + +const DEFAULT_CRON_TIMEZONE = 'UTC' + +export interface ScheduleJobPersistenceInput { + name: string + description: string | null + enabled: boolean + scheduleMode: ScheduleMode + intervalMinutes: number | null + cronExpression: string | null + timezone: string | null + agentSlug: string | null + prompt: string + model: string | null + skillMetadata: ScheduleSkillMetadata | null | undefined + nextRunAt: number | null +} + +function validateTimeZone(timezone: string): string { + try { + new Intl.DateTimeFormat('en-US', { timeZone: timezone }) + return timezone + } catch { + throw new Error(`Invalid timezone: ${timezone}`) + } +} + +function getCronNextRunAt(cronExpression: string, timezone: string, currentDate: number): number { + const interval = CronExpressionParser.parse(cronExpression, { + currentDate: new Date(currentDate), + tz: timezone, + }) + + return interval.next().getTime() +} + +function normalizeCronConfig(cronExpression: string, timezone: string | null | undefined, currentDate: number) { + const normalizedTimezone = validateTimeZone(timezone?.trim() || DEFAULT_CRON_TIMEZONE) + const normalizedCronExpression = cronExpression.trim() + const nextRunAt = getCronNextRunAt(normalizedCronExpression, normalizedTimezone, currentDate) + + return { + scheduleMode: 'cron' as const, + intervalMinutes: null, + cronExpression: normalizedCronExpression, + timezone: normalizedTimezone, + nextRunAt, + } +} + +function normalizeIntervalConfig(intervalMinutes: number, currentDate: number) { + return { + scheduleMode: 'interval' as const, + intervalMinutes, + cronExpression: null, + timezone: null, + nextRunAt: currentDate + intervalMinutes * 60_000, + } +} + +export function computeNextRunAtForJob(job: ScheduleJob, currentDate: number): number | null { + if (!job.enabled) { + return null + } + + if (job.scheduleMode === 'cron') { + if (!job.cronExpression) { + throw new Error('Cron expression is required for cron schedules') + } + + return getCronNextRunAt(job.cronExpression, job.timezone || DEFAULT_CRON_TIMEZONE, currentDate) + } + + if (!job.intervalMinutes) { + throw new Error('Interval minutes are required for interval schedules') + } + + return currentDate + job.intervalMinutes * 60_000 +} + +export function buildCreateSchedulePersistenceInput(input: CreateScheduleJobRequest, currentDate: number = Date.now()): ScheduleJobPersistenceInput { + const base = { + name: input.name.trim(), + description: input.description?.trim() || null, + enabled: input.enabled !== false, + agentSlug: input.agentSlug?.trim() || null, + prompt: input.prompt.trim(), + model: input.model?.trim() || null, + skillMetadata: input.skillMetadata, + } + + const scheduleConfig = input.scheduleMode === 'cron' + ? normalizeCronConfig(input.cronExpression, input.timezone, currentDate) + : normalizeIntervalConfig(input.intervalMinutes, currentDate) + + return { + ...base, + ...scheduleConfig, + nextRunAt: base.enabled ? scheduleConfig.nextRunAt : null, + } +} + +export function buildUpdatedSchedulePersistenceInput( + existing: ScheduleJob, + input: UpdateScheduleJobRequest, + currentDate: number = Date.now(), +): ScheduleJobPersistenceInput { + const enabled = input.enabled ?? existing.enabled + const scheduleMode = input.scheduleMode ?? existing.scheduleMode + + const scheduleConfig = scheduleMode === 'cron' + ? normalizeCronConfig( + input.cronExpression ?? existing.cronExpression ?? '', + input.timezone ?? existing.timezone ?? DEFAULT_CRON_TIMEZONE, + currentDate, + ) + : normalizeIntervalConfig( + input.intervalMinutes ?? existing.intervalMinutes ?? 60, + currentDate, + ) + + const scheduleChanged = + input.scheduleMode !== undefined || + input.intervalMinutes !== undefined || + input.cronExpression !== undefined || + input.timezone !== undefined + + const nextRunAt = enabled + ? scheduleChanged || input.enabled !== undefined || existing.nextRunAt === null + ? scheduleConfig.nextRunAt + : existing.nextRunAt + : null + + return { + name: input.name?.trim() || existing.name, + description: input.description === undefined ? existing.description : (input.description?.trim() || null), + enabled, + scheduleMode: scheduleConfig.scheduleMode, + intervalMinutes: scheduleConfig.intervalMinutes, + cronExpression: scheduleConfig.cronExpression, + timezone: scheduleConfig.timezone, + agentSlug: input.agentSlug === undefined ? existing.agentSlug : (input.agentSlug?.trim() || null), + prompt: input.prompt?.trim() || existing.prompt, + model: input.model === undefined ? existing.model : (input.model?.trim() || null), + skillMetadata: input.skillMetadata === undefined ? existing.skillMetadata : input.skillMetadata, + nextRunAt, + } +} diff --git a/backend/src/services/schedules.ts b/backend/src/services/schedules.ts new file mode 100644 index 00000000..353493be --- /dev/null +++ b/backend/src/services/schedules.ts @@ -0,0 +1,996 @@ +import type { Database } from 'bun:sqlite' +import { + type CreateScheduleJobRequest, + type ScheduleJob, + type ScheduleRun, + type ScheduleRunTriggerSource, + type UpdateScheduleJobRequest, +} from '@opencode-manager/shared/types' +import { getRepoById } from '../db/queries' +import { + createScheduleJob, + createScheduleRun, + deleteScheduleJob, + getScheduleJobById, + getRunningScheduleRunByJob, + getScheduleRunById, + listDueScheduleJobs, + listScheduleJobsByRepo, + listRunningScheduleRuns, + listScheduleRunsByJob, + reserveScheduleJobNextRun, + updateScheduleJob, + updateScheduleJobRunState, + updateScheduleRun, + updateScheduleRunMetadata, +} from '../db/schedules' +import { + buildCreateSchedulePersistenceInput, + buildUpdatedSchedulePersistenceInput, + computeNextRunAtForJob, +} from './schedule-config' +import { resolveOpenCodeModel } from './opencode-models' +import { proxyToOpenCodeWithDirectory } from './proxy' +import { sseAggregator, type SSEEvent } from './sse-aggregator' +import { getErrorMessage } from '../utils/error-utils' +import { logger } from '../utils/logger' + +class ScheduleServiceError extends Error { + status: number + + constructor(message: string, status: number) { + super(message) + this.status = status + } +} + +interface SessionResponse { + id: string +} + +interface PromptResponse { + parts?: Array<{ + type?: string + text?: string + }> +} + +interface SessionMessagePart { + type?: string + text?: string +} + +interface SessionMessage { + info?: { + id?: string + sessionID?: string + role?: string + time?: { + created?: number + completed?: number + } + error?: { + name?: string + data?: { + message?: string + } + } + } + parts?: SessionMessagePart[] +} + +interface SessionStatus { + type: 'idle' | 'retry' | 'busy' + attempt?: number + message?: string + next?: number +} + +const RUN_POLL_INTERVAL_MS = 2_000 +const RUN_POLL_TIMEOUT_MS = 5 * 60_000 + +interface SessionMonitor { + getErrorText(): string | null + isIdle(): boolean + dispose(): void +} + +function extractResponseText(response: PromptResponse): string { + return (response.parts ?? []) + .filter((part) => part.type === 'text' && typeof part.text === 'string') + .map((part) => part.text?.replace(/[\s\S]*?<\/think>\s*/g, '').trim() ?? '') + .filter(Boolean) + .join('\n\n') +} + +function buildSessionTitle(job: ScheduleJob): string { + return `Scheduled: ${job.name}` +} + +function buildRunLog(input: { + job: ScheduleJob + triggerSource: ScheduleRunTriggerSource + sessionId?: string | null + sessionTitle?: string | null + responseText?: string | null + errorText?: string | null + finishedAt: number +}): string { + const scheduleLabel = input.job.scheduleMode === 'cron' + ? `${input.job.cronExpression ?? ''} (${input.job.timezone ?? 'UTC'})` + : `every ${input.job.intervalMinutes ?? 0} minutes` + + const lines = [ + `Job: ${input.job.name}`, + `Trigger: ${input.triggerSource}`, + `Finished: ${new Date(input.finishedAt).toISOString()}`, + `Agent: ${input.job.agentSlug ?? 'default'}`, + `Schedule: ${scheduleLabel}`, + ] + + if (input.sessionId) { + lines.push(`Session ID: ${input.sessionId}`) + } + + if (input.sessionTitle) { + lines.push(`Session title: ${input.sessionTitle}`) + } + + if (input.errorText) { + lines.push('', 'Error:', input.errorText) + } + + if (input.responseText) { + lines.push('', 'Assistant output:', input.responseText) + } + + return lines.join('\n') +} + +function buildRunStartedLog(input: { + job: ScheduleJob + triggerSource: ScheduleRunTriggerSource + sessionId: string + sessionTitle: string +}): string { + const scheduleLabel = input.job.scheduleMode === 'cron' + ? `${input.job.cronExpression ?? ''} (${input.job.timezone ?? 'UTC'})` + : `every ${input.job.intervalMinutes ?? 0} minutes` + + return [ + `Job: ${input.job.name}`, + `Trigger: ${input.triggerSource}`, + `Started: ${new Date().toISOString()}`, + `Agent: ${input.job.agentSlug ?? 'default'}`, + `Schedule: ${scheduleLabel}`, + `Session ID: ${input.sessionId}`, + `Session title: ${input.sessionTitle}`, + '', + 'Run started. Waiting for assistant response...', + ].join('\n') +} + +function parsePromptResponse(responseText: string): PromptResponse | null { + if (!responseText.trim()) { + return null + } + + try { + return JSON.parse(responseText) as PromptResponse + } catch { + return null + } +} + +function extractAssistantMessageText(parts: SessionMessagePart[] | undefined): string { + return (parts ?? []) + .filter((part) => part.type === 'text' && typeof part.text === 'string') + .map((part) => part.text?.replace(/[\s\S]*?<\/think>\s*/g, '').trim() ?? '') + .filter(Boolean) + .join('\n\n') +} + +function getAssistantMessageState(messages: SessionMessage[]): { + responseText: string | null + errorText: string | null + completed: boolean +} | null { + const assistantMessage = [...messages] + .reverse() + .find((message) => message.info?.role === 'assistant') + + if (!assistantMessage) { + return null + } + + return { + responseText: extractAssistantMessageText(assistantMessage.parts) || null, + errorText: assistantMessage.info?.error?.data?.message ?? assistantMessage.info?.error?.name ?? null, + completed: Boolean(assistantMessage.info?.time?.completed), + } +} + +function getSessionEventId(event: SSEEvent): string | null { + const properties = event.properties as { + sessionID?: string + info?: { id?: string } + } + + return properties.sessionID ?? properties.info?.id ?? null +} + +function getSessionErrorText(event: SSEEvent): string | null { + const properties = event.properties as { + error?: { + name?: string + data?: { + message?: string + } + } + } + + return properties.error?.data?.message ?? properties.error?.name ?? null +} + +function getSessionStatusType(event: SSEEvent): string | null { + const properties = event.properties as { + status?: { + type?: string + } + } + + return properties.status?.type ?? null +} + +function createSessionMonitor(directory: string, sessionId: string): SessionMonitor { + const clientId = `schedule-monitor-${sessionId}-${Date.now()}` + let errorText: string | null = null + let idle = false + + const removeClient = sseAggregator.addClient(clientId, () => {}, [directory]) + const unsubscribe = sseAggregator.onEvent((eventDirectory, event) => { + if (eventDirectory !== directory) { + return + } + + if (getSessionEventId(event) !== sessionId) { + return + } + + if (event.type === 'session.error') { + errorText = getSessionErrorText(event) ?? 'The session reported an unknown error.' + return + } + + if (event.type === 'session.idle') { + idle = true + return + } + + if (event.type === 'session.status' && getSessionStatusType(event) === 'idle') { + idle = true + } + }) + + return { + getErrorText: () => errorText, + isIdle: () => idle, + dispose: () => { + unsubscribe() + removeClient() + }, + } +} + +export class ScheduleService { + private static activeRuns = new Set() + + constructor(private readonly db: Database) {} + + async recoverRunningRuns(): Promise { + const runningRuns = listRunningScheduleRuns(this.db) + + for (const run of runningRuns) { + const job = getScheduleJobById(this.db, run.repoId, run.jobId) + if (!job) { + continue + } + + if (ScheduleService.activeRuns.has(job.id)) { + continue + } + + ScheduleService.activeRuns.add(job.id) + await this.recoverRunningRun(job, run) + } + } + + listJobs(repoId: number): ScheduleJob[] { + this.assertRepo(repoId) + return listScheduleJobsByRepo(this.db, repoId) + } + + listDueJobs(now: number): ScheduleJob[] { + return listDueScheduleJobs(this.db, now) + } + + getJob(repoId: number, jobId: number): ScheduleJob | null { + return getScheduleJobById(this.db, repoId, jobId) + } + + createJob(repoId: number, input: CreateScheduleJobRequest): ScheduleJob { + this.assertRepo(repoId) + + try { + return createScheduleJob(this.db, repoId, buildCreateSchedulePersistenceInput(input)) + } catch (error) { + throw new ScheduleServiceError(getErrorMessage(error), 400) + } + } + + updateJob(repoId: number, jobId: number, input: UpdateScheduleJobRequest): ScheduleJob { + this.assertRepo(repoId) + const existing = this.assertJob(repoId, jobId) + let job: ScheduleJob | null + + try { + job = updateScheduleJob(this.db, repoId, jobId, buildUpdatedSchedulePersistenceInput(existing, input)) + } catch (error) { + throw new ScheduleServiceError(getErrorMessage(error), 400) + } + + if (!job) { + throw new ScheduleServiceError('Schedule not found', 404) + } + return job + } + + deleteJob(repoId: number, jobId: number): void { + this.assertRepo(repoId) + const deleted = deleteScheduleJob(this.db, repoId, jobId) + if (!deleted) { + throw new ScheduleServiceError('Schedule not found', 404) + } + } + + listRuns(repoId: number, jobId: number, limit: number = 20): ScheduleRun[] { + this.assertJob(repoId, jobId) + return listScheduleRunsByJob(this.db, repoId, jobId, limit) + } + + getRun(repoId: number, jobId: number, runId: number): ScheduleRun { + this.assertJob(repoId, jobId) + const run = getScheduleRunById(this.db, repoId, jobId, runId) + if (!run) { + throw new ScheduleServiceError('Run not found', 404) + } + return run + } + + async runJob(repoId: number, jobId: number, triggerSource: ScheduleRunTriggerSource): Promise { + const repo = this.assertRepo(repoId) + const job = this.assertJob(repoId, jobId) + + const existingRunningRun = getRunningScheduleRunByJob(this.db, repoId, jobId) + if (existingRunningRun) { + throw new ScheduleServiceError('Schedule is already running', 409) + } + + if (ScheduleService.activeRuns.has(jobId)) { + throw new ScheduleServiceError('Schedule is already running', 409) + } + + ScheduleService.activeRuns.add(jobId) + + const reservedNextRunAt = triggerSource === 'schedule' && job.enabled + ? computeNextRunAtForJob(job, Date.now()) + : job.nextRunAt + + if (triggerSource === 'schedule' && reservedNextRunAt !== null) { + reserveScheduleJobNextRun(this.db, repoId, jobId, reservedNextRunAt) + } + + const startedAt = Date.now() + const run = createScheduleRun(this.db, { + jobId, + repoId, + triggerSource, + status: 'running', + startedAt, + createdAt: startedAt, + }) + + try { + const model = await resolveOpenCodeModel(repo.fullPath, { + preferredModel: job.model, + }) + const sessionTitle = buildSessionTitle(job) + const sessionResponse = await proxyToOpenCodeWithDirectory( + '/session', + 'POST', + repo.fullPath, + JSON.stringify({ + title: sessionTitle, + agent: job.agentSlug ?? undefined, + }), + ) + + if (!sessionResponse.ok) { + throw new ScheduleServiceError('Failed to create OpenCode session', 502) + } + + const session = await sessionResponse.json() as SessionResponse + const runWithSession = updateScheduleRunMetadata(this.db, repoId, jobId, run.id, { + sessionId: session.id, + sessionTitle, + logText: buildRunStartedLog({ + job, + triggerSource, + sessionId: session.id, + sessionTitle, + }), + }) + + if (!runWithSession) { + throw new ScheduleServiceError('Failed to attach session to run', 500) + } + + const sessionMonitor = createSessionMonitor(repo.fullPath, session.id) + + void this.submitPromptAndMonitor({ + repoId, + job, + runId: run.id, + sessionId: session.id, + sessionTitle, + triggerSource, + reservedNextRunAt, + model, + sessionMonitor, + }) + + return runWithSession + } catch (error) { + const finishedAt = Date.now() + const errorText = getErrorMessage(error) + logger.error(`Failed to run schedule ${jobId}:`, error) + + const failedRun = updateScheduleRun(this.db, repoId, jobId, run.id, { + status: 'failed', + finishedAt, + errorText, + logText: buildRunLog({ + job, + triggerSource, + errorText, + finishedAt, + }), + }) + + updateScheduleJobRunState(this.db, repoId, jobId, { + lastRunAt: finishedAt, + nextRunAt: triggerSource === 'manual' ? job.nextRunAt : reservedNextRunAt, + }) + + if (!failedRun) { + ScheduleService.activeRuns.delete(jobId) + throw new ScheduleServiceError('Failed to load failed run', 500) + } + + if (error instanceof ScheduleServiceError) { + ScheduleService.activeRuns.delete(jobId) + throw error + } + + ScheduleService.activeRuns.delete(jobId) + throw new ScheduleServiceError(errorText, 500) + } + } + + async cancelRun(repoId: number, jobId: number, runId: number): Promise { + const repo = this.assertRepo(repoId) + const job = this.assertJob(repoId, jobId) + const run = this.getRun(repoId, jobId, runId) + + if (run.status !== 'running') { + throw new ScheduleServiceError('Only running schedule runs can be cancelled', 409) + } + + if (run.sessionId) { + const messages = await this.listSessionMessages(repo.fullPath, run.sessionId) + const assistantState = getAssistantMessageState(messages) + + if (assistantState?.completed || assistantState?.errorText) { + this.finalizeRecoveredRun(job, run, { + status: assistantState.errorText ? 'failed' : 'completed', + responseText: assistantState.responseText, + errorText: assistantState.errorText, + }) + + return this.getRun(repoId, jobId, runId) + } + + const abortResponse = await proxyToOpenCodeWithDirectory( + `/session/${run.sessionId}/abort`, + 'POST', + repo.fullPath, + ) + + if (!abortResponse.ok) { + const errorText = await abortResponse.text() + throw new ScheduleServiceError(errorText || 'Failed to cancel schedule run', 502) + } + } + + const finishedAt = Date.now() + const cancellationMessage = 'Run cancelled by user.' + const cancelledRun = updateScheduleRun(this.db, repoId, jobId, runId, { + status: 'cancelled', + finishedAt, + sessionId: run.sessionId, + sessionTitle: run.sessionTitle, + errorText: cancellationMessage, + responseText: run.responseText, + logText: buildRunLog({ + job, + triggerSource: run.triggerSource, + sessionId: run.sessionId, + sessionTitle: run.sessionTitle, + responseText: run.responseText, + errorText: cancellationMessage, + finishedAt, + }), + }) + + updateScheduleJobRunState(this.db, repoId, jobId, { + lastRunAt: finishedAt, + nextRunAt: job.nextRunAt, + }) + + ScheduleService.activeRuns.delete(jobId) + + if (!cancelledRun) { + throw new ScheduleServiceError('Failed to update cancelled run', 500) + } + + return cancelledRun + } + + private async submitPromptAndMonitor(input: { + repoId: number + job: ScheduleJob + runId: number + sessionId: string + sessionTitle: string + triggerSource: ScheduleRunTriggerSource + reservedNextRunAt: number | null + model: { providerID: string; modelID: string } + sessionMonitor: SessionMonitor + }): Promise { + const repo = this.assertRepo(input.repoId) + + try { + const promptResponse = await proxyToOpenCodeWithDirectory( + `/session/${input.sessionId}/message`, + 'POST', + repo.fullPath, + JSON.stringify({ + parts: [{ type: 'text', text: input.job.prompt }], + model: input.model, + }), + ) + + if (!promptResponse.ok) { + const errorText = await promptResponse.text() + throw new ScheduleServiceError(errorText || 'Failed to run scheduled prompt', 502) + } + + const promptBody = await promptResponse.text() + const promptResult = parsePromptResponse(promptBody) + + if (promptResult) { + const currentRun = getScheduleRunById(this.db, input.repoId, input.job.id, input.runId) + if (!currentRun || currentRun.status !== 'running') { + return + } + + const finishedAt = Date.now() + const responseText = extractResponseText(promptResult) + updateScheduleRun(this.db, input.repoId, input.job.id, input.runId, { + status: 'completed', + finishedAt, + sessionId: input.sessionId, + sessionTitle: input.sessionTitle, + responseText, + logText: buildRunLog({ + job: input.job, + triggerSource: input.triggerSource, + sessionId: input.sessionId, + sessionTitle: input.sessionTitle, + responseText, + finishedAt, + }), + }) + + updateScheduleJobRunState(this.db, input.repoId, input.job.id, { + lastRunAt: finishedAt, + nextRunAt: input.triggerSource === 'manual' ? input.job.nextRunAt : input.reservedNextRunAt, + }) + + return + } + + await this.monitorRunCompletion({ + sessionMonitor: input.sessionMonitor, + repoId: input.repoId, + job: input.job, + runId: input.runId, + sessionId: input.sessionId, + sessionTitle: input.sessionTitle, + triggerSource: input.triggerSource, + reservedNextRunAt: input.reservedNextRunAt, + }) + return + } catch (error) { + const finishedAt = Date.now() + const errorText = getErrorMessage(error) + logger.error(`Failed to submit prompt for schedule ${input.job.id}:`, error) + + const currentRun = getScheduleRunById(this.db, input.repoId, input.job.id, input.runId) + if (!currentRun || currentRun.status !== 'running') { + return + } + + updateScheduleRun(this.db, input.repoId, input.job.id, input.runId, { + status: 'failed', + finishedAt, + sessionId: input.sessionId, + sessionTitle: input.sessionTitle, + errorText, + logText: buildRunLog({ + job: input.job, + triggerSource: input.triggerSource, + sessionId: input.sessionId, + sessionTitle: input.sessionTitle, + errorText, + finishedAt, + }), + }) + + updateScheduleJobRunState(this.db, input.repoId, input.job.id, { + lastRunAt: finishedAt, + nextRunAt: input.triggerSource === 'manual' ? input.job.nextRunAt : input.reservedNextRunAt, + }) + } finally { + input.sessionMonitor.dispose() + ScheduleService.activeRuns.delete(input.job.id) + } + } + + private async monitorRunCompletion(input: { + sessionMonitor: SessionMonitor + repoId: number + job: ScheduleJob + runId: number + sessionId: string + sessionTitle: string + triggerSource: ScheduleRunTriggerSource + reservedNextRunAt: number | null + }): Promise { + try { + const response = await this.waitForAssistantMessage(input.job, input.sessionId, input.sessionMonitor) + const currentRun = getScheduleRunById(this.db, input.repoId, input.job.id, input.runId) + if (!currentRun || currentRun.status !== 'running') { + return + } + + const finishedAt = Date.now() + + if (response.errorText) { + updateScheduleRun(this.db, input.repoId, input.job.id, input.runId, { + status: 'failed', + finishedAt, + sessionId: input.sessionId, + sessionTitle: input.sessionTitle, + errorText: response.errorText, + responseText: response.responseText, + logText: buildRunLog({ + job: input.job, + triggerSource: input.triggerSource, + sessionId: input.sessionId, + sessionTitle: input.sessionTitle, + responseText: response.responseText, + errorText: response.errorText, + finishedAt, + }), + }) + } else { + updateScheduleRun(this.db, input.repoId, input.job.id, input.runId, { + status: 'completed', + finishedAt, + sessionId: input.sessionId, + sessionTitle: input.sessionTitle, + responseText: response.responseText, + logText: buildRunLog({ + job: input.job, + triggerSource: input.triggerSource, + sessionId: input.sessionId, + sessionTitle: input.sessionTitle, + responseText: response.responseText, + finishedAt, + }), + }) + } + + updateScheduleJobRunState(this.db, input.repoId, input.job.id, { + lastRunAt: finishedAt, + nextRunAt: input.triggerSource === 'manual' ? input.job.nextRunAt : input.reservedNextRunAt, + }) + } catch (error) { + const finishedAt = Date.now() + const errorText = getErrorMessage(error) + logger.error(`Failed to monitor schedule ${input.job.id}:`, error) + + const currentRun = getScheduleRunById(this.db, input.repoId, input.job.id, input.runId) + if (!currentRun || currentRun.status !== 'running') { + return + } + + updateScheduleRun(this.db, input.repoId, input.job.id, input.runId, { + status: 'failed', + finishedAt, + sessionId: input.sessionId, + sessionTitle: input.sessionTitle, + errorText, + logText: buildRunLog({ + job: input.job, + triggerSource: input.triggerSource, + sessionId: input.sessionId, + sessionTitle: input.sessionTitle, + errorText, + finishedAt, + }), + }) + + updateScheduleJobRunState(this.db, input.repoId, input.job.id, { + lastRunAt: finishedAt, + nextRunAt: input.triggerSource === 'manual' ? input.job.nextRunAt : input.reservedNextRunAt, + }) + } finally { + input.sessionMonitor.dispose() + ScheduleService.activeRuns.delete(input.job.id) + } + } + + private async recoverRunningRun(job: ScheduleJob, run: ScheduleRun): Promise { + try { + const repo = this.assertRepo(job.repoId) + + if (!run.sessionId) { + this.finalizeRecoveredRun(job, run, { + status: 'failed', + errorText: 'This run was interrupted before completion and had no linked session to recover.', + }) + return + } + + const messages = await this.listSessionMessages(repo.fullPath, run.sessionId) + const assistantState = getAssistantMessageState(messages) + + if (assistantState?.completed || assistantState?.errorText) { + this.finalizeRecoveredRun(job, run, { + status: assistantState.errorText ? 'failed' : 'completed', + responseText: assistantState.responseText, + errorText: assistantState.errorText, + }) + return + } + + const sessionStatuses = await this.getSessionStatuses(repo.fullPath) + const sessionStatus = run.sessionId ? sessionStatuses[run.sessionId] : undefined + + if (sessionStatus && sessionStatus.type !== 'idle') { + const sessionMonitor = createSessionMonitor(repo.fullPath, run.sessionId) + void this.monitorRunCompletion({ + sessionMonitor, + repoId: run.repoId, + job, + runId: run.id, + sessionId: run.sessionId, + sessionTitle: run.sessionTitle ?? buildSessionTitle(job), + triggerSource: run.triggerSource, + reservedNextRunAt: job.nextRunAt, + }) + return + } + + this.finalizeRecoveredRun(job, run, { + status: 'failed', + responseText: assistantState?.responseText ?? null, + errorText: 'This run was interrupted before completion, likely because OpenCode Manager restarted while it was in progress. Open the linked session to inspect the partial output and rerun if needed.', + }) + } catch (error) { + const errorText = getErrorMessage(error) + logger.error(`Failed to recover schedule ${job.id}:`, error) + this.finalizeRecoveredRun(job, run, { + status: 'failed', + errorText, + }) + } + } + + private finalizeRecoveredRun( + job: ScheduleJob, + run: ScheduleRun, + input: { + status: 'completed' | 'failed' + responseText?: string | null + errorText?: string | null + }, + ): void { + const finishedAt = Date.now() + + updateScheduleRun(this.db, run.repoId, run.jobId, run.id, { + status: input.status, + finishedAt, + sessionId: run.sessionId, + sessionTitle: run.sessionTitle, + responseText: input.responseText, + errorText: input.errorText, + logText: buildRunLog({ + job, + triggerSource: run.triggerSource, + sessionId: run.sessionId, + sessionTitle: run.sessionTitle, + responseText: input.responseText, + errorText: input.errorText, + finishedAt, + }), + }) + + updateScheduleJobRunState(this.db, run.repoId, run.jobId, { + lastRunAt: finishedAt, + nextRunAt: job.nextRunAt, + }) + + ScheduleService.activeRuns.delete(job.id) + } + + private async waitForAssistantMessage( + job: ScheduleJob, + sessionId: string, + sessionMonitor: SessionMonitor, + ): Promise<{ responseText: string | null; errorText: string | null }> { + const startedAt = Date.now() + const repo = this.assertRepo(job.repoId) + + while (Date.now() - startedAt < RUN_POLL_TIMEOUT_MS) { + const messages = await this.listSessionMessages(repo.fullPath, sessionId) + const assistantState = getAssistantMessageState(messages) + + if (assistantState && (assistantState.completed || assistantState.errorText)) { + return { + responseText: assistantState.responseText, + errorText: assistantState.errorText, + } + } + + const sessionErrorText = sessionMonitor.getErrorText() + if (sessionErrorText) { + return { + responseText: null, + errorText: sessionErrorText, + } + } + + if (sessionMonitor.isIdle()) { + return { + responseText: null, + errorText: 'The session became idle without producing an assistant response. Open the linked session to inspect any pending questions, permissions, or provider issues.', + } + } + + await Bun.sleep(RUN_POLL_INTERVAL_MS) + } + + return { + responseText: null, + errorText: 'Timed out waiting for the assistant response. Open the linked session to inspect any pending questions, permissions, or provider issues.', + } + } + + private async listSessionMessages(directory: string, sessionId: string): Promise { + const messagesResponse = await proxyToOpenCodeWithDirectory( + `/session/${sessionId}/message`, + 'GET', + directory, + ) + + if (!messagesResponse.ok) { + const errorText = await messagesResponse.text() + throw new ScheduleServiceError(errorText || 'Failed to fetch session messages', 502) + } + + return await messagesResponse.json() as SessionMessage[] + } + + private async getSessionStatuses(directory: string): Promise> { + const response = await proxyToOpenCodeWithDirectory('/session/status', 'GET', directory) + + if (!response.ok) { + const errorText = await response.text() + throw new ScheduleServiceError(errorText || 'Failed to fetch session statuses', 502) + } + + return await response.json() as Record + } + + private assertRepo(repoId: number) { + const repo = getRepoById(this.db, repoId) + if (!repo) { + throw new ScheduleServiceError('Repo not found', 404) + } + return repo + } + + private assertJob(repoId: number, jobId: number) { + const job = getScheduleJobById(this.db, repoId, jobId) + if (!job) { + throw new ScheduleServiceError('Schedule not found', 404) + } + return job + } +} + +export class ScheduleRunner { + private timer: ReturnType | null = null + private running = false + + constructor(private readonly scheduleService: ScheduleService) {} + + start(): void { + if (this.timer) { + return + } + + this.timer = setInterval(() => { + void this.tick() + }, 30_000) + + void this.tick() + } + + stop(): void { + if (!this.timer) { + return + } + + clearInterval(this.timer) + this.timer = null + } + + private async tick(): Promise { + if (this.running) { + return + } + + this.running = true + + try { + await this.scheduleService.recoverRunningRuns() + const dueJobs = this.scheduleService.listDueJobs(Date.now()) + + for (const job of dueJobs) { + try { + await this.scheduleService.runJob(job.repoId, job.id, 'schedule') + } catch (error) { + logger.error(`Scheduled run failed for job ${job.id}:`, error) + } + } + } finally { + this.running = false + } + } +} + +export { ScheduleServiceError } diff --git a/backend/test/db/schedule-migrations.test.ts b/backend/test/db/schedule-migrations.test.ts new file mode 100644 index 00000000..f8869ced --- /dev/null +++ b/backend/test/db/schedule-migrations.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from 'vitest' +import migration007 from '../../src/db/migrations/007-schedules' +import migration008 from '../../src/db/migrations/008-schedule-cron-support' + +describe('schedule migrations', () => { + it('creates schedule jobs with nullable interval minutes in v7', () => { + const db = { + run: vi.fn(), + } + + migration007.up(db as never) + + expect(db.run).toHaveBeenCalledWith(expect.stringContaining('interval_minutes INTEGER,')) + }) + + it('rebuilds schedule jobs for cron support in v8', () => { + const db = { + prepare: vi.fn().mockReturnValue({ + all: vi.fn().mockReturnValue([ + { name: 'id', notnull: 0, dflt_value: null }, + { name: 'repo_id', notnull: 1, dflt_value: null }, + { name: 'name', notnull: 1, dflt_value: null }, + { name: 'description', notnull: 0, dflt_value: null }, + { name: 'enabled', notnull: 1, dflt_value: 'TRUE' }, + { name: 'interval_minutes', notnull: 1, dflt_value: null }, + { name: 'agent_slug', notnull: 0, dflt_value: null }, + { name: 'prompt', notnull: 1, dflt_value: null }, + { name: 'model', notnull: 0, dflt_value: null }, + { name: 'skill_metadata', notnull: 0, dflt_value: null }, + { name: 'created_at', notnull: 1, dflt_value: null }, + { name: 'updated_at', notnull: 1, dflt_value: null }, + { name: 'last_run_at', notnull: 0, dflt_value: null }, + { name: 'next_run_at', notnull: 0, dflt_value: null }, + ]), + }), + run: vi.fn(), + } + + migration008.up(db as never) + + expect(db.prepare).toHaveBeenCalledWith('PRAGMA table_info(schedule_jobs)') + expect(db.run).toHaveBeenCalledWith(expect.stringContaining('CREATE TABLE schedule_jobs_new')) + expect(db.run).toHaveBeenCalledWith(expect.stringContaining('interval_minutes INTEGER,')) + expect(db.run).toHaveBeenCalledWith(expect.stringContaining("schedule_mode TEXT NOT NULL DEFAULT 'interval'")) + expect(db.run).toHaveBeenCalledWith(expect.stringContaining("'interval'")) + expect(db.run).toHaveBeenCalledWith('DROP TABLE schedule_jobs') + expect(db.run).toHaveBeenCalledWith('ALTER TABLE schedule_jobs_new RENAME TO schedule_jobs') + }) +}) diff --git a/backend/test/db/schedules.test.ts b/backend/test/db/schedules.test.ts new file mode 100644 index 00000000..50bbc9a2 --- /dev/null +++ b/backend/test/db/schedules.test.ts @@ -0,0 +1,292 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as schedulesDb from '../../src/db/schedules' + +const mockDb = { + prepare: vi.fn(), +} as any + +function makeJobRow(overrides: Record = {}) { + return { + id: 7, + repo_id: 42, + name: 'Weekly engineering summary', + description: 'Summarize repo health and recent changes.', + enabled: 1, + schedule_mode: 'cron', + interval_minutes: null, + cron_expression: '0 9 * * 1', + timezone: 'UTC', + agent_slug: 'planner', + prompt: 'Generate a weekly summary.', + model: 'openai/gpt-5-mini', + skill_metadata: JSON.stringify({ skillSlugs: ['planning'], notes: 'Optional notes' }), + created_at: Date.UTC(2026, 2, 8, 12, 0, 0), + updated_at: Date.UTC(2026, 2, 9, 12, 0, 0), + last_run_at: Date.UTC(2026, 2, 9, 11, 0, 0), + next_run_at: Date.UTC(2026, 2, 9, 13, 0, 0), + ...overrides, + } +} + +function makeRunRow(overrides: Record = {}) { + return { + id: 5, + job_id: 7, + repo_id: 42, + trigger_source: 'manual', + status: 'running', + started_at: Date.UTC(2026, 2, 9, 12, 0, 0), + finished_at: null, + created_at: Date.UTC(2026, 2, 9, 12, 0, 0), + session_id: 'ses-1', + session_title: 'Scheduled: Weekly engineering summary', + log_text: 'Run started. Waiting for assistant response...', + response_text: null, + error_text: null, + ...overrides, + } +} + +describe('schedule database queries', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('lists schedule jobs and parses persisted metadata', () => { + const stmt = { + all: vi.fn().mockReturnValue([makeJobRow()]), + } + mockDb.prepare.mockReturnValue(stmt) + + const jobs = schedulesDb.listScheduleJobsByRepo(mockDb, 42) + + expect(mockDb.prepare).toHaveBeenCalledWith('SELECT * FROM schedule_jobs WHERE repo_id = ? ORDER BY created_at DESC') + expect(jobs[0]).toMatchObject({ + id: 7, + repoId: 42, + scheduleMode: 'cron', + skillMetadata: { + skillSlugs: ['planning'], + notes: 'Optional notes', + }, + }) + }) + + it('creates a schedule job and reloads the inserted row', () => { + const insertStmt = { + run: vi.fn().mockReturnValue({ lastInsertRowid: 7 }), + } + const selectStmt = { + get: vi.fn().mockReturnValue(makeJobRow()), + } + + mockDb.prepare.mockReturnValueOnce(insertStmt).mockReturnValueOnce(selectStmt) + + const job = schedulesDb.createScheduleJob(mockDb, 42, { + name: 'Weekly engineering summary', + description: 'Summarize repo health and recent changes.', + enabled: true, + scheduleMode: 'cron', + intervalMinutes: null, + cronExpression: '0 9 * * 1', + timezone: 'UTC', + agentSlug: 'planner', + prompt: 'Generate a weekly summary.', + model: 'openai/gpt-5-mini', + skillMetadata: { skillSlugs: ['planning'], notes: 'Optional notes' }, + nextRunAt: Date.UTC(2026, 2, 9, 13, 0, 0), + }) + + expect(insertStmt.run).toHaveBeenCalledWith( + 42, + 'Weekly engineering summary', + 'Summarize repo health and recent changes.', + 1, + 'cron', + null, + '0 9 * * 1', + 'UTC', + 'planner', + 'Generate a weekly summary.', + 'openai/gpt-5-mini', + JSON.stringify({ skillSlugs: ['planning'], notes: 'Optional notes' }), + expect.any(Number), + expect.any(Number), + null, + Date.UTC(2026, 2, 9, 13, 0, 0), + ) + expect(job.id).toBe(7) + }) + + it('updates a schedule job when it exists', () => { + const existingStmt = { + get: vi.fn().mockReturnValue(makeJobRow({ name: 'Existing summary' })), + } + const updateStmt = { + run: vi.fn(), + } + const reloadStmt = { + get: vi.fn().mockReturnValue(makeJobRow({ name: 'Updated summary', enabled: 0, skill_metadata: null })), + } + + mockDb.prepare + .mockReturnValueOnce(existingStmt) + .mockReturnValueOnce(updateStmt) + .mockReturnValueOnce(reloadStmt) + + const job = schedulesDb.updateScheduleJob(mockDb, 42, 7, { + name: 'Updated summary', + description: null, + enabled: false, + scheduleMode: 'interval', + intervalMinutes: 90, + cronExpression: null, + timezone: null, + agentSlug: null, + prompt: 'Run a new summary.', + model: null, + skillMetadata: null, + nextRunAt: null, + }) + + expect(updateStmt.run).toHaveBeenCalledWith( + 'Updated summary', + null, + 0, + 'interval', + 90, + null, + null, + null, + 'Run a new summary.', + null, + null, + expect.any(Number), + null, + 42, + 7, + ) + expect(job).toMatchObject({ + name: 'Updated summary', + enabled: false, + skillMetadata: null, + }) + }) + + it('returns null when updating metadata for a missing run', () => { + const selectStmt = { + get: vi.fn().mockReturnValue(undefined), + } + mockDb.prepare.mockReturnValue(selectStmt) + + const run = schedulesDb.updateScheduleRunMetadata(mockDb, 42, 7, 5, { + sessionTitle: 'Updated title', + }) + + expect(run).toBeNull() + }) + + it('updates schedule run metadata while preserving omitted fields', () => { + const existingRun = makeRunRow({ response_text: 'Existing response', error_text: 'Existing error' }) + const existingStmt = { + get: vi.fn().mockReturnValue(existingRun), + } + const updateStmt = { + run: vi.fn(), + } + const reloadStmt = { + get: vi.fn().mockReturnValue(makeRunRow({ session_title: 'Updated title', response_text: 'Existing response', error_text: 'Existing error' })), + } + + mockDb.prepare + .mockReturnValueOnce(existingStmt) + .mockReturnValueOnce(updateStmt) + .mockReturnValueOnce(reloadStmt) + + const run = schedulesDb.updateScheduleRunMetadata(mockDb, 42, 7, 5, { + sessionTitle: 'Updated title', + }) + + expect(updateStmt.run).toHaveBeenCalledWith( + 'ses-1', + 'Updated title', + 'Run started. Waiting for assistant response...', + 'Existing response', + 'Existing error', + 42, + 7, + 5, + ) + expect(run?.sessionTitle).toBe('Updated title') + }) + + it('creates and reloads a schedule run', () => { + const insertStmt = { + run: vi.fn().mockReturnValue({ lastInsertRowid: 5 }), + } + const selectStmt = { + get: vi.fn().mockReturnValue(makeRunRow()), + } + + mockDb.prepare.mockReturnValueOnce(insertStmt).mockReturnValueOnce(selectStmt) + + const run = schedulesDb.createScheduleRun(mockDb, { + jobId: 7, + repoId: 42, + triggerSource: 'manual', + status: 'running', + startedAt: Date.UTC(2026, 2, 9, 12, 0, 0), + createdAt: Date.UTC(2026, 2, 9, 12, 0, 0), + }) + + expect(insertStmt.run).toHaveBeenCalledWith(7, 42, 'manual', 'running', expect.any(Number), expect.any(Number)) + expect(run.id).toBe(5) + }) + + it('lists active runs and maps persisted fields', () => { + const stmt = { + all: vi.fn().mockReturnValue([ + makeRunRow({ id: 5 }), + makeRunRow({ id: 6, status: 'failed', error_text: 'Model unavailable' }), + ]), + } + mockDb.prepare.mockReturnValue(stmt) + + const runs = schedulesDb.listRunningScheduleRuns(mockDb, 10) + + expect(mockDb.prepare).toHaveBeenCalledWith(expect.stringContaining('WHERE status = \'running\'')) + expect(runs).toHaveLength(2) + expect(runs[1]).toMatchObject({ id: 6, status: 'failed', errorText: 'Model unavailable' }) + }) + + it('lists run summaries without loading large log or response blobs', () => { + const stmt = { + all: vi.fn().mockReturnValue([ + makeRunRow({ log_text: null, response_text: null, error_text: 'Run failed' }), + ]), + } + mockDb.prepare.mockReturnValue(stmt) + + const runs = schedulesDb.listScheduleRunsByJob(mockDb, 42, 7, 5) + + expect(mockDb.prepare).toHaveBeenCalledWith(expect.stringContaining('NULL AS log_text')) + expect(mockDb.prepare).toHaveBeenCalledWith(expect.stringContaining('NULL AS response_text')) + expect(runs[0]).toMatchObject({ + id: 5, + logText: null, + responseText: null, + errorText: 'Run failed', + }) + }) + + it('returns the running run for a job when present', () => { + const stmt = { + get: vi.fn().mockReturnValue(makeRunRow({ id: 8 })), + } + mockDb.prepare.mockReturnValue(stmt) + + const run = schedulesDb.getRunningScheduleRunByJob(mockDb, 42, 7) + + expect(run).toMatchObject({ id: 8, sessionId: 'ses-1' }) + }) +}) diff --git a/backend/test/routes/schedules.test.ts b/backend/test/routes/schedules.test.ts new file mode 100644 index 00000000..1ad376c6 --- /dev/null +++ b/backend/test/routes/schedules.test.ts @@ -0,0 +1,195 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Hono } from 'hono' + +const mocks = vi.hoisted(() => { + class MockScheduleServiceError extends Error { + status: number + + constructor(message: string, status: number) { + super(message) + this.status = status + } + } + + return { + MockScheduleServiceError, + scheduleService: { + listJobs: vi.fn(), + createJob: vi.fn(), + getJob: vi.fn(), + updateJob: vi.fn(), + deleteJob: vi.fn(), + runJob: vi.fn(), + listRuns: vi.fn(), + getRun: vi.fn(), + cancelRun: vi.fn(), + }, + } +}) + +vi.mock('../../src/services/schedules', () => ({ + ScheduleService: vi.fn().mockImplementation(() => mocks.scheduleService), + ScheduleServiceError: mocks.MockScheduleServiceError, +})) + +vi.mock('../../src/utils/logger', () => ({ + logger: { + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, +})) + +import { createScheduleRoutes } from '../../src/routes/schedules' + +describe('Schedule Routes', () => { + let app: Hono + + beforeEach(() => { + vi.clearAllMocks() + app = new Hono() + app.route('/repos/:id/schedules', createScheduleRoutes({} as never)) + }) + + it('lists jobs for a repo', async () => { + mocks.scheduleService.listJobs.mockReturnValue([{ id: 7, name: 'Weekly engineering summary' }]) + + const response = await app.request('/repos/42/schedules') + const body = await response.json() as { jobs: Array<{ id: number }> } + + expect(response.status).toBe(200) + expect(body.jobs).toHaveLength(1) + expect(mocks.scheduleService.listJobs).toHaveBeenCalledWith(42) + }) + + it('creates a schedule from a valid request body', async () => { + mocks.scheduleService.createJob.mockReturnValue({ id: 7, name: 'Daily release summary' }) + + const response = await app.request('/repos/42/schedules', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'Daily release summary', + enabled: true, + scheduleMode: 'interval', + intervalMinutes: 60, + prompt: 'Summarize release readiness.', + }), + }) + const body = await response.json() as { job: { id: number } } + + expect(response.status).toBe(201) + expect(body.job.id).toBe(7) + expect(mocks.scheduleService.createJob).toHaveBeenCalledWith(42, expect.objectContaining({ name: 'Daily release summary' })) + }) + + it('runs a schedule manually', async () => { + mocks.scheduleService.runJob.mockResolvedValue({ id: 5, status: 'running' }) + + const response = await app.request('/repos/42/schedules/7/run', { + method: 'POST', + }) + const body = await response.json() as { run: { id: number; status: string } } + + expect(response.status).toBe(200) + expect(body.run).toEqual({ id: 5, status: 'running' }) + expect(mocks.scheduleService.runJob).toHaveBeenCalledWith(42, 7, 'manual') + }) + + it('cancels a running schedule run', async () => { + mocks.scheduleService.cancelRun.mockResolvedValue({ id: 5, status: 'cancelled' }) + + const response = await app.request('/repos/42/schedules/7/runs/5/cancel', { + method: 'POST', + }) + const body = await response.json() as { run: { status: string } } + + expect(response.status).toBe(200) + expect(body.run.status).toBe('cancelled') + expect(mocks.scheduleService.cancelRun).toHaveBeenCalledWith(42, 7, 5) + }) + + it('maps service conflicts to HTTP 409 responses', async () => { + mocks.scheduleService.runJob.mockRejectedValue(new mocks.MockScheduleServiceError('Schedule is already running', 409)) + + const response = await app.request('/repos/42/schedules/7/run', { + method: 'POST', + }) + const body = await response.json() as { error: string } + + expect(response.status).toBe(409) + expect(body.error).toBe('Schedule is already running') + }) + + it('returns 400 for invalid route ids before reaching the service', async () => { + const response = await app.request('/repos/not-a-number/schedules') + const body = await response.json() as { error: string } + + expect(response.status).toBe(400) + expect(body.error).toBe('Invalid repo id') + expect(mocks.scheduleService.listJobs).not.toHaveBeenCalled() + }) + + it('loads and updates a single schedule job', async () => { + mocks.scheduleService.getJob.mockReturnValue({ id: 7, name: 'Weekly engineering summary' }) + mocks.scheduleService.updateJob.mockReturnValue({ id: 7, name: 'Updated engineering summary' }) + + const getResponse = await app.request('/repos/42/schedules/7') + const getBody = await getResponse.json() as { job: { name: string } } + + const patchResponse = await app.request('/repos/42/schedules/7', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Updated engineering summary' }), + }) + const patchBody = await patchResponse.json() as { job: { name: string } } + + expect(getResponse.status).toBe(200) + expect(getBody.job.name).toBe('Weekly engineering summary') + expect(patchResponse.status).toBe(200) + expect(patchBody.job.name).toBe('Updated engineering summary') + }) + + it('lists runs, loads a single run, and deletes a schedule', async () => { + mocks.scheduleService.listRuns.mockReturnValue([{ id: 5, status: 'completed' }]) + mocks.scheduleService.getRun.mockReturnValue({ id: 5, status: 'completed' }) + mocks.scheduleService.deleteJob.mockReturnValue(undefined) + + const runsResponse = await app.request('/repos/42/schedules/7/runs?limit=5') + const runsBody = await runsResponse.json() as { runs: Array<{ id: number }> } + + const runResponse = await app.request('/repos/42/schedules/7/runs/5') + const runBody = await runResponse.json() as { run: { id: number } } + + const deleteResponse = await app.request('/repos/42/schedules/7', { + method: 'DELETE', + }) + const deleteBody = await deleteResponse.json() as { success: boolean } + + expect(runsResponse.status).toBe(200) + expect(runsBody.runs[0]?.id).toBe(5) + expect(runResponse.status).toBe(200) + expect(runBody.run.id).toBe(5) + expect(deleteResponse.status).toBe(200) + expect(deleteBody.success).toBe(true) + expect(mocks.scheduleService.deleteJob).toHaveBeenCalledWith(42, 7) + }) + + it('rejects non-positive run list limits', async () => { + const response = await app.request('/repos/42/schedules/7/runs?limit=0') + const body = await response.json() as { error: string } + + expect(response.status).toBe(400) + expect(body.error).toBe('Limit must be greater than 0') + expect(mocks.scheduleService.listRuns).not.toHaveBeenCalled() + }) + + it('clamps large run list limits', async () => { + mocks.scheduleService.listRuns.mockReturnValue([]) + + const response = await app.request('/repos/42/schedules/7/runs?limit=500') + + expect(response.status).toBe(200) + expect(mocks.scheduleService.listRuns).toHaveBeenCalledWith(42, 7, 100) + }) +}) diff --git a/backend/test/routes/title.test.ts b/backend/test/routes/title.test.ts new file mode 100644 index 00000000..99275cd7 --- /dev/null +++ b/backend/test/routes/title.test.ts @@ -0,0 +1,150 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolveOpenCodeModel: vi.fn(), + fetch: vi.fn(), + sleep: vi.fn(), +})) + +vi.mock('../../src/services/opencode-models', () => ({ + resolveOpenCodeModel: mocks.resolveOpenCodeModel, +})) + +vi.mock('../../src/utils/logger', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }, +})) + +vi.mock('@opencode-manager/shared/config/env', () => ({ + ENV: { + OPENCODE: { PORT: 5551 }, + }, +})) + +import { createTitleRoutes } from '../../src/routes/title' + +function jsonResponse(body: unknown, status: number = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +function textResponse(body: string, status: number = 200): Response { + return new Response(body, { status }) +} + +describe('Title Routes', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveOpenCodeModel.mockResolvedValue({ providerID: 'openai', modelID: 'gpt-5-mini' }) + vi.stubGlobal('fetch', mocks.fetch) + vi.stubGlobal('Bun', { sleep: mocks.sleep }) + mocks.sleep.mockResolvedValue(undefined) + }) + + it('updates the session title from an immediate JSON prompt response', async () => { + const app = createTitleRoutes() + + mocks.fetch + .mockResolvedValueOnce(jsonResponse({ id: 'title-session-1' })) + .mockResolvedValueOnce(textResponse(JSON.stringify({ + parts: [{ type: 'text', text: 'Refactoring background jobs' }], + }))) + .mockResolvedValueOnce(textResponse('', 200)) + .mockResolvedValueOnce(textResponse('', 200)) + + const response = await app.request('http://localhost/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + directory: '/workspace/repos/sample-project', + }, + body: JSON.stringify({ + text: 'Refactor recurring background jobs and improve observability.', + sessionID: 'session-main-1', + }), + }) + const body = await response.json() as { title: string } + + expect(response.status).toBe(200) + expect(body.title).toBe('Refactoring background jobs') + expect(mocks.fetch).toHaveBeenCalledWith( + 'http://127.0.0.1:5551/session/session-main-1?directory=%2Fworkspace%2Frepos%2Fsample-project', + expect.objectContaining({ method: 'PATCH' }), + ) + }) + + it('polls session messages when the prompt endpoint returns an empty body', async () => { + const app = createTitleRoutes() + + mocks.fetch + .mockResolvedValueOnce(jsonResponse({ id: 'title-session-2' })) + .mockResolvedValueOnce(textResponse('')) + .mockResolvedValueOnce(jsonResponse([ + { + info: { role: 'assistant', time: { completed: Date.now() } }, + parts: [{ type: 'text', text: 'Analyzing recurring job setup' }], + }, + ])) + .mockResolvedValueOnce(textResponse('', 200)) + .mockResolvedValueOnce(textResponse('', 200)) + + const response = await app.request('http://localhost/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + directory: '/workspace/repos/sample-project', + }, + body: JSON.stringify({ + text: 'Why do recurring jobs stop after a restart?', + sessionID: 'session-main-2', + }), + }) + const body = await response.json() as { title: string } + + expect(response.status).toBe(200) + expect(body.title).toBe('Analyzing recurring job setup') + expect(mocks.fetch).toHaveBeenNthCalledWith( + 3, + 'http://127.0.0.1:5551/session/title-session-2/message?directory=%2Fworkspace%2Frepos%2Fsample-project', + ) + }) + + it('returns a 500 when the polled assistant message reports an error', async () => { + const app = createTitleRoutes() + + mocks.fetch + .mockResolvedValueOnce(jsonResponse({ id: 'title-session-3' })) + .mockResolvedValueOnce(textResponse('')) + .mockResolvedValueOnce(jsonResponse([ + { + info: { + role: 'assistant', + error: { data: { message: 'Model unavailable' } }, + }, + parts: [], + }, + ])) + .mockResolvedValueOnce(textResponse('', 200)) + + const response = await app.request('http://localhost/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + directory: '/workspace/repos/sample-project', + }, + body: JSON.stringify({ + text: 'Generate a title for this broken run.', + sessionID: 'session-main-3', + }), + }) + const body = await response.json() as { error: string } + + expect(response.status).toBe(500) + expect(body.error).toBe('Model unavailable') + }) +}) diff --git a/backend/test/services/opencode-models.test.ts b/backend/test/services/opencode-models.test.ts new file mode 100644 index 00000000..1eb4088c --- /dev/null +++ b/backend/test/services/opencode-models.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { proxyToOpenCodeWithDirectory } = vi.hoisted(() => ({ + proxyToOpenCodeWithDirectory: vi.fn(), +})) + +vi.mock('../../src/services/proxy', () => ({ + proxyToOpenCodeWithDirectory, +})) + +import { resolveOpenCodeModel } from '../../src/services/opencode-models' + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) +} + +describe('resolveOpenCodeModel', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns the preferred model when it is available', async () => { + proxyToOpenCodeWithDirectory.mockImplementation((path: string) => { + if (path === '/config') { + return Promise.resolve(jsonResponse({ model: 'openai/gpt-5' })) + } + + return Promise.resolve(jsonResponse({ + providers: [ + { id: 'openai', models: { 'gpt-5': {}, 'gpt-5-mini': {} } }, + ], + default: { openai: 'gpt-5-mini' }, + })) + }) + + const result = await resolveOpenCodeModel('/workspace/repos/sample-project', { + preferredModel: 'openai/gpt-5', + }) + + expect(result).toEqual({ + providerID: 'openai', + modelID: 'gpt-5', + model: 'openai/gpt-5', + }) + }) + + it('falls back to the provider default when the preferred model is unavailable', async () => { + proxyToOpenCodeWithDirectory.mockImplementation((path: string) => { + if (path === '/config') { + return Promise.resolve(jsonResponse({ model: 'openai/gpt-5.4' })) + } + + return Promise.resolve(jsonResponse({ + providers: [ + { id: 'openai', models: { 'gpt-5.3-codex-spark': {}, 'gpt-5-mini': {} } }, + ], + default: { openai: 'gpt-5.3-codex-spark' }, + })) + }) + + const result = await resolveOpenCodeModel('/workspace/repos/sample-project', { + preferredModel: 'openai/gpt-5.4', + }) + + expect(result).toEqual({ + providerID: 'openai', + modelID: 'gpt-5.3-codex-spark', + model: 'openai/gpt-5.3-codex-spark', + }) + }) + + it('prefers the configured small model when requested', async () => { + proxyToOpenCodeWithDirectory.mockImplementation((path: string) => { + if (path === '/config') { + return Promise.resolve(jsonResponse({ + model: 'openai/gpt-5', + small_model: 'openai/gpt-5-mini', + })) + } + + return Promise.resolve(jsonResponse({ + providers: [ + { id: 'openai', models: { 'gpt-5': {}, 'gpt-5-mini': {} } }, + ], + default: { openai: 'gpt-5' }, + })) + }) + + const result = await resolveOpenCodeModel('/workspace/repos/sample-project', { + preferSmallModel: true, + }) + + expect(result).toEqual({ + providerID: 'openai', + modelID: 'gpt-5-mini', + model: 'openai/gpt-5-mini', + }) + }) + + it('falls back to the first available model when defaults are missing', async () => { + proxyToOpenCodeWithDirectory.mockImplementation((path: string) => { + if (path === '/config') { + return Promise.resolve(jsonResponse({})) + } + + return Promise.resolve(jsonResponse({ + providers: [ + { id: 'anthropic', models: { 'claude-sonnet-4': {}, 'claude-haiku-4': {} } }, + ], + })) + }) + + const result = await resolveOpenCodeModel('/workspace/repos/sample-project') + + expect(result).toEqual({ + providerID: 'anthropic', + modelID: 'claude-sonnet-4', + model: 'anthropic/claude-sonnet-4', + }) + }) + + it('throws when no configured models are available', async () => { + proxyToOpenCodeWithDirectory.mockImplementation((path: string) => { + if (path === '/config') { + return Promise.resolve(jsonResponse({ model: 'openai/gpt-5' })) + } + + return Promise.resolve(jsonResponse({ providers: [], default: {} })) + }) + + await expect(resolveOpenCodeModel('/workspace/repos/sample-project')).rejects.toThrow( + 'No configured OpenCode models are available', + ) + }) +}) diff --git a/backend/test/services/schedule-config.test.ts b/backend/test/services/schedule-config.test.ts new file mode 100644 index 00000000..b2cdbb08 --- /dev/null +++ b/backend/test/services/schedule-config.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from 'vitest' +import type { ScheduleJob } from '@opencode-manager/shared/types' +import { + buildCreateSchedulePersistenceInput, + buildUpdatedSchedulePersistenceInput, + computeNextRunAtForJob, +} from '../../src/services/schedule-config' + +describe('schedule-config', () => { + it('builds interval schedule persistence input with trimmed fields', () => { + const currentDate = Date.UTC(2026, 2, 9, 12, 0, 0) + + const result = buildCreateSchedulePersistenceInput({ + name: ' Daily health check ', + description: ' Summarize repo health ', + enabled: true, + scheduleMode: 'interval', + intervalMinutes: 60, + prompt: ' Review the repository and summarize risks. ', + agentSlug: ' code ', + model: ' openai/gpt-5 ', + }, currentDate) + + expect(result).toEqual({ + name: 'Daily health check', + description: 'Summarize repo health', + enabled: true, + scheduleMode: 'interval', + intervalMinutes: 60, + cronExpression: null, + timezone: null, + agentSlug: 'code', + prompt: 'Review the repository and summarize risks.', + model: 'openai/gpt-5', + skillMetadata: undefined, + nextRunAt: currentDate + 60 * 60_000, + }) + }) + + it('defaults cron schedules to UTC and computes the next run', () => { + const currentDate = Date.UTC(2026, 2, 9, 8, 15, 0) + + const result = buildCreateSchedulePersistenceInput({ + name: 'Morning report', + enabled: true, + scheduleMode: 'cron', + cronExpression: ' 0 9 * * * ', + timezone: ' ', + prompt: 'Generate the daily report.', + }, currentDate) + + expect(result.scheduleMode).toBe('cron') + expect(result.cronExpression).toBe('0 9 * * *') + expect(result.timezone).toBe('UTC') + expect(result.nextRunAt).toBe(Date.UTC(2026, 2, 9, 9, 0, 0)) + }) + + it('preserves the existing next run when only prompt text changes', () => { + const existing: ScheduleJob = { + id: 7, + repoId: 42, + name: 'Weekly engineering summary', + description: 'Summarize health', + enabled: true, + scheduleMode: 'interval', + intervalMinutes: 60, + cronExpression: null, + timezone: null, + agentSlug: null, + prompt: 'Old prompt', + model: null, + skillMetadata: null, + nextRunAt: Date.UTC(2026, 2, 9, 13, 0, 0), + lastRunAt: Date.UTC(2026, 2, 9, 12, 0, 0), + createdAt: Date.UTC(2026, 2, 8, 12, 0, 0), + updatedAt: Date.UTC(2026, 2, 9, 12, 0, 0), + } + + const result = buildUpdatedSchedulePersistenceInput(existing, { + prompt: ' New prompt body ', + }, Date.UTC(2026, 2, 9, 12, 30, 0)) + + expect(result.prompt).toBe('New prompt body') + expect(result.nextRunAt).toBe(existing.nextRunAt) + }) + + it('normalizes optional text fields when updating a schedule', () => { + const existing: ScheduleJob = { + id: 10, + repoId: 42, + name: 'Weekly engineering summary', + description: 'Existing description', + enabled: true, + scheduleMode: 'interval', + intervalMinutes: 60, + cronExpression: null, + timezone: null, + agentSlug: 'planner', + prompt: 'Old prompt', + model: 'openai/gpt-5-mini', + skillMetadata: null, + nextRunAt: Date.UTC(2026, 2, 9, 13, 0, 0), + lastRunAt: Date.UTC(2026, 2, 9, 12, 0, 0), + createdAt: Date.UTC(2026, 2, 8, 12, 0, 0), + updatedAt: Date.UTC(2026, 2, 9, 12, 0, 0), + } + + const result = buildUpdatedSchedulePersistenceInput(existing, { + description: ' ', + agentSlug: ' reviewer ', + model: ' ', + }, Date.UTC(2026, 2, 9, 12, 30, 0)) + + expect(result.description).toBeNull() + expect(result.agentSlug).toBe('reviewer') + expect(result.model).toBeNull() + }) + + it('recomputes the next run when a disabled schedule is re-enabled', () => { + const existing: ScheduleJob = { + id: 8, + repoId: 42, + name: 'Paused summary', + description: null, + enabled: false, + scheduleMode: 'interval', + intervalMinutes: 30, + cronExpression: null, + timezone: null, + agentSlug: null, + prompt: 'Run a report', + model: null, + skillMetadata: null, + nextRunAt: null, + lastRunAt: null, + createdAt: Date.UTC(2026, 2, 8, 12, 0, 0), + updatedAt: Date.UTC(2026, 2, 9, 12, 0, 0), + } + + const currentDate = Date.UTC(2026, 2, 9, 14, 0, 0) + const result = buildUpdatedSchedulePersistenceInput(existing, { + enabled: true, + }, currentDate) + + expect(result.enabled).toBe(true) + expect(result.nextRunAt).toBe(currentDate + 30 * 60_000) + }) + + it('throws for invalid cron timezones', () => { + expect(() => buildCreateSchedulePersistenceInput({ + name: 'Invalid timezone', + enabled: true, + scheduleMode: 'cron', + cronExpression: '0 9 * * *', + timezone: 'Mars/Phobos', + prompt: 'Test prompt', + }, Date.UTC(2026, 2, 9, 8, 0, 0))).toThrow('Invalid timezone: Mars/Phobos') + }) + + it('returns null for disabled jobs when computing the next run', () => { + const job: ScheduleJob = { + id: 9, + repoId: 42, + name: 'Disabled summary', + description: null, + enabled: false, + scheduleMode: 'interval', + intervalMinutes: 15, + cronExpression: null, + timezone: null, + agentSlug: null, + prompt: 'Prompt', + model: null, + skillMetadata: null, + nextRunAt: null, + lastRunAt: null, + createdAt: Date.UTC(2026, 2, 8, 12, 0, 0), + updatedAt: Date.UTC(2026, 2, 9, 12, 0, 0), + } + + expect(computeNextRunAtForJob(job, Date.UTC(2026, 2, 9, 12, 0, 0))).toBeNull() + }) +}) diff --git a/backend/test/services/schedules.test.ts b/backend/test/services/schedules.test.ts new file mode 100644 index 00000000..38abbe1c --- /dev/null +++ b/backend/test/services/schedules.test.ts @@ -0,0 +1,770 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ScheduleJob, ScheduleRun } from '@opencode-manager/shared/types' + +const mocks = vi.hoisted(() => ({ + getRepoById: vi.fn(), + createScheduleJob: vi.fn(), + createScheduleRun: vi.fn(), + deleteScheduleJob: vi.fn(), + getScheduleJobById: vi.fn(), + getRunningScheduleRunByJob: vi.fn(), + getScheduleRunById: vi.fn(), + listDueScheduleJobs: vi.fn(), + listRunningScheduleRuns: vi.fn(), + listScheduleJobsByRepo: vi.fn(), + listScheduleRunsByJob: vi.fn(), + reserveScheduleJobNextRun: vi.fn(), + updateScheduleJob: vi.fn(), + updateScheduleJobRunState: vi.fn(), + updateScheduleRun: vi.fn(), + updateScheduleRunMetadata: vi.fn(), + buildCreateSchedulePersistenceInput: vi.fn(), + buildUpdatedSchedulePersistenceInput: vi.fn(), + computeNextRunAtForJob: vi.fn(), + resolveOpenCodeModel: vi.fn(), + proxyToOpenCodeWithDirectory: vi.fn(), + addClient: vi.fn(), + onEvent: vi.fn(), + loggerError: vi.fn(), +})) + +vi.mock('../../src/db/queries', () => ({ + getRepoById: mocks.getRepoById, +})) + +vi.mock('../../src/db/schedules', () => ({ + createScheduleJob: mocks.createScheduleJob, + createScheduleRun: mocks.createScheduleRun, + deleteScheduleJob: mocks.deleteScheduleJob, + getScheduleJobById: mocks.getScheduleJobById, + getRunningScheduleRunByJob: mocks.getRunningScheduleRunByJob, + getScheduleRunById: mocks.getScheduleRunById, + listDueScheduleJobs: mocks.listDueScheduleJobs, + listRunningScheduleRuns: mocks.listRunningScheduleRuns, + listScheduleJobsByRepo: mocks.listScheduleJobsByRepo, + listScheduleRunsByJob: mocks.listScheduleRunsByJob, + reserveScheduleJobNextRun: mocks.reserveScheduleJobNextRun, + updateScheduleJob: mocks.updateScheduleJob, + updateScheduleJobRunState: mocks.updateScheduleJobRunState, + updateScheduleRun: mocks.updateScheduleRun, + updateScheduleRunMetadata: mocks.updateScheduleRunMetadata, +})) + +vi.mock('../../src/services/schedule-config', () => ({ + buildCreateSchedulePersistenceInput: mocks.buildCreateSchedulePersistenceInput, + buildUpdatedSchedulePersistenceInput: mocks.buildUpdatedSchedulePersistenceInput, + computeNextRunAtForJob: mocks.computeNextRunAtForJob, +})) + +vi.mock('../../src/services/opencode-models', () => ({ + resolveOpenCodeModel: mocks.resolveOpenCodeModel, +})) + +vi.mock('../../src/services/proxy', () => ({ + proxyToOpenCodeWithDirectory: mocks.proxyToOpenCodeWithDirectory, +})) + +vi.mock('../../src/services/sse-aggregator', () => ({ + sseAggregator: { + addClient: mocks.addClient, + onEvent: mocks.onEvent, + }, +})) + +vi.mock('../../src/utils/logger', () => ({ + logger: { + error: mocks.loggerError, + info: vi.fn(), + warn: vi.fn(), + }, +})) + +import { ScheduleRunner, ScheduleService } from '../../src/services/schedules' + +function jsonResponse(body: unknown, status: number = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +function textResponse(body: string, status: number = 200): Response { + return new Response(body, { status }) +} + +const repo = { + id: 42, + fullPath: '/workspace/repos/sample-project', + localPath: 'sample-project', + repoUrl: 'https://github.com/example/sample-project', +} + +const job: ScheduleJob = { + id: 7, + repoId: 42, + name: 'Weekly engineering summary', + description: 'Summarize repo health and recent changes.', + enabled: true, + scheduleMode: 'interval', + intervalMinutes: 60, + cronExpression: null, + timezone: null, + agentSlug: null, + prompt: 'Review the repository and summarize the current state.', + model: null, + skillMetadata: null, + nextRunAt: Date.UTC(2026, 2, 9, 13, 0, 0), + lastRunAt: Date.UTC(2026, 2, 9, 12, 0, 0), + createdAt: Date.UTC(2026, 2, 8, 12, 0, 0), + updatedAt: Date.UTC(2026, 2, 9, 12, 0, 0), +} + +const baseRun: ScheduleRun = { + id: 5, + jobId: 7, + repoId: 42, + triggerSource: 'manual', + status: 'running', + startedAt: Date.UTC(2026, 2, 9, 12, 5, 0), + finishedAt: null, + createdAt: Date.UTC(2026, 2, 9, 12, 5, 0), + sessionId: null, + sessionTitle: null, + logText: null, + responseText: null, + errorText: null, +} + +describe('ScheduleService', () => { + beforeEach(() => { + vi.clearAllMocks() + Reflect.get(ScheduleService, 'activeRuns').clear() + + mocks.getRepoById.mockReturnValue(repo) + mocks.getScheduleJobById.mockReturnValue(job) + mocks.getRunningScheduleRunByJob.mockReturnValue(null) + mocks.createScheduleRun.mockReturnValue(baseRun) + mocks.resolveOpenCodeModel.mockResolvedValue({ providerID: 'openai', modelID: 'gpt-5-mini' }) + mocks.addClient.mockReturnValue(vi.fn()) + mocks.onEvent.mockReturnValue(vi.fn()) + mocks.getScheduleRunById.mockReturnValue({ + ...baseRun, + sessionId: 'ses-run-1', + sessionTitle: 'Scheduled: Weekly engineering summary', + logText: 'Run started. Waiting for assistant response...', + }) + }) + + it('starts a run immediately and completes it after polling session messages', async () => { + const service = new ScheduleService({} as never) + const runWithSession: ScheduleRun = { + ...baseRun, + sessionId: 'ses-run-1', + sessionTitle: 'Scheduled: Weekly engineering summary', + logText: 'Run started. Waiting for assistant response...', + } + + mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession) + mocks.proxyToOpenCodeWithDirectory.mockImplementation((path: string, method: string) => { + if (path === '/session' && method === 'POST') { + return Promise.resolve(jsonResponse({ id: 'ses-run-1' })) + } + + if (path === '/session/ses-run-1/message' && method === 'POST') { + return Promise.resolve(textResponse('')) + } + + if (path === '/session/ses-run-1/message' && method === 'GET') { + return Promise.resolve(jsonResponse([ + { + info: { role: 'assistant', time: { completed: Date.now() } }, + parts: [{ type: 'text', text: 'System health is stable.' }], + }, + ])) + } + + throw new Error(`Unexpected proxy request: ${method} ${path}`) + }) + + const result = await service.runJob(42, 7, 'manual') + + expect(result).toEqual(runWithSession) + + await vi.waitFor(() => { + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ + status: 'completed', + responseText: 'System health is stable.', + sessionId: 'ses-run-1', + }), + ) + }) + + expect(mocks.updateScheduleJobRunState).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + expect.objectContaining({ nextRunAt: job.nextRunAt }), + ) + }) + + it('completes a run immediately when the prompt endpoint returns JSON', async () => { + const service = new ScheduleService({} as never) + const runWithSession: ScheduleRun = { + ...baseRun, + sessionId: 'ses-run-2', + sessionTitle: 'Scheduled: Weekly engineering summary', + logText: 'Run started. Waiting for assistant response...', + } + + mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession) + mocks.getScheduleRunById.mockReturnValue(runWithSession) + mocks.proxyToOpenCodeWithDirectory.mockImplementation((path: string, method: string) => { + if (path === '/session' && method === 'POST') { + return Promise.resolve(jsonResponse({ id: 'ses-run-2' })) + } + + if (path === '/session/ses-run-2/message' && method === 'POST') { + return Promise.resolve(textResponse(JSON.stringify({ + parts: [{ type: 'text', text: 'Immediate status summary.' }], + }))) + } + + throw new Error(`Unexpected proxy request: ${method} ${path}`) + }) + + await service.runJob(42, 7, 'manual') + + await vi.waitFor(() => { + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ + status: 'completed', + responseText: 'Immediate status summary.', + }), + ) + }) + }) + + it('rejects a new run when the job already has a running entry', async () => { + const service = new ScheduleService({} as never) + + mocks.getRunningScheduleRunByJob.mockReturnValue({ + ...baseRun, + sessionId: 'ses-existing', + sessionTitle: 'Scheduled: Existing run', + }) + + await expect(service.runJob(42, 7, 'manual')).rejects.toMatchObject({ + message: 'Schedule is already running', + status: 409, + }) + }) + + it('surfaces setup failures when the model cannot be resolved', async () => { + const service = new ScheduleService({} as never) + + mocks.resolveOpenCodeModel.mockRejectedValueOnce(new Error('No configured models are available.')) + mocks.updateScheduleRun.mockReturnValue({ + ...baseRun, + status: 'failed', + finishedAt: Date.UTC(2026, 2, 9, 12, 6, 0), + errorText: 'No configured models are available.', + }) + + await expect(service.runJob(42, 7, 'manual')).rejects.toMatchObject({ + message: 'No configured models are available.', + status: 500, + }) + + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ + status: 'failed', + errorText: 'No configured models are available.', + }), + ) + }) + + it('marks the run failed when prompt submission is rejected after session creation', async () => { + const service = new ScheduleService({} as never) + const runWithSession: ScheduleRun = { + ...baseRun, + sessionId: 'ses-run-6', + sessionTitle: 'Scheduled: Weekly engineering summary', + logText: 'Run started. Waiting for assistant response...', + } + + mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession) + mocks.getScheduleRunById.mockReturnValue(runWithSession) + mocks.proxyToOpenCodeWithDirectory.mockImplementation((path: string, method: string) => { + if (path === '/session' && method === 'POST') { + return Promise.resolve(jsonResponse({ id: 'ses-run-6' })) + } + + if (path === '/session/ses-run-6/message' && method === 'POST') { + return Promise.resolve(textResponse('Provider unavailable', 500)) + } + + throw new Error(`Unexpected proxy request: ${method} ${path}`) + }) + + const result = await service.runJob(42, 7, 'manual') + + expect(result).toEqual(runWithSession) + + await vi.waitFor(() => { + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ + status: 'failed', + errorText: 'Provider unavailable', + sessionId: 'ses-run-6', + }), + ) + }) + }) + + it('cancels an in-progress run by aborting the linked session', async () => { + const service = new ScheduleService({} as never) + const runningRun: ScheduleRun = { + ...baseRun, + sessionId: 'ses-run-3', + sessionTitle: 'Scheduled: Weekly engineering summary', + logText: 'Run started. Waiting for assistant response...', + } + const cancelledRun: ScheduleRun = { + ...runningRun, + status: 'cancelled', + finishedAt: Date.UTC(2026, 2, 9, 12, 10, 0), + errorText: 'Run cancelled by user.', + } + + mocks.getScheduleRunById.mockReturnValue(runningRun) + mocks.updateScheduleRun.mockReturnValue(cancelledRun) + mocks.proxyToOpenCodeWithDirectory.mockImplementation((path: string, method: string) => { + if (path === '/session/ses-run-3/message' && method === 'GET') { + return Promise.resolve(jsonResponse([])) + } + + if (path === '/session/ses-run-3/abort' && method === 'POST') { + return Promise.resolve(textResponse('')) + } + + throw new Error(`Unexpected proxy request: ${method} ${path}`) + }) + + const result = await service.cancelRun(42, 7, 5) + + expect(result).toEqual(cancelledRun) + expect(mocks.proxyToOpenCodeWithDirectory).toHaveBeenCalledWith( + '/session/ses-run-3/abort', + 'POST', + repo.fullPath, + ) + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'cancelled', errorText: 'Run cancelled by user.' }), + ) + }) + + it('rejects cancellation for runs that already finished', async () => { + const service = new ScheduleService({} as never) + + mocks.getScheduleRunById.mockReturnValue({ + ...baseRun, + status: 'completed', + finishedAt: Date.UTC(2026, 2, 9, 12, 10, 0), + responseText: 'Already done', + }) + + await expect(service.cancelRun(42, 7, 5)).rejects.toMatchObject({ + message: 'Only running schedule runs can be cancelled', + status: 409, + }) + }) + + it('cancels a running entry without a linked session', async () => { + const service = new ScheduleService({} as never) + const runningRun: ScheduleRun = { + ...baseRun, + sessionId: null, + sessionTitle: null, + } + const cancelledRun: ScheduleRun = { + ...runningRun, + status: 'cancelled', + finishedAt: Date.UTC(2026, 2, 9, 12, 10, 0), + errorText: 'Run cancelled by user.', + } + + mocks.getScheduleRunById.mockReturnValue(runningRun) + mocks.updateScheduleRun.mockReturnValue(cancelledRun) + + const result = await service.cancelRun(42, 7, 5) + + expect(result).toEqual(cancelledRun) + expect(mocks.proxyToOpenCodeWithDirectory).not.toHaveBeenCalled() + }) + + it('surfaces abort failures when cancellation cannot reach OpenCode', async () => { + const service = new ScheduleService({} as never) + const runningRun: ScheduleRun = { + ...baseRun, + sessionId: 'ses-run-7', + sessionTitle: 'Scheduled: Weekly engineering summary', + } + + mocks.getScheduleRunById.mockReturnValue(runningRun) + mocks.proxyToOpenCodeWithDirectory.mockImplementation((path: string, method: string) => { + if (path === '/session/ses-run-7/message' && method === 'GET') { + return Promise.resolve(jsonResponse([])) + } + + if (path === '/session/ses-run-7/abort' && method === 'POST') { + return Promise.resolve(textResponse('Abort refused', 500)) + } + + throw new Error(`Unexpected proxy request: ${method} ${path}`) + }) + + await expect(service.cancelRun(42, 7, 5)).rejects.toMatchObject({ + message: 'Abort refused', + status: 502, + }) + }) + + it('marks orphaned idle runs as failed during recovery', async () => { + const service = new ScheduleService({} as never) + const orphanedRun: ScheduleRun = { + ...baseRun, + triggerSource: 'schedule', + sessionId: 'ses-run-4', + sessionTitle: 'Scheduled: Weekly engineering summary', + responseText: null, + } + + mocks.listRunningScheduleRuns.mockReturnValue([orphanedRun]) + mocks.proxyToOpenCodeWithDirectory.mockImplementation((path: string, method: string) => { + if (path === '/session/ses-run-4/message' && method === 'GET') { + return Promise.resolve(jsonResponse([ + { + info: { role: 'assistant' }, + parts: [{ type: 'text', text: 'Partial summary' }], + }, + ])) + } + + if (path === '/session/status' && method === 'GET') { + return Promise.resolve(jsonResponse({ + 'ses-run-4': { type: 'idle' }, + })) + } + + throw new Error(`Unexpected proxy request: ${method} ${path}`) + }) + + await service.recoverRunningRuns() + + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ + status: 'failed', + responseText: 'Partial summary', + errorText: expect.stringContaining('interrupted before completion'), + }), + ) + }) + + it('finalizes interrupted runs without a linked session during recovery', async () => { + const service = new ScheduleService({} as never) + + mocks.listRunningScheduleRuns.mockReturnValue([ + { + ...baseRun, + sessionId: null, + sessionTitle: null, + }, + ]) + + await service.recoverRunningRuns() + + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ + status: 'failed', + errorText: expect.stringContaining('no linked session to recover'), + }), + ) + }) + + it('completes recoverable runs when the assistant already finished', async () => { + const service = new ScheduleService({} as never) + const completedRun: ScheduleRun = { + ...baseRun, + triggerSource: 'schedule', + sessionId: 'ses-run-8', + sessionTitle: 'Scheduled: Weekly engineering summary', + } + + mocks.listRunningScheduleRuns.mockReturnValue([completedRun]) + mocks.proxyToOpenCodeWithDirectory.mockImplementation((path: string, method: string) => { + if (path === '/session/ses-run-8/message' && method === 'GET') { + return Promise.resolve(jsonResponse([ + { + info: { role: 'assistant', time: { completed: Date.now() } }, + parts: [{ type: 'text', text: 'Recovered summary' }], + }, + ])) + } + + throw new Error(`Unexpected proxy request: ${method} ${path}`) + }) + + await service.recoverRunningRuns() + + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ + status: 'completed', + responseText: 'Recovered summary', + }), + ) + }) + + it('resumes recoverable runs when the session is still active', async () => { + const service = new ScheduleService({} as never) + const resumedRun: ScheduleRun = { + ...baseRun, + triggerSource: 'schedule', + sessionId: 'ses-run-9', + sessionTitle: 'Scheduled: Weekly engineering summary', + } + let messageRequests = 0 + + mocks.listRunningScheduleRuns.mockReturnValue([resumedRun]) + mocks.getScheduleRunById.mockReturnValue(resumedRun) + mocks.proxyToOpenCodeWithDirectory.mockImplementation((path: string, method: string) => { + if (path === '/session/ses-run-9/message' && method === 'GET') { + messageRequests += 1 + + if (messageRequests === 1) { + return Promise.resolve(jsonResponse([])) + } + + return Promise.resolve(jsonResponse([ + { + info: { role: 'assistant', time: { completed: Date.now() } }, + parts: [{ type: 'text', text: 'Recovered after reconnect' }], + }, + ])) + } + + if (path === '/session/status' && method === 'GET') { + return Promise.resolve(jsonResponse({ + 'ses-run-9': { type: 'busy' }, + })) + } + + throw new Error(`Unexpected proxy request: ${method} ${path}`) + }) + + await service.recoverRunningRuns() + + await vi.waitFor(() => { + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ + status: 'completed', + responseText: 'Recovered after reconnect', + sessionId: 'ses-run-9', + }), + ) + }) + }) + + it('lists jobs and runs through the persistence layer', () => { + const service = new ScheduleService({} as never) + const listedRun = { ...baseRun, status: 'completed', finishedAt: Date.UTC(2026, 2, 9, 12, 10, 0) } + + mocks.listScheduleJobsByRepo.mockReturnValue([job]) + mocks.listScheduleRunsByJob.mockReturnValue([listedRun]) + + expect(service.listJobs(42)).toEqual([job]) + expect(service.listRuns(42, 7, 10)).toEqual([listedRun]) + expect(mocks.listScheduleJobsByRepo).toHaveBeenCalledWith(expect.anything(), 42) + expect(mocks.listScheduleRunsByJob).toHaveBeenCalledWith(expect.anything(), 42, 7, 10) + }) + + it('creates and updates jobs using normalized persistence input', () => { + const service = new ScheduleService({} as never) + const createdJob = { ...job, id: 8, name: 'Daily release summary' } + const updatedJob = { ...job, name: 'Updated release summary' } + + mocks.buildCreateSchedulePersistenceInput.mockReturnValue({ name: 'Daily release summary' }) + mocks.createScheduleJob.mockReturnValue(createdJob) + mocks.buildUpdatedSchedulePersistenceInput.mockReturnValue({ name: 'Updated release summary' }) + mocks.updateScheduleJob.mockReturnValue(updatedJob) + + const createResult = service.createJob(42, { + name: 'Daily release summary', + enabled: true, + scheduleMode: 'interval', + intervalMinutes: 60, + prompt: 'Summarize release readiness.', + }) + const updateResult = service.updateJob(42, 7, { name: 'Updated release summary' }) + + expect(createResult).toEqual(createdJob) + expect(updateResult).toEqual(updatedJob) + expect(mocks.buildCreateSchedulePersistenceInput).toHaveBeenCalled() + expect(mocks.buildUpdatedSchedulePersistenceInput).toHaveBeenCalledWith(job, { name: 'Updated release summary' }) + }) + + it('throws when deleting or loading missing records', () => { + const service = new ScheduleService({} as never) + + mocks.deleteScheduleJob.mockReturnValue(false) + mocks.getScheduleRunById.mockReturnValue(null) + + expect(() => service.deleteJob(42, 7)).toThrow('Schedule not found') + expect(() => service.getRun(42, 7, 5)).toThrow('Run not found') + }) + + it('cancels by finalizing the run when the assistant already completed', async () => { + const service = new ScheduleService({} as never) + const runningRun: ScheduleRun = { + ...baseRun, + sessionId: 'ses-run-5', + sessionTitle: 'Scheduled: Weekly engineering summary', + } + const completedRun: ScheduleRun = { + ...runningRun, + status: 'completed', + finishedAt: Date.UTC(2026, 2, 9, 12, 20, 0), + responseText: 'Completed summary', + } + + mocks.getScheduleRunById.mockReturnValueOnce(runningRun).mockReturnValueOnce(completedRun) + mocks.proxyToOpenCodeWithDirectory.mockImplementation((path: string, method: string) => { + if (path === '/session/ses-run-5/message' && method === 'GET') { + return Promise.resolve(jsonResponse([ + { + info: { role: 'assistant', time: { completed: Date.now() } }, + parts: [{ type: 'text', text: 'Completed summary' }], + }, + ])) + } + + throw new Error(`Unexpected proxy request: ${method} ${path}`) + }) + + const result = await service.cancelRun(42, 7, 5) + + expect(result).toEqual(completedRun) + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'completed', responseText: 'Completed summary' }), + ) + }) +}) + +describe('ScheduleRunner', () => { + it('recovers interrupted runs and starts due scheduled jobs during a tick', async () => { + const scheduleService = { + recoverRunningRuns: vi.fn().mockResolvedValue(undefined), + listDueJobs: vi.fn().mockReturnValue([{ id: 7, repoId: 42 }]), + runJob: vi.fn().mockResolvedValue(undefined), + } + const runner = new ScheduleRunner(scheduleService as never) + + await (runner as unknown as { tick(): Promise }).tick() + + expect(scheduleService.recoverRunningRuns).toHaveBeenCalled() + expect(scheduleService.listDueJobs).toHaveBeenCalledWith(expect.any(Number)) + expect(scheduleService.runJob).toHaveBeenCalledWith(42, 7, 'schedule') + }) + + it('ignores overlapping ticks while a previous pass is still running', async () => { + let resolveRecovery: (() => void) | null = null + const scheduleService = { + recoverRunningRuns: vi.fn().mockImplementation(() => new Promise((resolve) => { + resolveRecovery = resolve + })), + listDueJobs: vi.fn().mockReturnValue([]), + runJob: vi.fn(), + } + const runner = new ScheduleRunner(scheduleService as never) + + const firstTick = (runner as unknown as { tick(): Promise }).tick() + const secondTick = (runner as unknown as { tick(): Promise }).tick() + + expect(scheduleService.recoverRunningRuns).toHaveBeenCalledTimes(1) + + resolveRecovery?.() + + await firstTick + await secondTick + }) + + it('starts one interval and stops cleanly', async () => { + vi.useFakeTimers() + + try { + const scheduleService = { + recoverRunningRuns: vi.fn().mockResolvedValue(undefined), + listDueJobs: vi.fn().mockReturnValue([]), + runJob: vi.fn(), + } + const runner = new ScheduleRunner(scheduleService as never) + + runner.start() + runner.start() + await Promise.resolve() + + expect(scheduleService.recoverRunningRuns).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(30_000) + expect(scheduleService.recoverRunningRuns).toHaveBeenCalledTimes(2) + + runner.stop() + runner.stop() + await vi.advanceTimersByTimeAsync(30_000) + + expect(scheduleService.recoverRunningRuns).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index 703c230f..2425b4fa 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -5,12 +5,16 @@ export default defineConfig({ globals: true, environment: 'node', setupFiles: ['./test/setup.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'html'], + }, env: { NODE_ENV: 'test', PORT: '3001', DATABASE_PATH: ':memory:', AUTH_SECRET: 'test-secret-for-encryption', - WORKSPACE_PATH: '/test/workspace', + WORKSPACE_PATH: '/tmp/test-workspace', }, }, }) diff --git a/docs/features/overview.md b/docs/features/overview.md index e9249f26..1e6ac6c2 100644 --- a/docs/features/overview.md +++ b/docs/features/overview.md @@ -33,6 +33,16 @@ OpenCode Manager provides a comprehensive web interface for managing OpenCode AI [Learn more →](chat.md) +### Schedules & Recurring Jobs + +- **Recurring Repo Jobs** - Run reusable prompts against a repository on an interval or cron schedule +- **Prompt Templates** - Start with built-in reviews for repo health, dependencies, release readiness, docs drift, and more +- **Run History** - Inspect statuses, logs, errors, and assistant output from past runs +- **Session Handoff** - Open the linked OpenCode session for any run and continue from there +- **Manual Runs** - Trigger the same job on demand when you want a fresh report immediately + +[Learn more →](schedules.md) + ### AI Configuration - **Model Selection** - Browse and filter available AI models @@ -94,4 +104,3 @@ OpenCode Manager provides a comprehensive web interface for managing OpenCode AI - **Customizable** - Control which events trigger notifications [Learn more →](notifications.md) - diff --git a/docs/features/schedules.md b/docs/features/schedules.md new file mode 100644 index 00000000..42850e7a --- /dev/null +++ b/docs/features/schedules.md @@ -0,0 +1,119 @@ +# Schedules & Recurring Jobs + +Create recurring repo jobs that run reusable prompts against a repository, store run history, and link every run back to a normal OpenCode session. + +## What Schedules Are Good For + +Schedules make OpenCode Manager proactive instead of purely session-driven. Good examples include: + +- **Repo health reports** for a quick morning review +- **Dependency watchlists** to catch upgrade pressure early +- **Release readiness checks** before a ship window +- **Docs drift reviews** to spot stale setup instructions +- **Tech debt triage** for recurring cleanup planning + +Each run is stored with status, timestamps, logs, assistant output, and a linked session you can open and continue. + +## Creating a Schedule + +1. Open a repository +2. Click **Schedules** +3. Click **New Schedule** +4. Configure the job name, prompt, timing, and optional overrides +5. Save the schedule + +The schedule is scoped to the current repository. + +## Timing Options + +The schedule builder supports both simple presets and advanced cron: + +- **Interval** - Repeat every N minutes +- **Hourly** - Run once each hour at a chosen minute +- **Daily** - Run once per day at a chosen time +- **Weekdays** - Run Monday through Friday +- **Weekly** - Pick one or more weekdays and a time +- **Monthly** - Run on a day of the month +- **Advanced** - Enter a cron expression directly + +Cron-based schedules also store a timezone so runs happen when expected. + +## Prompt Templates + +Built-in prompt templates help you start quickly with recurring jobs like: + +- Repo health report +- Dependency watchlist +- Release readiness review +- Test stability audit +- Docs drift review +- Tech debt triage +- Security and config review +- CI and ops review + +Applying a template fills the schedule name, description, and prompt so you can customize from a strong default instead of starting from scratch. + +## Agent and Model Overrides + +Schedules can run with: + +- the default workspace agent and model +- a custom agent slug +- a specific model override when needed + +If a requested model is no longer available, OpenCode Manager falls back to a valid configured model for that provider so the run can still start when possible. + +## Run History + +Each schedule stores a run history panel with: + +- **Status** - Running, completed, or failed +- **Trigger source** - Manual or scheduled +- **Log output** - Execution metadata and captured results +- **Assistant output** - Rendered markdown preview and raw markdown +- **Errors** - Failure details when a run does not complete + +This makes recurring jobs easy to review without digging through raw session data first. + +## Linked Sessions + +Every run creates or attaches to a normal OpenCode session. + +Use **Open session** when you want to: + +- inspect the original conversation +- continue from the generated report +- answer follow-up questions from the agent +- debug provider, permission, or tool issues + +This keeps automation connected to the rest of the OpenCode Manager workflow instead of creating a separate silo. + +## Best Practices + +- Keep prompts focused on one recurring outcome +- Prefer a few high-value schedules per repo over many overlapping jobs +- Use manual runs to validate a prompt before relying on the schedule +- Review failed runs quickly so broken provider or permission setups do not go unnoticed +- Treat schedules as reusable repo routines, not long-running background workers + +## Troubleshooting + +### Run Failed + +1. Open the run from **Run History** +2. Check the **Error** tab for the failure message +3. Use **Open session** to inspect the underlying session +4. Verify provider credentials, model availability, and any pending agent questions or permissions + +### No Assistant Output + +If a run starts but does not produce assistant output: + +1. Open the linked session +2. Check for a provider error +3. Check whether the agent asked a question or needed permission +4. Re-run the schedule manually after fixing the issue + +### Prompt Needs Iteration + +Use **Run now** to test prompt changes immediately before waiting for the next scheduled run. diff --git a/docs/images/schedules/01-repo-detail-entry.png b/docs/images/schedules/01-repo-detail-entry.png new file mode 100644 index 00000000..4f2cca2c Binary files /dev/null and b/docs/images/schedules/01-repo-detail-entry.png differ diff --git a/docs/images/schedules/02-schedules-shell.png b/docs/images/schedules/02-schedules-shell.png new file mode 100644 index 00000000..8fc37cfa Binary files /dev/null and b/docs/images/schedules/02-schedules-shell.png differ diff --git a/docs/images/schedules/03-run-history-preview.png b/docs/images/schedules/03-run-history-preview.png new file mode 100644 index 00000000..35ca5a0f Binary files /dev/null and b/docs/images/schedules/03-run-history-preview.png differ diff --git a/docs/images/schedules/04-run-history-markdown.png b/docs/images/schedules/04-run-history-markdown.png new file mode 100644 index 00000000..cbb8f79f Binary files /dev/null and b/docs/images/schedules/04-run-history-markdown.png differ diff --git a/docs/images/schedules/05-schedules-expanded-details.png b/docs/images/schedules/05-schedules-expanded-details.png new file mode 100644 index 00000000..d84f6d25 Binary files /dev/null and b/docs/images/schedules/05-schedules-expanded-details.png differ diff --git a/docs/images/schedules/06-modal-general.png b/docs/images/schedules/06-modal-general.png new file mode 100644 index 00000000..c0565b32 Binary files /dev/null and b/docs/images/schedules/06-modal-general.png differ diff --git a/docs/images/schedules/07-modal-timing.png b/docs/images/schedules/07-modal-timing.png new file mode 100644 index 00000000..2abed1ce Binary files /dev/null and b/docs/images/schedules/07-modal-timing.png differ diff --git a/docs/images/schedules/08-modal-prompt.png b/docs/images/schedules/08-modal-prompt.png new file mode 100644 index 00000000..29a41777 Binary files /dev/null and b/docs/images/schedules/08-modal-prompt.png differ diff --git a/docs/index.md b/docs/index.md index 74902e53..ed67e6ae 100644 --- a/docs/index.md +++ b/docs/index.md @@ -21,6 +21,7 @@ OpenCode Manager provides a web-based interface for OpenCode AI agents, allowing - **Manage repositories** - Clone, browse, and work with multiple git repos - **Chat with AI** - Real-time streaming chat with file mentions and slash commands +- **Run recurring jobs** - Schedule repo reviews, health checks, and other reusable prompts - **View diffs** - See code changes with syntax highlighting - **Control from anywhere** - Mobile-first PWA with push notifications - **Configure AI** - Manage models, providers, and MCP servers @@ -30,6 +31,7 @@ OpenCode Manager provides a web-based interface for OpenCode AI agents, allowing - **Multi-Repository Support** - Clone and manage multiple git repos with private repo support - **Git Integration** - View diffs, manage branches, create PRs directly from the UI - **Real-time Chat** - Stream responses with file mentions and custom slash commands +- **Scheduled Repo Jobs** - Run recurring prompts with linked sessions, logs, and reviewable output - **Mobile-First PWA** - Install as an app on any device with push notifications - **Push Notifications** - Get background alerts for agent events when app is closed - **AI Configuration** - Configure models, providers, OAuth, and custom agents @@ -46,5 +48,6 @@ OpenCode Manager provides a web-based interface for OpenCode AI agents, allowing - [Installation Guide](getting-started/installation.md) - Detailed setup instructions - [Quick Start](getting-started/quickstart.md) - Get up and running fast - [Features Overview](features/overview.md) - Explore all features +- [Schedules & Recurring Jobs](features/schedules.md) - Automate recurring repo reviews and follow-ups - [Memory Plugin](features/memory.md) - Persistent project knowledge with semantic search - [Configuration](configuration/environment.md) - Environment variables and setup diff --git a/frontend/package.json b/frontend/package.json index 9acb2891..809c8e4b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,6 +33,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "cronstrue": "^3.13.0", "date-fns": "^4.1.0", "diff": "^8.0.2", "highlight.js": "^11.11.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c6a4a69d..d9c6322c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import { Repos } from './pages/Repos' import { RepoDetail } from './pages/RepoDetail' import { SessionDetail } from './pages/SessionDetail' import { Memories } from './pages/Memories' +import { Schedules } from './pages/Schedules' import { Login } from './pages/Login' import { Register } from './pages/Register' import { Setup } from './pages/Setup' @@ -136,6 +137,11 @@ const router = createBrowserRouter([ element: , loader: protectedLoader, }, + { + path: '/repos/:id/schedules', + element: , + loader: protectedLoader, + }, ], }, ]) diff --git a/frontend/src/api/schedules.ts b/frontend/src/api/schedules.ts new file mode 100644 index 00000000..cf5553af --- /dev/null +++ b/frontend/src/api/schedules.ts @@ -0,0 +1,58 @@ +import { fetchWrapper, fetchWrapperVoid } from './fetchWrapper' +import { API_BASE_URL } from '@/config' +import type { + CreateScheduleJobRequest, + ScheduleJob, + ScheduleRun, + UpdateScheduleJobRequest, +} from '@opencode-manager/shared/types' + +export async function listRepoSchedules(repoId: number): Promise<{ jobs: ScheduleJob[] }> { + return fetchWrapper(`${API_BASE_URL}/api/repos/${repoId}/schedules`) +} + +export async function getRepoSchedule(repoId: number, jobId: number): Promise<{ job: ScheduleJob }> { + return fetchWrapper(`${API_BASE_URL}/api/repos/${repoId}/schedules/${jobId}`) +} + +export async function createRepoSchedule(repoId: number, data: CreateScheduleJobRequest): Promise<{ job: ScheduleJob }> { + return fetchWrapper(`${API_BASE_URL}/api/repos/${repoId}/schedules`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) +} + +export async function updateRepoSchedule(repoId: number, jobId: number, data: UpdateScheduleJobRequest): Promise<{ job: ScheduleJob }> { + return fetchWrapper(`${API_BASE_URL}/api/repos/${repoId}/schedules/${jobId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) +} + +export async function deleteRepoSchedule(repoId: number, jobId: number): Promise { + return fetchWrapperVoid(`${API_BASE_URL}/api/repos/${repoId}/schedules/${jobId}`, { + method: 'DELETE', + }) +} + +export async function runRepoSchedule(repoId: number, jobId: number): Promise<{ run: ScheduleRun }> { + return fetchWrapper(`${API_BASE_URL}/api/repos/${repoId}/schedules/${jobId}/run`, { + method: 'POST', + }) +} + +export async function listRepoScheduleRuns(repoId: number, jobId: number, limit: number = 20): Promise<{ runs: ScheduleRun[] }> { + return fetchWrapper(`${API_BASE_URL}/api/repos/${repoId}/schedules/${jobId}/runs?limit=${limit}`) +} + +export async function getRepoScheduleRun(repoId: number, jobId: number, runId: number): Promise<{ run: ScheduleRun }> { + return fetchWrapper(`${API_BASE_URL}/api/repos/${repoId}/schedules/${jobId}/runs/${runId}`) +} + +export async function cancelRepoScheduleRun(repoId: number, jobId: number, runId: number): Promise<{ run: ScheduleRun }> { + return fetchWrapper(`${API_BASE_URL}/api/repos/${repoId}/schedules/${jobId}/runs/${runId}/cancel`, { + method: 'POST', + }) +} diff --git a/frontend/src/components/schedules/ScheduleJobDialog.tsx b/frontend/src/components/schedules/ScheduleJobDialog.tsx new file mode 100644 index 00000000..77f38fd9 --- /dev/null +++ b/frontend/src/components/schedules/ScheduleJobDialog.tsx @@ -0,0 +1,502 @@ +import { useEffect, useMemo, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import type { CreateScheduleJobRequest, ScheduleJob } from '@opencode-manager/shared/types' +import { getProvidersWithModels } from '@/api/providers' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Combobox, type ComboboxOption } from '@/components/ui/combobox' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Textarea } from '@/components/ui/textarea' +import { + buildCronExpressionFromPreset, + cronPresetOptions, + detectSchedulePreset, + formatDraftScheduleSummary, + getLocalTimeZone, + intervalOptions, + promptTemplateOptions, + schedulePresetOptions, + type PromptTemplateOption, + type SchedulePreset, + weekdayOptions, +} from '@/components/schedules/schedule-utils' +import { Info, Loader2, Sparkles } from 'lucide-react' + +type ScheduleJobDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void + job?: ScheduleJob + isSaving: boolean + onSubmit: (data: CreateScheduleJobRequest) => void +} + +function InfoHint({ text }: { text: string }) { + return ( + + + + ) +} + +export function ScheduleJobDialog({ open, onOpenChange, job, isSaving, onSubmit }: ScheduleJobDialogProps) { + const [schedulePreset, setSchedulePreset] = useState('interval') + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [enabled, setEnabled] = useState(true) + const [intervalMinutes, setIntervalMinutes] = useState('60') + const [timeOfDay, setTimeOfDay] = useState('09:00') + const [hourlyMinute, setHourlyMinute] = useState('0') + const [weeklyDays, setWeeklyDays] = useState(['1']) + const [monthlyDay, setMonthlyDay] = useState('1') + const [cronExpression, setCronExpression] = useState('0 9 * * 1-5') + const [timezone, setTimezone] = useState(getLocalTimeZone()) + const [agentSlug, setAgentSlug] = useState('') + const [model, setModel] = useState('') + const [prompt, setPrompt] = useState('') + const [selectedPromptTemplateId, setSelectedPromptTemplateId] = useState(null) + const [skillSlugs, setSkillSlugs] = useState('') + const [skillNotes, setSkillNotes] = useState('') + + const { data: providerModels = [] } = useQuery({ + queryKey: ['providers-with-models', 'schedule-dialog'], + queryFn: () => getProvidersWithModels(), + enabled: open, + staleTime: 5 * 60 * 1000, + }) + + const modelOptions = useMemo(() => { + return providerModels.flatMap((provider) => + provider.models.map((providerModel) => ({ + value: `${provider.id}/${providerModel.id}`, + label: providerModel.name || providerModel.id, + description: `${provider.id}/${providerModel.id}`, + group: provider.name, + })), + ) + }, [providerModels]) + + useEffect(() => { + if (!open) { + return + } + + setName(job?.name ?? '') + setDescription(job?.description ?? '') + setEnabled(job?.enabled ?? true) + const scheduleDefaults = detectSchedulePreset(job) + setSchedulePreset(scheduleDefaults.preset) + setIntervalMinutes(scheduleDefaults.intervalMinutes) + setTimeOfDay(scheduleDefaults.timeOfDay) + setHourlyMinute(scheduleDefaults.hourlyMinute) + setWeeklyDays(scheduleDefaults.weeklyDays) + setMonthlyDay(scheduleDefaults.monthlyDay) + setCronExpression(scheduleDefaults.cronExpression) + setTimezone(scheduleDefaults.timezone) + setAgentSlug(job?.agentSlug ?? '') + setModel(job?.model ?? '') + setPrompt(job?.prompt ?? '') + setSelectedPromptTemplateId(promptTemplateOptions.find((template) => template.prompt === (job?.prompt ?? ''))?.id ?? null) + setSkillSlugs(job?.skillMetadata?.skillSlugs.join(', ') ?? '') + setSkillNotes(job?.skillMetadata?.notes ?? '') + }, [job, open]) + + const applyPromptTemplate = (template: PromptTemplateOption) => { + setSelectedPromptTemplateId(template.id) + setName(template.suggestedName) + setDescription(template.suggestedDescription) + setPrompt(template.prompt) + } + + const handleSubmit = () => { + const parsedInterval = Number.parseInt(intervalMinutes, 10) + const resolvedCronExpression = buildCronExpressionFromPreset({ + preset: schedulePreset, + intervalMinutes, + timeOfDay, + hourlyMinute, + weeklyDays, + monthlyDay, + cronExpression, + }) + const baseFields = { + name: name.trim(), + description: description.trim() || undefined, + enabled, + agentSlug: agentSlug.trim() || undefined, + model: model.trim() || undefined, + prompt: prompt.trim(), + skillMetadata: skillSlugs.trim() || skillNotes.trim() + ? { + skillSlugs: skillSlugs.split(',').map((value) => value.trim()).filter(Boolean), + notes: skillNotes.trim() || undefined, + } + : undefined, + } + + if (schedulePreset !== 'interval') { + onSubmit({ + ...baseFields, + scheduleMode: 'cron', + cronExpression: resolvedCronExpression, + timezone: timezone.trim() || 'UTC', + }) + return + } + + onSubmit({ + ...baseFields, + scheduleMode: 'interval', + intervalMinutes: Number.isNaN(parsedInterval) ? 60 : parsedInterval, + }) + } + + const isScheduleConfigInvalid = + (schedulePreset === 'advanced' && (!cronExpression.trim() || !timezone.trim())) || + ((schedulePreset === 'daily' || schedulePreset === 'weekdays' || schedulePreset === 'weekly' || schedulePreset === 'monthly') && !timezone.trim()) || + (schedulePreset === 'weekly' && weeklyDays.length === 0) + + return ( + + + + {job ? 'Edit schedule' : 'New schedule'} + + Create a reusable repo job with a visual schedule builder, manual runs, and optional advanced metadata. + + + + +
+ + General + Timing + Prompt + Advanced + +
+ + +
+
+
+ + setName(event.target.value)} placeholder="Nightly repo health check" /> +
+ +
+ + setDescription(event.target.value)} placeholder="What this job checks or produces" /> +
+
+ +
+
+ + setAgentSlug(event.target.value)} placeholder="code" /> +
+ +
+
+ + +
+ +
+
+ +
+
+
+

Enabled

+ +
+ +
+
+
+
+ + +
+
+
+ +

Use a simple scheduler builder by default. Advanced cron is still available if you need it.

+
+ +
+ {schedulePresetOptions.map((option) => ( + + ))} +
+
+ + {schedulePreset === 'interval' ? ( +
+
+ + setIntervalMinutes(event.target.value)} + className="w-28" + /> +
+ +
+ {intervalOptions.map((option) => ( + + ))} +
+
+ ) : schedulePreset === 'hourly' ? ( +
+
+ + setHourlyMinute(event.target.value)} + /> +
+

Run every hour at the selected minute mark.

+
+ ) : schedulePreset === 'daily' || schedulePreset === 'weekdays' ? ( +
+
+ + setTimeOfDay(event.target.value)} /> +
+
+ + setTimezone(event.target.value)} placeholder="Detected from browser" /> +
+
+ ) : schedulePreset === 'weekly' ? ( +
+
+ +
+ {weekdayOptions.map((option) => { + const selected = weeklyDays.includes(option.value) + + return ( + + ) + })} +
+
+ +
+
+ + setTimeOfDay(event.target.value)} /> +
+
+ + setTimezone(event.target.value)} placeholder="Detected from browser" /> +
+
+
+ ) : schedulePreset === 'monthly' ? ( +
+
+
+ + setMonthlyDay(event.target.value)} + /> +
+
+ + setTimeOfDay(event.target.value)} /> +
+
+ + setTimezone(event.target.value)} placeholder="Detected from browser" /> +
+
+
+ ) : ( +
+
+ + setCronExpression(event.target.value)} + placeholder="0 9 * * 1-5" + className="font-mono" + /> +

Examples: `0 9 * * 1-5` weekdays at 9 AM, `30 6 1 * *` monthly on the 1st at 6:30 AM.

+
+ +
+ + setTimezone(event.target.value)} + placeholder="Detected from browser" + /> +
+ +
+ {cronPresetOptions.map((option) => ( + + ))} +
+
+ )} + +
+

Schedule Preview

+

{formatDraftScheduleSummary({ preset: schedulePreset, intervalMinutes, timeOfDay, hourlyMinute, weeklyDays, monthlyDay, cronExpression, timezone })}

+ {schedulePreset !== 'interval' && ( +

+ {buildCronExpressionFromPreset({ preset: schedulePreset, intervalMinutes, timeOfDay, hourlyMinute, weeklyDays, monthlyDay, cronExpression })} +

+ )} +
+
+
+ + +
+
+
+ +

Applying a template fills the name, description, and prompt.

+
+ +
+ {promptTemplateOptions.map((template) => { + const isSelected = selectedPromptTemplateId === template.id + + return ( + + ) + })} +
+
+ +
+ +