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 })
}