feat: 加固运行期可靠性并补齐调度与观测接口

- 运行句柄按 run_id 归属,修复取消后重跑时闸门被旧运行删除的竞态
- 调度循环改用取消令牌退出,启动时对账遗留运行,校验 rows_affected
- 三类任务统一参与调度,新增 /api/v1/stats 与 /api/v1/schedule
- SQLite 启用 WAL 与 busy_timeout,日志清理跳过活跃文件,单行上限 256 KiB
- 前端缓存管理令牌并在 403 时重试一次,仪表盘改用统计接口
This commit is contained in:
2026-10-26 00:38:16 +08:00
parent 773196d689
commit 815258c19b
19 changed files with 990 additions and 93 deletions
+56 -18
View File
@@ -24,6 +24,8 @@ export type InterpreterKind = components['schemas']['InterpreterKind']
export type DirectoryEntry = components['schemas']['DirectoryEntry']
export type DirectoryListingResponse =
components['schemas']['DirectoryListingResponse']
export type TaskStats = components['schemas']['TaskStatsResponse']
export type ScheduledTask = components['schemas']['ScheduledTaskResponse']
export interface TaskFilters {
q?: string
@@ -77,20 +79,27 @@ 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 mutating = !['GET', 'HEAD', 'OPTIONS'].includes(method)
const send = async (): Promise<Response> => {
const headers = new Headers(init.headers)
headers.set('Accept', 'application/json')
if (init.body) {
headers.set('Content-Type', 'application/json')
}
if (mutating) {
headers.set('X-TaskPilot-Token', await fetchSessionToken())
}
return fetch(path, { ...init, headers })
}
const response = await fetch(path, {
...init,
headers,
})
let response = await send()
if (mutating && response.status === 403) {
// 后端重启会轮换管理令牌:丢弃缓存后重试一次。
invalidateSessionToken()
response = await send()
}
if (!response.ok) {
let body: ErrorResponse | undefined
@@ -108,15 +117,34 @@ async function requestJson<T>(
return (await response.json()) as T
}
let cachedSessionToken: string | null = null
let pendingSessionToken: Promise<string> | null = null
export function invalidateSessionToken(): void {
cachedSessionToken = null
}
async function fetchSessionToken(): Promise<string> {
const response = await fetch('/api/v1/session', {
headers: { Accept: 'application/json' },
})
if (!response.ok) {
throw new ApiRequestError(response.status)
if (cachedSessionToken) {
return cachedSessionToken
}
const session = (await response.json()) as SessionResponse
return session.token
if (!pendingSessionToken) {
pendingSessionToken = fetch('/api/v1/session', {
headers: { Accept: 'application/json' },
})
.then(async (response) => {
if (!response.ok) {
throw new ApiRequestError(response.status)
}
const session = (await response.json()) as SessionResponse
cachedSessionToken = session.token
return session.token
})
.finally(() => {
pendingSessionToken = null
})
}
return pendingSessionToken
}
function withQuery(path: string, values: object): string {
@@ -265,3 +293,13 @@ export function listDirectories(
{ signal },
)
}
/** 仪表盘计数:一次请求拿到全部指标。 */
export function fetchStats(signal?: AbortSignal): Promise<TaskStats> {
return requestJson<TaskStats>('/api/v1/stats', { signal })
}
/** 已启用任务的下次触发时间。 */
export function fetchSchedule(signal?: AbortSignal): Promise<ScheduledTask[]> {
return requestJson<ScheduledTask[]>('/api/v1/schedule', { signal })
}
+114
View File
@@ -132,6 +132,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/schedule": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["list_schedule"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/session": {
parameters: {
query?: never;
@@ -148,6 +164,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/stats": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["task_stats"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/system/directories": {
parameters: {
query?: never;
@@ -338,6 +370,15 @@ export interface components {
RunStatus: "queued" | "running" | "succeeded" | "failed" | "timed_out" | "cancelled" | "skipped";
/** @enum {string} */
RunTrigger: "schedule" | "manual" | "retry";
/** @description 已注册调度的任务及其下次触发时间。 */
ScheduledTaskResponse: {
/**
* Format: date-time
* @description 下次触发时间;调度正在重建时可能为空
*/
next_run_at?: string | null;
task_id: string;
};
SessionResponse: {
/** @description Random per-process token required for mutating browser requests. */
token: string;
@@ -427,6 +468,39 @@ export interface components {
task_id: string;
trigger: components["schemas"]["RunTrigger"];
};
/** @description 仪表盘指标:一次请求拿到全部计数,避免为每个指标各发一次列表查询。 */
TaskStatsResponse: {
/**
* Format: int64
* @description 已启用任务数
*/
enabled: number;
/**
* Format: int64
* @description HTTP 类任务数
*/
http: number;
/**
* Format: int64
* @description 进程类任务数
*/
process: number;
/**
* Format: int64
* @description 当前正在执行的任务数
*/
running: number;
/**
* Format: int64
* @description 脚本类任务数
*/
script: number;
/**
* Format: int64
* @description 任务总数
*/
total: number;
};
UpdateTaskRequest: {
action: components["schemas"]["TaskAction"];
concurrency_policy?: components["schemas"]["ConcurrencyPolicy"];
@@ -718,6 +792,26 @@ export interface operations {
};
};
};
list_schedule: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Next trigger time per enabled task */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ScheduledTaskResponse"][];
};
};
};
};
session: {
parameters: {
query?: never;
@@ -738,6 +832,26 @@ export interface operations {
};
};
};
task_stats: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Dashboard counters */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskStatsResponse"];
};
};
};
};
list_directories: {
parameters: {
query?: {