You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
wiki/backend/api/scheduler.ts

297 lines
7.5 KiB

import type { FastifyInstance } from 'fastify'
import { JOB_STATES, type JobState } from '../models/jobs.ts'
/**
* Scheduler API Routes
*/
async function routes(app: FastifyInstance) {
/**
* LIST SCHEDULED TASKS
*/
app.get(
'/schedule',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'List the cron schedule',
description:
'The tasks the scheduler runs automatically. These are definitions, not executions — the jobs they produce show up under upcoming and then in the history.',
tags: ['Scheduler'],
response: {
200: {
description: 'List of scheduled tasks',
type: 'array',
items: { $ref: 'SchedulerTask#' }
}
}
}
},
async () => {
return WIKI.models.jobs.getSchedule()
}
)
/**
* RUN A SCHEDULED TASK NOW
*/
app.post<{ Params: { scheduleId: string } }>(
'/schedule/:scheduleId/run',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Run a scheduled task now',
description:
'Queues the task immediately, without waiting for its cron expression and without disturbing the planned iterations. The run is recorded in the history like any other job.',
tags: ['Scheduler'],
params: {
type: 'object',
properties: {
scheduleId: {
type: 'string',
format: 'uuid'
}
},
required: ['scheduleId']
},
response: {
200: {
description: 'Task queued successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
},
id: {
type: 'string',
format: 'uuid',
description: 'The ID of the queued job.'
}
}
}
}
}
},
async (req, reply) => {
const entry = await WIKI.models.jobs.getScheduleEntry(req.params.scheduleId)
if (!entry) {
return reply.notFound('Scheduled task does not exist.')
}
const id = await WIKI.models.jobs.runScheduledTask(entry)
if (!id) {
return reply.internalServerError('The scheduler could not queue the job.')
}
return {
ok: true,
message: 'Task queued successfully.',
id
}
}
)
/**
* LIST UPCOMING JOBS
*/
app.get(
'/upcoming',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'List the pending job queue',
description:
'Jobs waiting to be picked up, soonest first. A job with no `waitUntil` is eligible immediately.',
tags: ['Scheduler'],
response: {
200: {
description: 'List of upcoming jobs',
type: 'array',
items: { $ref: 'SchedulerUpcomingJob#' }
}
}
}
},
async () => {
return WIKI.models.jobs.getUpcoming()
}
)
/**
* CANCEL UPCOMING JOB
*/
app.delete<{ Params: { jobId: string } }>(
'/upcoming/:jobId',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Cancel a pending job',
description:
'Removes the job from the queue. A job that an instance has already picked up cannot be cancelled and answers 404, as it is no longer pending.',
tags: ['Scheduler'],
params: {
type: 'object',
properties: {
jobId: {
type: 'string',
format: 'uuid'
}
},
required: ['jobId']
},
response: {
204: {
description: 'Job cancelled successfully'
}
}
}
},
async (req, reply) => {
const cancelled = await WIKI.models.jobs.cancelUpcoming(req.params.jobId)
if (!cancelled) {
return reply.notFound('No pending job with this ID.')
}
return reply.code(204).send()
}
)
/**
* LIST JOB HISTORY
*/
app.get<{ Querystring: { states?: JobState[]; limit?: number } }>(
'/jobs',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'List job execution history',
description:
'Past and running jobs, most recently started first. Older entries are purged by the `cleanJobHistory` task.',
tags: ['Scheduler'],
querystring: {
type: 'object',
properties: {
states: {
type: 'array',
description: 'Keep only jobs in these states. All states when omitted.',
items: {
type: 'string',
enum: JOB_STATES
}
},
limit: { type: 'integer', minimum: 1, maximum: 500, default: 100 }
}
},
response: {
200: {
description: 'List of jobs',
type: 'object',
properties: {
total: {
type: 'integer',
description:
'How many jobs match the requested states, which can exceed the number returned.'
},
limit: {
type: 'integer'
},
jobs: {
type: 'array',
items: { $ref: 'SchedulerJob#' }
}
}
}
}
}
},
async (req) => {
const limit = req.query.limit ?? 100
const { total, jobs } = await WIKI.models.jobs.getHistory({
states: req.query.states ?? [],
limit
})
return { total, limit, jobs }
}
)
/**
* RETRY JOB
*/
app.post<{ Params: { jobId: string } }>(
'/jobs/:jobId/retry',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Run a past job again',
description:
'Queues a new job with the same task and payload. The original history entry is left as it is, and the new run is recorded separately with a full retry budget.',
tags: ['Scheduler'],
params: {
type: 'object',
properties: {
jobId: {
type: 'string',
format: 'uuid'
}
},
required: ['jobId']
},
response: {
200: {
description: 'Job queued successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
},
id: {
type: 'string',
format: 'uuid',
description: 'The ID of the newly queued job, not the one it was created from.'
}
}
}
}
}
},
async (req, reply) => {
const entry = await WIKI.models.jobs.getHistoryEntry(req.params.jobId)
if (!entry) {
return reply.notFound('Job does not exist.')
}
if (entry.state === 'active') {
return reply.conflict('This job is still running.')
}
const id = await WIKI.models.jobs.retryJob(entry)
if (!id) {
return reply.internalServerError('The scheduler could not queue the job.')
}
return {
ok: true,
message: 'Job queued successfully.',
id
}
}
)
}
export default routes