feat: implement full-stack TaskPilot scheduler

This commit is contained in:
2026-10-18 22:05:45 +08:00
parent d88c21753d
commit 8ce02af13a
72 changed files with 17812 additions and 0 deletions
+234
View File
@@ -0,0 +1,234 @@
import type { components } from './generated'
export type HealthResponse = components['schemas']['HealthResponse']
export type Task = components['schemas']['TaskResponse']
export type TaskAction = components['schemas']['TaskAction']
export type TaskActionType = components['schemas']['TaskActionType']
export type CreateTaskRequest = components['schemas']['CreateTaskRequest']
export type UpdateTaskRequest = components['schemas']['UpdateTaskRequest']
export type TaskListResponse = components['schemas']['TaskListResponse']
export type TaskRun = components['schemas']['TaskRunResponse']
export type TaskRunListResponse = components['schemas']['TaskRunListResponse']
export type RunStatus = components['schemas']['RunStatus']
export type RunTrigger = components['schemas']['RunTrigger']
export type LogChunkResponse = components['schemas']['LogChunkResponse']
export type ErrorResponse = components['schemas']['ErrorResponse']
export type SessionResponse = components['schemas']['SessionResponse']
export type CronPreviewRequest = components['schemas']['CronPreviewRequest']
export type CronPreviewResponse = components['schemas']['CronPreviewResponse']
export interface TaskFilters {
q?: string
enabled?: boolean
action_type?: TaskActionType
page?: number
page_size?: number
}
export interface RunFilters {
page?: number
page_size?: number
status?: RunStatus
trigger?: RunTrigger
}
export type ExecutionEvent =
| {
event: 'run'
data: {
run: TaskRun
}
}
| {
event: 'log'
data: {
run_id: number
offset: number
next_offset: number
content: string
}
}
export class ApiRequestError extends Error {
readonly status: number
readonly code?: string
readonly field?: string | null
readonly details?: Record<string, string>
constructor(status: number, body?: ErrorResponse) {
super(body?.message ?? `请求失败(HTTP ${status})`)
this.name = 'ApiRequestError'
this.status = status
this.code = body?.code
this.field = body?.field
this.details = body?.details
}
}
async function requestJson<T>(
path: string,
init: RequestInit = {},
): Promise<T> {
const headers = new Headers(init.headers)
headers.set('Accept', 'application/json')
const method = (init.method ?? 'GET').toUpperCase()
if (init.body) {
headers.set('Content-Type', 'application/json')
}
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
headers.set('X-TaskPilot-Token', await fetchSessionToken())
}
const response = await fetch(path, {
...init,
headers,
})
if (!response.ok) {
let body: ErrorResponse | undefined
try {
body = (await response.json()) as ErrorResponse
} catch {
body = undefined
}
throw new ApiRequestError(response.status, body)
}
if (response.status === 204) {
return undefined as T
}
return (await response.json()) as T
}
async function fetchSessionToken(): Promise<string> {
const response = await fetch('/api/v1/session', {
headers: { Accept: 'application/json' },
})
if (!response.ok) {
throw new ApiRequestError(response.status)
}
const session = (await response.json()) as SessionResponse
return session.token
}
function withQuery(path: string, values: object): string {
const query = new URLSearchParams()
for (const [key, value] of Object.entries(values)) {
if (value !== undefined && value !== null && value !== '') {
query.set(key, String(value))
}
}
const serialized = query.toString()
return serialized ? `${path}?${serialized}` : path
}
export function fetchHealth(signal?: AbortSignal): Promise<HealthResponse> {
return requestJson<HealthResponse>('/api/v1/health', { signal })
}
export function listTasks(
filters: TaskFilters = {},
signal?: AbortSignal,
): Promise<TaskListResponse> {
return requestJson<TaskListResponse>(
withQuery('/api/v1/tasks', filters),
{ signal },
)
}
export function getTask(id: string, signal?: AbortSignal): Promise<Task> {
return requestJson<Task>(`/api/v1/tasks/${encodeURIComponent(id)}`, {
signal,
})
}
export function createTask(payload: CreateTaskRequest): Promise<Task> {
return requestJson<Task>('/api/v1/tasks', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export function updateTask(
id: string,
payload: UpdateTaskRequest,
): Promise<Task> {
return requestJson<Task>(`/api/v1/tasks/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(payload),
})
}
export function deleteTask(id: string): Promise<void> {
return requestJson<void>(`/api/v1/tasks/${encodeURIComponent(id)}`, {
method: 'DELETE',
})
}
export function setTaskEnabled(
id: string,
enabled: boolean,
): Promise<Task> {
const action = enabled ? 'enable' : 'disable'
return requestJson<Task>(
`/api/v1/tasks/${encodeURIComponent(id)}/${action}`,
{ method: 'POST' },
)
}
export function runTask(id: string): Promise<TaskRun> {
return requestJson<TaskRun>(
`/api/v1/tasks/${encodeURIComponent(id)}/run`,
{ method: 'POST' },
)
}
export function listTaskRuns(
taskId: string,
filters: RunFilters = {},
signal?: AbortSignal,
): Promise<TaskRunListResponse> {
return requestJson<TaskRunListResponse>(
withQuery(`/api/v1/tasks/${encodeURIComponent(taskId)}/runs`, filters),
{ signal },
)
}
export function getRun(
runId: number,
signal?: AbortSignal,
): Promise<TaskRun> {
return requestJson<TaskRun>(`/api/v1/runs/${runId}`, { signal })
}
export function getRunLogs(
runId: number,
offset = 0,
signal?: AbortSignal,
): Promise<LogChunkResponse> {
return requestJson<LogChunkResponse>(
withQuery(`/api/v1/runs/${runId}/logs`, { offset }),
{ signal },
)
}
export function cancelRun(runId: number): Promise<TaskRun> {
return requestJson<TaskRun>(`/api/v1/runs/${runId}/cancel`, {
method: 'POST',
})
}
export function runLogDownloadUrl(runId: number): string {
return `/api/v1/runs/${runId}/logs/download`
}
export function previewCron(
request: CronPreviewRequest,
signal?: AbortSignal,
): Promise<CronPreviewResponse> {
return requestJson<CronPreviewResponse>('/api/v1/cron/preview', {
method: 'POST',
body: JSON.stringify(request),
signal,
})
}
+940
View File
@@ -0,0 +1,940 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
"/api/v1/cron/preview": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["preview_cron"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/events": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["execution_events"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/health": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["health"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/runs/{run_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["get_run"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/runs/{run_id}/cancel": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["cancel_run"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/runs/{run_id}/logs": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["get_run_logs"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/runs/{run_id}/logs/download": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["download_run_logs"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/session": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["session"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/tasks": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["list_tasks"];
put?: never;
post: operations["create_task"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/tasks/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["get_task"];
put: operations["update_task"];
post?: never;
delete: operations["delete_task"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/tasks/{id}/disable": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["disable_task"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/tasks/{id}/enable": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["enable_task"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/tasks/{id}/run": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["run_task"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/tasks/{id}/runs": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["list_task_runs"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/** @enum {string} */
ConcurrencyPolicy: "skip";
CreateTaskRequest: {
action: components["schemas"]["TaskAction"];
concurrency_policy?: components["schemas"]["ConcurrencyPolicy"];
cron: string;
enabled?: boolean;
id: string;
name: string;
retry_policy?: components["schemas"]["RetryPolicy"];
/** Format: int64 */
timeout_secs?: number;
timezone?: string;
};
CronPreviewRequest: {
count: number;
expression: string;
timezone: string;
};
CronPreviewResponse: {
runs: string[];
timezone: string;
};
ErrorResponse: {
code: string;
details: {
[key: string]: string;
};
field?: string | null;
message: string;
};
HealthResponse: {
/** @description Stable service identifier. */
service: string;
/** @description Current service status. */
status: string;
/** @description Backend package version. */
version: string;
};
/** @enum {string} */
HttpMethod: "GET" | "POST";
LogChunkResponse: {
complete: boolean;
content: string;
/** Format: int64 */
next_offset: number;
};
RetryPolicy: {
/** Format: int64 */
delay_secs?: number;
/** Format: int32 */
max_attempts?: number;
};
/** @enum {string} */
RunStatus: "queued" | "running" | "succeeded" | "failed" | "timed_out" | "cancelled" | "skipped";
/** @enum {string} */
RunTrigger: "schedule" | "manual" | "retry";
SessionResponse: {
/** @description Random per-process token required for mutating browser requests. */
token: string;
};
TaskAction: {
args?: string[];
env?: {
[key: string]: string;
};
interpreter: string;
script: string;
/** @enum {string} */
type: "script";
working_dir: string;
} | {
body?: string | null;
expected_status?: number[];
headers?: {
[key: string]: string;
};
method: components["schemas"]["HttpMethod"];
/** @enum {string} */
type: "http";
url: string;
} | {
args?: string[];
env?: {
[key: string]: string;
};
executable: string;
/** @enum {string} */
type: "process";
working_dir?: string | null;
};
/** @enum {string} */
TaskActionType: "script" | "http" | "process";
TaskListResponse: {
items: components["schemas"]["TaskResponse"][];
/** Format: int32 */
page: number;
/** Format: int32 */
page_size: number;
/** Format: int64 */
total: number;
};
TaskResponse: {
action: components["schemas"]["TaskAction"];
concurrency_policy: components["schemas"]["ConcurrencyPolicy"];
/** Format: date-time */
created_at: string;
cron: string;
enabled: boolean;
id: string;
name: string;
retry_policy: components["schemas"]["RetryPolicy"];
/** Format: int64 */
timeout_secs: number;
timezone: string;
/** Format: date-time */
updated_at: string;
};
TaskRunListResponse: {
items: components["schemas"]["TaskRunResponse"][];
/** Format: int32 */
page: number;
/** Format: int32 */
page_size: number;
/** Format: int64 */
total: number;
};
TaskRunResponse: {
/** Format: date-time */
created_at: string;
/** Format: int64 */
duration_ms?: number | null;
error_message?: string | null;
/** Format: int32 */
exit_code?: number | null;
/** Format: date-time */
finished_at?: string | null;
/** Format: int64 */
id: number;
/** Format: date-time */
started_at?: string | null;
status: components["schemas"]["RunStatus"];
task_id: string;
trigger: components["schemas"]["RunTrigger"];
};
UpdateTaskRequest: {
action: components["schemas"]["TaskAction"];
concurrency_policy?: components["schemas"]["ConcurrencyPolicy"];
cron: string;
enabled?: boolean;
name: string;
retry_policy?: components["schemas"]["RetryPolicy"];
/** Format: int64 */
timeout_secs?: number;
timezone?: string;
};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export interface operations {
preview_cron: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CronPreviewRequest"];
};
};
responses: {
/** @description Upcoming cron occurrences */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CronPreviewResponse"];
};
};
/** @description Invalid cron preview request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
execution_events: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Run status and log events */
200: {
headers: {
[name: string]: unknown;
};
content: {
"text/event-stream": string;
};
};
};
};
health: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description TaskPilot API is healthy */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HealthResponse"];
};
};
};
};
get_run: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Run identifier */
run_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Task run detail */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskRunResponse"];
};
};
/** @description Run not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
cancel_run: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Run identifier */
run_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Cancellation requested */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskRunResponse"];
};
};
/** @description Run not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Run is not cancellable */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
get_run_logs: {
parameters: {
query?: {
offset?: number;
};
header?: never;
path: {
/** @description Run identifier */
run_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Incremental task run log */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["LogChunkResponse"];
};
};
/** @description Run not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
download_run_logs: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Run identifier */
run_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Plain text task log */
200: {
headers: {
[name: string]: unknown;
};
content: {
"text/plain": string;
};
};
/** @description Run or log not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
session: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Local API session token */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SessionResponse"];
};
};
};
};
list_tasks: {
parameters: {
query?: {
q?: string;
enabled?: boolean;
action_type?: components["schemas"]["TaskActionType"];
page?: number;
page_size?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Paginated task list */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskListResponse"];
};
};
};
};
create_task: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateTaskRequest"];
};
};
responses: {
/** @description Task created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskResponse"];
};
};
/** @description Validation failed */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Task ID already exists */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
get_task: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Stable task identifier */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Task detail */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskResponse"];
};
};
/** @description Task not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
update_task: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Stable task identifier */
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UpdateTaskRequest"];
};
};
responses: {
/** @description Task updated */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskResponse"];
};
};
/** @description Validation failed */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Task not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
delete_task: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Stable task identifier */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Task deleted */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Task not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
disable_task: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Stable task identifier */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Task disabled */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskResponse"];
};
};
/** @description Task not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
enable_task: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Stable task identifier */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Task enabled */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskResponse"];
};
};
/** @description Task not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
run_task: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Stable task identifier */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Manual run accepted */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskRunResponse"];
};
};
/** @description Task not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Action executor not implemented */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
list_task_runs: {
parameters: {
query?: {
page?: number;
page_size?: number;
status?: components["schemas"]["RunStatus"];
trigger?: components["schemas"]["RunTrigger"];
};
header?: never;
path: {
/** @description Stable task identifier */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Task run history */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskRunListResponse"];
};
};
};
};
}