feat(server): unified scheduled-job registry and scheduler; module timers moved onto it

- modules/schedules: registerScheduledJob({key, defaults, run}) registry; defaults are
  synced into scheduled_job without overriding user-changed enabled/schedule
- scheduler runs only on the io worker, one instance via pg_try_advisory_lock held on a
  dedicated connection; due = enabled and next_run_at reached, or run requested;
  jobs run in parallel, never re-entrant; interval next = finish + period, cron next match;
  run-now does not move next_run_at and merges duplicate requests; skip advances one period;
  history trimmed to the last 50 runs; interrupted runs marked failed on takeover
- agent_check jobs: trigger condition checked first, goal template rendered, runAgent
  enqueued via startRun(trigger=schedule) with allowlist and budget, one run per time slot;
  allowRecover uses the autoInvestigate grant check; results harvested back into the run
  history and optionally reported as an info decision; 3 built-in checks, off by default
- sources-identity (feeds.poll, sites.snapshot, identity.review), acquisition-organize
  (downloads.sync, cache.threshold, downloads.orchestrate, slots.reevaluate,
  subscriptions.search, subscriptions.metadata, library.reconcile) and file-manager
  (trash.purge) register jobs instead of running their own setInterval; default
  intervals unchanged; backup registered as an unconfigured placeholder
- endpoints under /schedules (admin, console session only)

TODO: pi refreshPiTokens and auth-settings session cleanup still own their timers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 02:32:33 +08:00
1 parent f8f70d8e09
commit a68fcea13b
16 files changed
+1724 -119

No files matched your search

@@ -1,6 +1,7 @@
import { sql } from "@umt/db";
import { createMockQb, createTempTree, type MockQb, makeTorrent, type TempTree } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createSchedulesModule } from "../../schedules/index.ts";
import { evaluateAcquisition } from "../services/acquisition.ts";
import { submitDownload, syncDownloads } from "../services/downloads.ts";
import { executeRecord, planImport } from "../services/organize.ts";
@@ -15,7 +16,7 @@ import {
seedSlot,
} from "./harness.ts";
// W4-D P0:后台循环只编排。补入库在巡检里只入队 verifyAndImport;一个 50MB 的入库(复制 + sha256 + 慢 ffprobe)
// W4-D P0:后台循环只编排(W7-C 起由 schedules 调度器驱动模块登记的定时任务)。补入库在巡检里只入队 verifyAndImport;一个 50MB 的入库(复制 + sha256 + 慢 ffprobe)
// 进行中时,qB 同步定时器仍按周期打到 mock qB(用 mock qB 的请求时间戳断言)。
const hasPg = await pgAvailable();
@@ -31,6 +32,7 @@ describe.skipIf(!hasPg)("acquisition-organize 后台循环不被入库阻塞", (
const disk = fakeDisk(10 * 1024 ** 3);
const torrents = new Map<string, Uint8Array>();
let base: Awaited<ReturnType<typeof seedBase>>;
const schedules = createSchedulesModule({ builtinChecks: false, scheduler: { tickMs: 50, lockRetryMs: 100 } });
beforeAll(async () => {
qb = await createMockQb({ addVisibleAfterPolls: 0 });
@@ -53,6 +55,7 @@ describe.skipIf(!hasPg)("acquisition-organize 后台循环不被入库阻塞", (
}, 60_000);
afterAll(async () => {
await schedules.stop?.();
await h?.module.stop?.();
await h?.close();
await qb?.close();
@@ -82,6 +85,7 @@ describe.skipIf(!hasPg)("acquisition-organize 后台循环不被入库阻塞", (
await sql`update download_task set completed_at = now() - interval '10 minutes' where id = ${dtId}`.execute(h.db);
await h.module.start?.(h.ctx);
await schedules.start?.(h.ctx);
const deadline = Date.now() + 10_000;
while (!h.engine.enqueued.some((e) => e.task === "verifyAndImport") && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 50));
@@ -1,5 +1,6 @@
import { type EventPayload, EventType } from "@umt/contracts";
import { sql } from "@umt/db";
import { registerScheduledJob } from "../schedules/registry.ts";
import type { ServerContext, ServerModule } from "../types.ts";
import { registerAoCapabilities } from "./capabilities.ts";
import { registerAoTasks } from "./handlers.ts";
@@ -31,48 +32,29 @@ const CLEANUP_SLOT_MS = 5 * 60_000;
/**
* 模块工厂:测试注入 mock qB / 固定磁盘用量 / 假种子来源;正式装配用 acquisitionOrganizeModule。
*
* 后台只做编排,两个独立定时器互不阻塞:
* sync —— qB 同步 + 缓存阈值暂停/恢复(syncDownloads),只有轻量 HTTP 与 DB 写;
* plan —— 阈值判断后入队 cleanupDownloadCache、巡检补投 submitDownload / verifyAndImport、按周期入队 reconcileLibrary。
* 复制/删种/全量 stat 这些重活都进任务引擎(租约 + attemptToken),不在循环里 await。
* 后台只做编排,start() 把各项周期登记到定时任务注册表(schedules 模块的调度器驱动,不同任务并行、同一任务不重入):
* downloads.sync —— qB 同步 + 缓存阈值暂停/恢复(syncDownloads),只有轻量 HTTP 与 DB 写;
* cache.threshold —— 超硬阈值入队 cleanupDownloadCache;
* downloads.orchestrate —— 巡检补投 submitDownload / verifyAndImport;
* slots.reevaluate / subscriptions.search / subscriptions.metadata —— Slot 到期重评、订阅周期搜索、总集数刷新;
* library.reconcile —— 按周期入队 reconcileLibrary。
* 复制/删种/全量 stat 这些重活都进任务引擎(租约 + attemptToken),不在调度里 await。
*/
export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): ServerModule & { deps(): AoDeps } {
let deps: AoDeps | null = null;
const timers = new Set<ReturnType<typeof setTimeout>>();
let stopped = false;
let lastReconcileSlot: string | null = null;
// register* 阶段只保存 ctx;端口在首次使用时组装(ctx 其它字段此时才完整)
const lazy = (ctx: ServerContext): AoDeps => {
deps ??= { ctx, ports: createPorts(ctx, overrides) };
return deps;
};
/** 编排一轮:阈值 → 入队清理;补投提交/入库;周期入队资产对账 */
const planTick = async (d: AoDeps) => {
/** 编排类任务:自动化总开关关闭或只读对账时跳过 */
const guarded =
(d: AoDeps, fn: (d: AoDeps) => Promise<string>) => async (): Promise<{ status?: "skipped"; summary: string }> => {
const blocked = await automationBlock(d);
if (!blocked) {
const cache = await d.ctx.settings.get("cache");
const over = (await loadVolumes(d)).some((v) => (usedPercent(v) ?? 0) >= cache.hardThresholdPercent);
if (over) await enqueueThresholdCleanup(d);
await sweepReservedPlans(d);
await sweepPendingImports(d);
// W5-C:Slot 到期重评(忙时延后的发布 / 定时)、订阅周期主动搜索、追踪剧集的总集数刷新
await sweepDueSlots(d);
await sweepSubscriptionSearch(d);
await sweepMetadataRefresh(d);
}
const every = d.ports.options.reconcileIntervalMs;
if (every > 0) {
const slot = timeSlot(d.ports.now(), every);
if (slot !== lastReconcileSlot) {
await enqueueTracked(d, {
kind: "reconcileLibrary",
idempotencyKey: `auto:${slot}`,
input: {},
});
lastReconcileSlot = slot;
}
}
if (blocked)
return { status: "skipped", summary: blocked === "automation_disabled" ? "自动化已关闭" : "只读对账模式" };
return { summary: await fn(d) };
};
const module: ServerModule & { deps(): AoDeps } = {
@@ -113,49 +95,114 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
async start(ctx) {
const d = lazy(ctx);
if (!d.ports.options.backgroundLoops) return;
stopped = false;
const intervalMs = async () => {
const cache = await ctx.settings.get("cache").catch(() => null);
return d.ports.options.syncIntervalMs ?? (cache?.checkIntervalSec ?? 60) * 1000;
};
/** 自调度循环:上一轮结束后再排下一轮,单个循环内不重入;两个循环互不等待 */
const loop = (name: string, fn: (d: AoDeps) => Promise<unknown>, period: () => Promise<number>) => {
const schedule = async () => {
if (stopped) return;
const t = setTimeout(
async () => {
timers.delete(t);
try {
await fn(d);
} catch (err) {
ctx.logger.error(
{ err: { message: errMessage(err) }, loop: name },
"acquisition-organize 后台轮询失败",
);
const syncMs = d.ports.options.syncIntervalMs ?? (cache?.checkIntervalSec ?? 60) * 1000;
const planMs = d.ports.options.planIntervalMs ?? syncMs;
const every = (everyMs: number) => ({ type: "interval" as const, everyMs });
registerScheduledJob(ctx, {
key: "downloads.sync",
defaults: {
name: "qB 同步",
description: "同步下载器任务状态,按缓存阈值暂停或恢复",
group: "download",
schedule: every(syncMs),
},
run: async () => {
const st = await syncDownloads(d);
if (st.instances > 0 && st.unreachable.length === st.instances) {
throw new Error(`下载器都连不上:${st.unreachable.join("、")}`);
}
void schedule();
},
await period(),
);
timers.add(t);
if (stopped) clearTimeout(t);
return {
summary:
`${st.instances} 个下载器,更新 ${st.updated},完成 ${st.completed.length}` +
(st.unreachable.length ? `,连不上 ${st.unreachable.length}` : ""),
};
void schedule();
};
loop("sync", syncDownloads, intervalMs);
loop("plan", planTick, async () => d.ports.options.planIntervalMs ?? (await intervalMs()));
},
stop() {
stopped = true;
for (const t of timers) clearTimeout(t);
timers.clear();
});
registerScheduledJob(ctx, {
key: "cache.threshold",
defaults: {
name: "缓存阈值检查",
description: "下载缓存超过硬阈值时入队清理",
group: "maintain",
schedule: every(planMs),
},
run: guarded(d, async (d) => {
const hard = (await d.ctx.settings.get("cache")).hardThresholdPercent;
const over = (await loadVolumes(d)).some((v) => (usedPercent(v) ?? 0) >= hard);
if (!over) return "未超阈值";
return (await enqueueThresholdCleanup(d)) ? "已入队清理" : "清理进行中";
}),
});
registerScheduledJob(ctx, {
key: "downloads.orchestrate",
defaults: {
name: "补提交与补入库",
description: "补投已预留未提交的下载、已完成未入库的任务",
group: "download",
schedule: every(planMs),
},
run: guarded(d, async (d) => {
const submitted = await sweepReservedPlans(d);
const imports = await sweepPendingImports(d);
return `补提交 ${submitted},补入库 ${imports}`;
}),
});
registerScheduledJob(ctx, {
key: "slots.reevaluate",
defaults: {
name: "到期重评",
description: "到了重评时间的收录位重新评估(忙时延后的发布、定时)",
group: "download",
schedule: every(planMs),
},
run: guarded(d, async (d) => `${await sweepDueSlots(d)} 项`),
});
registerScheduledJob(ctx, {
key: "subscriptions.search",
defaults: {
name: "订阅周期搜索",
description: "到期的订阅主动搜索站点",
group: "collect",
schedule: every(planMs),
},
run: guarded(d, async (d) => `${await sweepSubscriptionSearch(d)} 项`),
});
registerScheduledJob(ctx, {
key: "subscriptions.metadata",
defaults: {
name: "总集数刷新",
description: "追踪中的剧集刷新总集数",
group: "organize",
schedule: every(planMs),
},
run: guarded(d, async (d) => `${await sweepMetadataRefresh(d)} 项`),
});
const reconcileMs = d.ports.options.reconcileIntervalMs;
if (reconcileMs > 0) {
registerScheduledJob(ctx, {
key: "library.reconcile",
defaults: {
name: "资产对账",
description: "核对媒体库文件与资产记录",
group: "organize",
schedule: every(reconcileMs),
},
run: async (c) => {
// 同一周期时间槽只入队一次;立即执行用独立槽
await enqueueTracked(d, { kind: "reconcileLibrary", idempotencyKey: `auto:${c.slot}`, input: {} });
return { summary: "已入队对账" };
},
});
}
},
};
return module;
}
/** 超阈值:按时间槽入队一次 cleanupDownloadCache;上一次清理仍在进行则跳过 */
async function enqueueThresholdCleanup(d: AoDeps): Promise<void> {
/** 超阈值:按时间槽入队一次 cleanupDownloadCache;上一次清理仍在进行则跳过(返回 false) */
async function enqueueThresholdCleanup(d: AoDeps): Promise<boolean> {
const busy = await d.ctx.db
.selectFrom("task")
.select("id")
@@ -163,12 +210,13 @@ async function enqueueThresholdCleanup(d: AoDeps): Promise<void> {
.where("status", "in", ["pending", "running"])
.where("updated_at", ">", new Date(d.ports.now().getTime() - 30 * 60_000))
.executeTakeFirst();
if (busy) return;
if (busy) return false;
await enqueueTracked(d, {
kind: "cleanupDownloadCache",
idempotencyKey: `threshold:${timeSlot(d.ports.now(), CLEANUP_SLOT_MS)}`,
input: {},
});
return true;
}
/** 下载已完成、没有整理记录(入库事件被总开关延后等)的纳管任务:入队 verifyAndImport(业务行 import:<id>) */
+19 -23
View File
@@ -3,9 +3,9 @@ import { defineTask } from "@umt/tasks";
import { z } from "zod";
import type { AoOverrides } from "../acquisition-organize/index.ts";
import { createPorts } from "../acquisition-organize/ports.ts";
import { timeSlot } from "../acquisition-organize/services/common.ts";
import { registerScheduledJob } from "../schedules/registry.ts";
import type { ServerContext, ServerModule } from "../types.ts";
import { defaultOptions, errMessage, type FmDeps, type FmOptions } from "./common.ts";
import { defaultOptions, type FmDeps, type FmOptions } from "./common.ts";
import { registerFileRoutes } from "./routes.ts";
import { purgeTrash } from "./trash.ts";
@@ -13,7 +13,7 @@ import { purgeTrash } from "./trash.ts";
* W5-A 文件管理(/files,docs/plan/06-file-manager-spec.md):只浏览已登记根(库根 + 下载器路径映射),
* 行内业务状态、改名同步资产与路径绑定修订、移动/复制两段式 planId、整理走 planImport、回收站 + 引用检查、识别/探测/刮削。
* 复用 acquisition-organize 的服务导出(planImport / 路径守卫 / Move 授权 / H&R),不改其文件。
* 持久任务 purgeTrash:每日清理过期回收站项(FILES_TRASH_RETENTION_DAYS,默认 7 天)。
* 持久任务 purgeTrash:每日清理过期回收站项(FILES_TRASH_RETENTION_DAYS,默认 7 天);周期登记为定时任务 trash.purge。
*/
export interface FmOverrides {
ao?: AoOverrides;
@@ -22,7 +22,6 @@ export interface FmOverrides {
export function createFileManagerModule(overrides: FmOverrides = {}): ServerModule & { deps(): FmDeps } {
let deps: FmDeps | null = null;
let timer: ReturnType<typeof setInterval> | null = null;
const lazy = (ctx: ServerContext): FmDeps => {
if (!deps) {
const { options: aoOptions, ...aoRest } = overrides.ao ?? {};
@@ -34,20 +33,6 @@ export function createFileManagerModule(overrides: FmOverrides = {}): ServerModu
}
return deps;
};
const enqueuePurge = async (d: FmDeps) => {
try {
await d.ctx.engine.enqueue(
"purgeTrash",
{},
{
idempotencyKey: `purgeTrash:${timeSlot(d.ao.ports.now(), d.options.purgeIntervalMs)}`,
traceId: randomUUID(),
},
);
} catch (err) {
d.ctx.logger.warn({ err: { message: errMessage(err) } }, "回收站清理入队失败");
}
};
return {
name: "file-manager",
deps() {
@@ -75,12 +60,23 @@ export function createFileManagerModule(overrides: FmOverrides = {}): ServerModu
start(ctx) {
const d = lazy(ctx);
if (!d.options.backgroundLoops || d.options.purgeIntervalMs <= 0) return;
void enqueuePurge(d);
timer = setInterval(() => void enqueuePurge(d), Math.min(d.options.purgeIntervalMs, 3600_000));
registerScheduledJob(ctx, {
key: "trash.purge",
defaults: {
name: "回收站清理",
description: "清除过期的回收站项",
group: "maintain",
schedule: { type: "interval", everyMs: d.options.purgeIntervalMs },
},
stop() {
if (timer) clearInterval(timer);
timer = null;
run: async (c) => {
const r = await d.ctx.engine.enqueue(
"purgeTrash",
{},
{ idempotencyKey: `purgeTrash:${c.slot}`, traceId: randomUUID() },
);
return { summary: r.deduplicated ? "本周期已入队" : "已入队清理" };
},
});
},
};
}
@@ -0,0 +1,162 @@
import {
AgentCheckConfig,
type AgentCheckConfigInput,
CAPABILITY_CATALOG,
type KnownCapabilityId,
} from "@umt/contracts";
import { sql } from "@umt/db";
import { recoveryGrants } from "../agent/investigate.ts";
import { INVESTIGATE_CAPABILITIES, PLAN_ONLY_KINDS, RECOVER_CAPABILITIES } from "../agent/runner.ts";
import { startRun } from "../agent/service.ts";
import type { ServerContext } from "../types.ts";
import type { JobRunContext, JobRunResult, ScheduledJobDef } from "./registry.ts";
// Agent 巡检(kind = agent_check):到点先按业务表判断触发条件,满足才渲染目标模板、入队 runAgent
// (trigger=schedule,能力白名单 + 预算;同一时间槽 dedupe_key 相同只建一个 run)。
// allowRecover 走与 autoInvestigate 相同的授权路径:有覆盖恢复动作的有效持续授权才用 execute 模式,
// 否则降级只读;实际每次调用仍由 authorize 按授权判定。run 结束后调度器回填结果(见 scheduler.harvest)。
export const HOUR = 3600_000;
/** 内置巡检模板(默认关闭) */
export const BUILTIN_AGENT_CHECKS: {
key: string;
name: string;
description: string;
everyMs: number;
config: AgentCheckConfigInput;
}[] = [
{
key: "check.pending",
name: "待处理巡检",
description: "检查待处理事项并给出建议(只读)",
everyMs: 6 * HOUR,
config: {
goalTemplate:
"巡检待处理中心({{now}}):当前有 {{openDecisions}} 条未关闭事项。逐条查看证据,按紧急程度排序," +
"给出每条的处理建议与理由;只读,不执行任何变更。",
capabilityAllowlist: [...INVESTIGATE_CAPABILITIES],
budget: { maxToolCalls: 20, maxDurationSec: 300 },
triggerCondition: "open_decisions",
},
},
{
key: "check.downloads",
name: "下载诊断",
description: "检查无进展或失败的下载并诊断;开启恢复后在授权内重试或重新校验",
everyMs: 2 * HOUR,
config: {
goalTemplate:
"巡检下载({{now}}):有 {{stalledDownloads}} 个下载无进展或失败。用只读能力查明原因(站点、做种、磁盘、任务错误)," +
"给出诊断与建议;若本次允许恢复,只在授权内做重试或强制重新校验,每个任务最多一次,不换源、不删除。",
capabilityAllowlist: [...INVESTIGATE_CAPABILITIES],
budget: { maxToolCalls: 24, maxDurationSec: 420 },
triggerCondition: "stalled_downloads",
allowRecover: false,
},
},
{
key: "check.weekly",
name: "订阅与站点周报",
description: "检查订阅缺集与站点健康,汇总写入待处理(只读)",
everyMs: 7 * 24 * HOUR,
config: {
goalTemplate:
"生成本周巡检周报({{date}}):追踪中的订阅 {{activeSubscriptions}} 个。列出缺集的订阅与原因推测、" +
"健康异常的站点与 Feed,给出本周建议;只读,最后用一段不超过 300 字的中文汇总。",
capabilityAllowlist: [...INVESTIGATE_CAPABILITIES],
budget: { maxToolCalls: 30, maxDurationSec: 600 },
triggerCondition: "subscriptions_active",
reportAsDecision: true,
},
},
];
interface Facts {
openDecisions: number;
stalledDownloads: number;
activeSubscriptions: number;
}
async function facts(ctx: ServerContext): Promise<Facts> {
const r = await sql<Facts>`
select
(select count(*)::int from decision where status in ('open', 'investigating', 'awaiting_user')) as "openDecisions",
(select count(*)::int from download_task where removed_at is null and status in ('stalled', 'failed')) as "stalledDownloads",
(select count(*)::int from subscription where status = 'active') as "activeSubscriptions"`.execute(ctx.db);
return r.rows[0] ?? { openDecisions: 0, stalledDownloads: 0, activeSubscriptions: 0 };
}
export function renderGoal(template: string, now: Date, f: Facts): string {
const vars: Record<string, string> = {
now: now.toISOString(),
date: now.toISOString().slice(0, 10),
openDecisions: String(f.openDecisions),
stalledDownloads: String(f.stalledDownloads),
activeSubscriptions: String(f.activeSubscriptions),
};
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (m, k: string) => vars[k] ?? m);
}
const isPlanOnly = (id: string) => {
const d = CAPABILITY_CATALOG[id as KnownCapabilityId];
return !!d && PLAN_ONLY_KINDS.includes(d.kind);
};
/** agent_check 的执行体(内置与自定义共用) */
export async function runAgentCheck(ctx: ServerContext, c: JobRunContext): Promise<JobRunResult> {
const parsed = AgentCheckConfig.safeParse(c.config);
if (!parsed.success) throw new Error(`巡检参数无效:${parsed.error.issues[0]?.message ?? "未知"}`);
const cfg = parsed.data;
const f = await facts(ctx);
const unmet =
(cfg.triggerCondition === "open_decisions" && f.openDecisions === 0) ||
(cfg.triggerCondition === "stalled_downloads" && f.stalledDownloads === 0) ||
(cfg.triggerCondition === "subscriptions_active" && f.activeSubscriptions === 0);
// 手动执行不看触发条件
if (unmet && c.trigger === "schedule") return { status: "skipped", summary: "触发条件不满足,未唤醒 Agent" };
let mode: "execute" | "plan_only" = "plan_only";
let allowlist = cfg.capabilityAllowlist.filter(isPlanOnly);
let note = "";
if (cfg.allowRecover) {
const grants = await recoveryGrants(ctx, ["task.retry", "download.recheck"]);
if (grants.length > 0) {
mode = "execute";
allowlist = [...new Set([...(allowlist.length ? allowlist : INVESTIGATE_CAPABILITIES), ...RECOVER_CAPABILITIES])];
} else note = "(没有覆盖恢复动作的有效授权,已降级只读)";
}
const goal = renderGoal(cfg.goalTemplate, c.now, f);
const dedupeKey = c.trigger === "manual" ? `schedule:${c.jobId}:manual:${c.runId}` : `schedule:${c.jobId}:${c.slot}`;
const { run, created } = await startRun(ctx, {
goal,
mode,
capabilityAllowlist: allowlist,
trigger: "schedule",
createdBy: `schedule:${c.jobId}`,
dedupeKey,
budget: cfg.budget,
});
return {
summary: `${created ? "已入队" : "本时段已有"} Agent 运行(${mode === "execute" ? "授权内恢复" : "只读"})${note}`,
agentRunId: run.id,
};
}
/** 内置巡检的注册定义(默认关闭) */
export function builtinAgentCheckDefs(ctx: ServerContext): ScheduledJobDef[] {
return BUILTIN_AGENT_CHECKS.map((t) => ({
key: t.key,
kind: "agent_check" as const,
defaults: {
name: t.name,
description: t.description,
group: "agent" as const,
schedule: { type: "interval" as const, everyMs: t.everyMs },
enabled: false,
config: AgentCheckConfig.parse(t.config) as unknown as Record<string, unknown>,
firstRun: "after_interval" as const,
},
run: (c) => runAgentCheck(ctx, c),
}));
}
+89
View File
@@ -0,0 +1,89 @@
// 最小 5 段 cron(分 时 日 月 周),按服务器本地时区计算;支持 `*`、`a,b`、`a-b`、`*/n`、`a-b/n`。
// 周:0–7(0 与 7 都是周日)。日与周同时受限时按 vixie cron 语义取"或"。
export interface CronFields {
minute: Set<number>;
hour: Set<number>;
day: Set<number>;
month: Set<number>;
weekday: Set<number>;
dayAny: boolean;
weekdayAny: boolean;
}
const RANGES: [number, number][] = [
[0, 59],
[0, 23],
[1, 31],
[1, 12],
[0, 7],
];
function parseField(src: string, [lo, hi]: [number, number], label: string): Set<number> {
const out = new Set<number>();
for (const part of src.split(",")) {
const m = /^(\*|(\d+)(?:-(\d+))?)(?:\/(\d+))?$/.exec(part);
if (!m) throw new Error(`cron ${label} 段无效:${part}`);
let a = lo;
let b = hi;
if (m[1] !== "*") {
a = Number(m[2]);
b = m[3] !== undefined ? Number(m[3]) : m[4] !== undefined ? hi : a;
}
const step = m[4] !== undefined ? Number(m[4]) : 1;
if (a < lo || b > hi || a > b || step < 1) throw new Error(`cron ${label} 段越界:${part}`);
for (let v = a; v <= b; v += step) out.add(v);
}
return out;
}
export function parseCron(expr: string): CronFields {
const parts = expr.trim().split(/\s+/);
if (parts.length !== 5) throw new Error("cron 需要 5 段:分 时 日 月 周");
const [mi, h, d, mo, w] = parts as [string, string, string, string, string];
const weekday = parseField(w, RANGES[4]!, "周");
if (weekday.has(7)) weekday.add(0);
return {
minute: parseField(mi, RANGES[0]!, "分"),
hour: parseField(h, RANGES[1]!, "时"),
day: parseField(d, RANGES[2]!, "日"),
month: parseField(mo, RANGES[3]!, "月"),
weekday,
dayAny: d === "*",
weekdayAny: w === "*",
};
}
/** 严格晚于 after 的下一次触发时间(最多向后找约 4 年,找不到抛错) */
export function nextCronAfter(expr: string | CronFields, after: Date): Date {
const f = typeof expr === "string" ? parseCron(expr) : expr;
const t = new Date(after.getTime());
t.setSeconds(0, 0);
t.setMinutes(t.getMinutes() + 1);
const limit = after.getTime() + 4 * 366 * 24 * 3600_000;
while (t.getTime() <= limit) {
if (!f.month.has(t.getMonth() + 1)) {
t.setMonth(t.getMonth() + 1, 1);
t.setHours(0, 0, 0, 0);
continue;
}
const dayOk = f.day.has(t.getDate());
const wdOk = f.weekday.has(t.getDay());
const dateOk = f.dayAny && f.weekdayAny ? true : f.dayAny ? wdOk : f.weekdayAny ? dayOk : dayOk || wdOk;
if (!dateOk) {
t.setDate(t.getDate() + 1);
t.setHours(0, 0, 0, 0);
continue;
}
if (!f.hour.has(t.getHours())) {
t.setHours(t.getHours() + 1, 0, 0, 0);
continue;
}
if (!f.minute.has(t.getMinutes())) {
t.setMinutes(t.getMinutes() + 1, 0, 0);
continue;
}
return t;
}
throw new Error(`cron 在 4 年内没有触发时间:${typeof expr === "string" ? expr : "(parsed)"}`);
}
@@ -0,0 +1,61 @@
import type { ServerContext, ServerModule } from "../types.ts";
import { builtinAgentCheckDefs } from "./agent-checks.ts";
import { registerScheduledJob, scheduleRegistry } from "./registry.ts";
import { registerScheduleRoutes } from "./routes.ts";
import { Scheduler, type SchedulerOptions } from "./scheduler.ts";
export type { JobRunContext, JobRunResult, ScheduledJobDef, ScheduledJobDefaults } from "./registry.ts";
export { registerScheduledJob, scheduleRegistry } from "./registry.ts";
/**
* W7-C 定时任务统一注册表 + 看板(docs/runbook/schedules.md):
* 各模块 start() 用 registerScheduledJob 登记周期任务;本模块在 io worker 上起唯一调度器(PG 咨询锁)按表驱动,
* 并登记内置 Agent 巡检模板(默认关闭)与备份占位。看板 API 见 routes.ts(全部只接受控制台会话)。
* 须放在 SERVER_MODULES 末尾:start 时其它模块已登记完(之后再登记的也会在下一轮同步)。
*
* TODO(W7-A):pi 模块的 refreshPiTokens 目前在 pi/index.ts 自建 setInterval,
* 待其改为 registerScheduledJob({ key: "pi.token_refresh", defaults: { group: "maintain", ... } })。
*/
export interface SchedulesModuleOptions {
scheduler?: SchedulerOptions;
/** 缺省 true;测试可关掉内置巡检 */
builtinChecks?: boolean;
}
export function createSchedulesModule(opts: SchedulesModuleOptions = {}): ServerModule & {
scheduler(): Scheduler | null;
} {
let scheduler: Scheduler | null = null;
return {
name: "schedules",
scheduler: () => scheduler,
registerRoutes(app, ctx) {
registerScheduleRoutes(app, ctx, () => scheduleRegistry(ctx));
},
registerTasks() {},
start(ctx: ServerContext) {
if (opts.builtinChecks !== false) for (const def of builtinAgentCheckDefs(ctx)) registerScheduledJob(ctx, def);
// 备份目前由宿主机 scripts/backup.sh(cron / 手动)执行,这里只占位,看板上显示"未配置"
registerScheduledJob(ctx, {
key: "maintain.backup",
defaults: {
name: "备份",
description: "数据库与数据目录备份;目前由 scripts/backup.sh 在宿主机执行,未接入调度",
group: "maintain",
schedule: { type: "cron", cron: "30 3 * * *" },
enabled: false,
available: false,
},
run: async () => ({ status: "skipped", summary: "未配置" }),
});
scheduler = new Scheduler(ctx, scheduleRegistry(ctx), opts.scheduler);
scheduler.start();
},
async stop() {
await scheduler?.stop();
scheduler = null;
},
};
}
export const schedulesModule = createSchedulesModule();
@@ -0,0 +1,99 @@
import type { ScheduleGroup, ScheduleKind, ScheduleSpec, ScheduleTrigger } from "@umt/contracts";
import type { ServerContext } from "../types.ts";
// 定时任务注册表(进程内):模块在 start() 里登记 {key, defaults, run},schedules 模块的调度器
// 把定义同步进 scheduled_job 表(不覆盖用户改过的开关/调度),按 next_run_at 调用 run。
// 各模块只 import 本文件(不依赖调度器实现);没装 schedules 模块时登记只是一张内存表,不会执行。
export interface ScheduledJobDefaults {
name: string;
description?: string;
group: ScheduleGroup;
schedule: ScheduleSpec;
/** 缺省 true */
enabled?: boolean;
/** 任务参数(看板可改;task 浅合并,agent_check 整体替换) */
config?: Record<string, unknown>;
/** false = 占位(如备份未配置),不执行、不可开启 */
available?: boolean;
/** 首次登记时何时第一次运行:now(缺省,尽快)/ after_interval(一个周期后) */
firstRun?: "now" | "after_interval";
}
export interface JobRunContext {
jobId: string;
/** scheduled_job_run.id */
runId: string;
trigger: Exclude<ScheduleTrigger, "skip">;
now: Date;
/** 当前任务参数(表里的 config) */
config: Record<string, unknown>;
/**
* 时间槽:按调度周期取整的稳定串(interval:floor(now/everyMs);cron:本次计划时间)。
* 入队幂等键用它拼,多实例 / 重复触发同槽只入队一次;立即执行用 manual:<runId>。
*/
slot: string;
}
export interface JobRunResult {
/** 缺省 ok;业务侧主动放弃(如只读对账模式)返回 skipped */
status?: "ok" | "skipped";
summary?: string | null;
agentRunId?: string | null;
}
export type JobRunFn = (c: JobRunContext) => Promise<JobRunResult | undefined | void>;
export interface ScheduledJobDef {
/** 稳定 key(表主键),如 feeds.poll */
key: string;
kind?: ScheduleKind;
defaults: ScheduledJobDefaults;
run: JobRunFn;
}
export interface ScheduleRegistry {
/** 每次登记 +1,调度器据此判断是否需要重新同步到表 */
readonly version: number;
get(key: string): ScheduledJobDef | undefined;
list(): ScheduledJobDef[];
register(def: ScheduledJobDef): void;
unregister(key: string): void;
}
class MemoryRegistry implements ScheduleRegistry {
version = 0;
private defs = new Map<string, ScheduledJobDef>();
get(key: string) {
return this.defs.get(key);
}
list() {
return [...this.defs.values()];
}
register(def: ScheduledJobDef) {
if (!/^[a-z][a-z0-9_.:-]{1,120}$/.test(def.key)) throw new Error(`定时任务 key 不合法:${def.key}`);
this.defs.set(def.key, def);
this.version += 1;
}
unregister(key: string) {
if (this.defs.delete(key)) this.version += 1;
}
}
const registries = new WeakMap<object, ScheduleRegistry>();
/** 取 ctx 对应的注册表(同一 ServerContext 共用一张) */
export function scheduleRegistry(ctx: Pick<ServerContext, "db">): ScheduleRegistry {
const k = ctx as object;
let r = registries.get(k);
if (!r) {
r = new MemoryRegistry();
registries.set(k, r);
}
return r;
}
/** 模块 start() 里调用:登记一个周期任务(同 key 重复登记以最后一次为准) */
export function registerScheduledJob(ctx: Pick<ServerContext, "db">, def: ScheduledJobDef): void {
scheduleRegistry(ctx).register(def);
}
+141
View File
@@ -0,0 +1,141 @@
import { createRoute, type RouteConfig } from "@hono/zod-openapi";
import {
type Actor,
type AgentCheckCreate,
Api,
apiResponse,
type EndpointDef,
SCHEDULE_CHANGED_EVENT,
type ScheduleUpdate,
} from "@umt/contracts";
import type { z } from "zod";
import { requireAdminSession, requireScope } from "../../http/auth.ts";
import type { ModuleApp, ServerContext } from "../types.ts";
import type { ScheduleRegistry } from "./registry.ts";
import {
board,
createAgentCheck,
deleteJob,
jobView,
listRuns,
requestRun,
skipNext,
syncDefinitions,
updateJob,
} from "./store.ts";
type Infer<T> = T extends z.ZodType ? z.infer<T> : Record<string, never>;
interface Input<D extends EndpointDef> {
params: Infer<D["params"]>;
query: Infer<D["query"]>;
body: Infer<D["body"]>;
actor: Actor | undefined;
}
/** 合同 EndpointDef → OpenAPI 路由 + requireScope + 控制台会话;出参按合同 parse */
function route<D extends EndpointDef>(app: ModuleApp, def: D, handler: (i: Input<D>) => Promise<unknown>) {
const request: RouteConfig["request"] = {};
if (def.params) request.params = def.params as never;
if (def.query) request.query = def.query as never;
if (def.body) request.body = { content: { "application/json": { schema: def.body as never } }, required: true };
const r = createRoute({
method: def.method,
path: def.path,
summary: def.summary,
tags: ["schedules"],
middleware: [requireScope(def.scope), ...(def.auth === "session" ? [requireAdminSession()] : [])] as never,
request,
responses: {
200: { description: def.summary, content: { "application/json": { schema: apiResponse(def.response) } } },
},
});
// biome-ignore lint/suspicious/noExplicitAny: 路由泛型在封装里擦除,入参已由 zod-openapi 校验
app.openapi(r, (async (c: any) => {
const input = {
params: def.params ? c.req.valid("param") : {},
query: def.query ? c.req.valid("query") : {},
body: def.body ? c.req.valid("json") : {},
actor: c.get("actor"),
} as Input<D>;
const data = def.response.parse(await handler(input));
return c.json({ ok: true, data }, 200);
// biome-ignore lint/suspicious/noExplicitAny: 同上
}) as any);
}
async function audit(ctx: ServerContext, actor: Actor | undefined, action: string, id: string, payload = {}) {
try {
await ctx.db
.insertInto("audit_event")
.values({
actor_type: actor?.type === "api_key" ? "api_key" : actor ? "user" : "system",
actor_id: actor?.id ?? null,
action,
target_type: "scheduled_job",
target_id: id,
payload: JSON.stringify(payload),
})
.execute();
} catch (err) {
ctx.logger.warn({ err, action }, "审计写入失败");
}
}
export function registerScheduleRoutes(app: ModuleApp, ctx: ServerContext, registry: () => ScheduleRegistry) {
const changed = (jobId: string) => void ctx.events.publish(SCHEDULE_CHANGED_EVENT, { jobId, status: null });
// api 与 worker 同进程(role all)时,worker 还没同步过也能先看到内置定义
let synced = -1;
const ensureSynced = async () => {
const r = registry();
if (r.version === synced || r.list().length === 0) return;
const v = r.version;
await syncDefinitions(ctx.db, r.list(), new Date());
synced = v;
};
route(app, Api.schedules.list, async () => {
await ensureSynced();
return await board(ctx.db);
});
route(app, Api.schedules.create, async ({ body, actor }) => {
const row = await createAgentCheck(ctx.db, body as AgentCheckCreate, new Date());
await audit(ctx, actor, "schedule.create", row.id, { name: row.name });
changed(row.id);
return jobView(row);
});
route(app, Api.schedules.update, async ({ params, body, actor }) => {
const row = await updateJob(ctx.db, params.id, body as ScheduleUpdate, new Date());
await audit(ctx, actor, "schedule.update", row.id, body as Record<string, unknown>);
changed(row.id);
return jobView(row);
});
route(app, Api.schedules.remove, async ({ params, actor }) => {
await deleteJob(ctx.db, params.id);
await audit(ctx, actor, "schedule.delete", params.id);
changed(params.id);
return { ok: true as const };
});
route(app, Api.schedules.runNow, async ({ params, actor }) => {
const { row, queued } = await requestRun(ctx.db, params.id);
if (queued) {
await audit(ctx, actor, "schedule.run_now", row.id);
changed(row.id);
}
return { job: jobView(row), queued };
});
route(app, Api.schedules.skip, async ({ params, actor }) => {
const row = await skipNext(ctx.db, params.id, new Date());
await audit(ctx, actor, "schedule.skip", row.id, { nextRunAt: row.next_run_at?.toISOString() ?? null });
changed(row.id);
return jobView(row);
});
route(app, Api.schedules.runs, async ({ params, query }) => ({
items: await listRuns(ctx.db, params.id, query.limit),
}));
}
@@ -0,0 +1,326 @@
import { SCHEDULE_CHANGED_EVENT } from "@umt/contracts";
import { type DB, type Kysely, type ScheduledJobRow, sql } from "@umt/db";
import type { Logger } from "pino";
import type { EventBusPort } from "../../http/sse.ts";
import type { ServerContext } from "../types.ts";
import { runAgentCheck } from "./agent-checks.ts";
import type { JobRunFn, JobRunResult, ScheduleRegistry } from "./registry.ts";
import { computeNext, LOCK_NS, parseSpec, slotOf, syncDefinitions, trimRuns } from "./store.ts";
// 调度器:只在 io worker(或 all)运行;多个实例用 pg_try_advisory_lock 选出唯一执行者(锁在一条专用连接上,
// 连接断开即释放,其它实例下一轮接手)。执行者每 tickMs 查一次到期(enabled 且 next_run_at 已到)或被请求
// 立即执行(run_requested_at)的任务,逐个异步执行(不同任务并行,同一任务不重入),结果写回表与运行历史。
// interval 任务的下次时间 = 本次结束 + 周期(与原来的自调度循环一致);cron 任务取下一个匹配时间。
// 立即执行不改下次运行时间。
export interface SchedulerOptions {
/** 查到期任务的间隔(缺省 1s) */
tickMs?: number;
/** 抢锁失败后的重试间隔(缺省 5s) */
lockRetryMs?: number;
/** 回填巡检 Agent 结果的间隔(缺省 15s) */
harvestMs?: number;
/** 停止时等待进行中任务的上限(缺省 10s) */
stopWaitMs?: number;
now?: () => Date;
}
const TERMINAL_AGENT = ["succeeded", "failed", "cancelled", "budget_exceeded"];
const errText = (err: unknown) => (err instanceof Error ? err.message : String(err)).slice(0, 2000);
export class Scheduler {
private stopped = false;
private wake: (() => void) | null = null;
private loop: Promise<void> | null = null;
private inflight = new Map<string, Promise<void>>();
private syncedVersion = -1;
private lastHarvest = 0;
leader = false;
private readonly db: Kysely<DB>;
private readonly log: Logger;
private readonly events: EventBusPort;
private readonly now: () => Date;
constructor(
private readonly ctx: ServerContext,
private readonly registry: ScheduleRegistry,
private readonly opts: SchedulerOptions = {},
) {
this.db = ctx.db;
this.log = ctx.logger.child({ component: "scheduler" });
this.events = ctx.events;
this.now = opts.now ?? (() => new Date());
}
start(): void {
if (this.loop) return;
this.stopped = false;
this.loop = this.leaderLoop();
}
async stop(): Promise<void> {
this.stopped = true;
this.wake?.();
await this.loop;
this.loop = null;
const pending = [...this.inflight.values()];
if (pending.length) {
await Promise.race([Promise.allSettled(pending), this.sleep(this.opts.stopWaitMs ?? 10_000, true)]);
}
}
/** 可被 stop 打断的等待 */
private sleep(ms: number, ignoreStop = false): Promise<void> {
return new Promise((resolve) => {
const t = setTimeout(done, ms);
function done() {
clearTimeout(t);
resolve();
}
if (!ignoreStop) this.wake = done;
});
}
private async leaderLoop(): Promise<void> {
const retry = this.opts.lockRetryMs ?? 5000;
while (!this.stopped) {
try {
await this.db.connection().execute(async (conn) => {
while (!this.stopped) {
const r = await sql<{ ok: boolean }>`
select pg_try_advisory_lock(${LOCK_NS}::int4, hashtext(current_schema())) as ok`.execute(conn);
if (r.rows[0]?.ok) break;
await this.sleep(retry);
}
if (this.stopped) return;
this.leader = true;
this.log.info("调度器取得执行权");
try {
await this.recoverInterrupted();
while (!this.stopped) {
await this.tick();
await this.sleep(this.opts.tickMs ?? 1000);
// 探活:专用连接断了锁也就没了,抛错后重新抢锁
await sql`select 1`.execute(conn);
}
} finally {
this.leader = false;
await sql`select pg_advisory_unlock(${LOCK_NS}::int4, hashtext(current_schema()))`
.execute(conn)
.catch(() => undefined);
}
});
} catch (err) {
this.leader = false;
this.log.warn({ err: { message: errText(err) } }, "调度器循环出错,稍后重试");
if (!this.stopped) await this.sleep(retry);
}
}
}
/** 上一个执行者中断时留下的 running 状态:标为失败 */
private async recoverInterrupted(): Promise<void> {
const msg = "调度器重启,上次运行中断";
await this.db
.updateTable("scheduled_job_run")
.set({ status: "failed", error: msg, finished_at: this.now() })
.where("status", "=", "running")
.execute();
await this.db
.updateTable("scheduled_job")
.set({ running_since: null, last_status: "failed", last_error: msg })
.where("running_since", "is not", null)
.execute();
}
/** 一轮:同步定义 → 找到期任务 → 异步执行;定期回填巡检结果。测试可直接调用 */
async tick(): Promise<string[]> {
const now = this.now();
if (this.registry.version !== this.syncedVersion) {
const v = this.registry.version;
await syncDefinitions(this.db, this.registry.list(), now);
this.syncedVersion = v;
}
const busy = [...this.inflight.keys()];
const due = await this.db
.selectFrom("scheduled_job")
.selectAll()
.where("available", "=", true)
.where((eb) =>
eb.or([
eb("run_requested_at", "is not", null),
eb.and([eb("enabled", "=", true), eb("next_run_at", "<=", now)]),
]),
)
.$if(busy.length > 0, (q) => q.where("id", "not in", busy))
.orderBy("next_run_at")
.limit(50)
.execute();
const started: string[] = [];
for (const job of due) {
const fn = this.runFor(job);
if (!fn) continue;
const p = this.execute(job, fn).finally(() => this.inflight.delete(job.id));
this.inflight.set(job.id, p);
started.push(job.id);
}
if (Date.now() - this.lastHarvest >= (this.opts.harvestMs ?? 15_000)) {
this.lastHarvest = Date.now();
await this.harvest().catch((err) => this.log.warn({ err: { message: errText(err) } }, "巡检结果回填失败"));
}
return started;
}
/** 等进行中的任务全部结束(测试用) */
async idle(): Promise<void> {
while (this.inflight.size) await Promise.allSettled([...this.inflight.values()]);
}
private runFor(job: ScheduledJobRow): JobRunFn | null {
const def = this.registry.get(job.id);
if (def) return def.run;
// 自定义巡检不在代码里登记,共用巡检执行体
if (job.kind === "agent_check") return (c) => runAgentCheck(this.ctx, c);
return null;
}
private publish(jobId: string, status: string | null) {
void this.events.publish(SCHEDULE_CHANGED_EVENT, { jobId, status });
}
private async execute(job: ScheduledJobRow, fn: JobRunFn): Promise<void> {
const manual = job.run_requested_at !== null;
const spec = parseSpec(job.schedule);
const startedAt = this.now();
const t0 = performance.now();
// 认领:清掉请求标志、标运行中(条件更新,别的执行者抢先认领过就放弃)
const claimed = await this.db
.updateTable("scheduled_job")
.set({ running_since: startedAt, last_status: "running", run_requested_at: null })
.where("id", "=", job.id)
.where("running_since", "is", null)
.returning("id")
.executeTakeFirst();
if (!claimed) return;
const run = await this.db
.insertInto("scheduled_job_run")
.values({ job_id: job.id, trigger: manual ? "manual" : "schedule", status: "running", started_at: startedAt })
.returning("id")
.executeTakeFirstOrThrow();
if (manual || job.last_status !== "ok") this.publish(job.id, "running");
let status: "ok" | "skipped" | "failed" = "ok";
let error: string | null = null;
let result: JobRunResult | undefined;
try {
result =
(await fn({
jobId: job.id,
runId: run.id,
trigger: manual ? "manual" : "schedule",
now: startedAt,
config: (job.config ?? {}) as Record<string, unknown>,
slot: manual ? `manual:${run.id}` : slotOf(spec, startedAt, job.next_run_at),
})) ?? undefined;
status = result?.status ?? "ok";
} catch (err) {
status = "failed";
error = errText(err);
this.log.error({ err: { message: error }, job: job.id }, "定时任务执行失败");
}
const finishedAt = this.now();
const durationMs = Math.max(0, Math.round(performance.now() - t0));
// 计划触发(或从未排过)才推进下次时间;立即执行保持原计划
const advance = !manual || job.next_run_at === null || job.next_run_at <= startedAt;
try {
await this.db.transaction().execute(async (trx) => {
const cur = await trx
.selectFrom("scheduled_job")
.select(["schedule"])
.where("id", "=", job.id)
.executeTakeFirst();
if (!cur) return; // 运行期间被删除
const liveSpec = parseSpec(cur.schedule);
await trx
.updateTable("scheduled_job")
.set({
running_since: null,
last_run_at: startedAt,
last_status: status,
last_duration_ms: durationMs,
last_error: error,
run_count: sql`run_count + 1`,
...(status === "failed" ? { fail_count: sql`fail_count + 1` } : {}),
...(advance
? { next_run_at: computeNext(liveSpec, liveSpec.type === "interval" ? finishedAt : startedAt) }
: {}),
})
.where("id", "=", job.id)
.execute();
await trx
.updateTable("scheduled_job_run")
.set({
status,
finished_at: finishedAt,
duration_ms: durationMs,
error,
summary: result?.summary ?? null,
agent_run_id: result?.agentRunId ?? null,
})
.where("id", "=", run.id)
.execute();
await trimRuns(trx, job.id);
});
} catch (err) {
this.log.error({ err: { message: errText(err) }, job: job.id }, "定时任务结果写回失败");
}
if (manual || status !== job.last_status || status === "failed") this.publish(job.id, status);
}
/** 巡检入队的 agent_run 结束后:回填终态与摘要;配置了 reportAsDecision 的写一条 info 待处理 */
async harvest(): Promise<number> {
const rows = await sql<{
id: string;
job_id: string;
job_name: string;
config: Record<string, unknown>;
agent_run_id: string;
status: string;
summary: string | null;
}>`
select r.id, r.job_id, j.name as job_name, j.config, r.agent_run_id, a.status, a.summary
from scheduled_job_run r
join scheduled_job j on j.id = r.job_id
join agent_run a on a.id = r.agent_run_id
where r.agent_status is null and a.status = any(${TERMINAL_AGENT})
limit 50`.execute(this.db);
for (const r of rows.rows) {
const summary = (r.summary ?? "").trim() || `Agent 运行结束:${r.status}`;
await this.db
.updateTable("scheduled_job_run")
.set({ agent_status: r.status, summary: summary.slice(0, 4000) })
.where("id", "=", r.id)
.execute();
if (r.config?.reportAsDecision === true && r.status === "succeeded") {
const ins = await sql<{ id: string }>`
insert into decision (kind, severity, title, summary, subject_type, subject_id, dedupe_key, evidence, agent_run_id)
values ('other', 'info', ${`巡检报告:${r.job_name}`}, ${summary.slice(0, 4000)}, 'agent_run', ${r.agent_run_id},
${`schedule-report:${r.id}`}, ${JSON.stringify({ scheduledJobId: r.job_id, scheduledRunId: r.id })}::jsonb,
${r.agent_run_id})
on conflict do nothing
returning id`.execute(this.db);
const d = ins.rows[0];
if (d)
await this.ctx.outbox.emit(
this.db,
"decision.created",
{ decisionId: d.id, kind: "other", severity: "info", title: `巡检报告:${r.job_name}` },
{ idempotencyKey: `decision.created:${d.id}` },
);
}
this.publish(r.job_id, null);
}
return rows.rows.length;
}
}
+322
View File
@@ -0,0 +1,322 @@
import { randomUUID } from "node:crypto";
import {
AgentCheckConfig,
type AgentCheckCreate,
type ScheduleBoard,
type ScheduledJob,
type ScheduledJobRun,
ScheduleSpec,
type ScheduleUpdate,
} from "@umt/contracts";
import { type DB, type Kysely, type ScheduledJobRow, type ScheduledJobRunRow, sql } from "@umt/db";
import { ApiException } from "../../http/errors.ts";
import { nextCronAfter, parseCron } from "./cron.ts";
import type { ScheduledJobDef } from "./registry.ts";
// scheduled_job / scheduled_job_run 读写:定义同步(不覆盖用户改动)、视图映射、看板操作。
/** 咨询锁命名空间('umts');第二个键是 hashtext(current_schema()),测试 schema 之间互不影响 */
export const LOCK_NS = 0x756d7473;
/** 每个任务保留的运行历史条数 */
export const RUN_HISTORY_KEEP = 50;
const iso = (d: Date | null) => (d ? d.toISOString() : null);
export function parseSpec(v: unknown): ScheduleSpec {
return ScheduleSpec.parse(v);
}
/** from 之后的下一次计划时间 */
export function computeNext(spec: ScheduleSpec, from: Date): Date {
return spec.type === "interval" ? new Date(from.getTime() + spec.everyMs) : nextCronAfter(spec.cron, from);
}
/** 时间槽:interval 按周期取整;cron 用本次计划时间 */
export function slotOf(spec: ScheduleSpec, now: Date, planned: Date | null): string {
if (spec.type === "interval") return String(Math.floor(now.getTime() / spec.everyMs));
return String(Math.floor((planned ?? now).getTime() / 60_000));
}
export function validateSpec(spec: ScheduleSpec): void {
if (spec.type === "cron") {
try {
parseCron(spec.cron);
} catch (err) {
throw new ApiException("validation_failed", (err as Error).message);
}
}
}
export function jobView(r: ScheduledJobRow): ScheduledJob {
return {
id: r.id,
name: r.name,
description: r.description,
group: r.board_group,
kind: r.kind,
builtin: r.builtin,
available: r.available,
enabled: r.enabled,
schedule: parseSpec(r.schedule),
defaultSchedule: parseSpec(r.default_schedule),
customized: r.customized,
config: (r.config ?? {}) as Record<string, unknown>,
lastRunAt: iso(r.last_run_at),
lastStatus: r.last_status,
lastDurationMs: r.last_duration_ms,
lastError: r.last_error,
nextRunAt: iso(r.next_run_at),
runRequestedAt: iso(r.run_requested_at),
runningSince: iso(r.running_since),
runCount: r.run_count,
failCount: r.fail_count,
createdAt: r.created_at.toISOString(),
updatedAt: r.updated_at.toISOString(),
};
}
export function runView(r: ScheduledJobRunRow): ScheduledJobRun {
return {
id: r.id,
jobId: r.job_id,
trigger: r.trigger,
status: r.status,
startedAt: r.started_at.toISOString(),
finishedAt: iso(r.finished_at),
durationMs: r.duration_ms,
error: r.error,
summary: r.summary,
agentRunId: r.agent_run_id,
agentStatus: r.agent_status,
createdAt: r.created_at.toISOString(),
};
}
/**
* 把代码登记的定义同步进表:新行按默认值插入;已有行刷新名称/分组/默认值,
* 用户改过(customized)的 enabled/schedule 保留;config 以表内值为准、补上新增的默认键。
* 未改过且默认调度变了的行,下次运行改为立即(随后按新周期)。
* 同步时代码里已不再登记的内置 task 行标为不可用(不会执行,看板显示"未登记")。
*/
export async function syncDefinitions(db: Kysely<DB>, defs: readonly ScheduledJobDef[], now: Date): Promise<void> {
for (const d of defs) {
const spec = parseSpec(d.defaults.schedule);
const enabled = d.defaults.enabled ?? true;
const first = d.defaults.firstRun === "after_interval" ? computeNext(spec, now) : now;
await sql`
insert into scheduled_job (id, name, description, board_group, kind, builtin, available, enabled, default_enabled,
schedule, default_schedule, config, next_run_at)
values (${d.key}, ${d.defaults.name}, ${d.defaults.description ?? null}, ${d.defaults.group}, ${d.kind ?? "task"},
true, ${d.defaults.available ?? true}, ${enabled}, ${enabled},
${JSON.stringify(spec)}::jsonb, ${JSON.stringify(spec)}::jsonb, ${JSON.stringify(d.defaults.config ?? {})}::jsonb,
${first})
on conflict (id) do update set
name = excluded.name,
description = excluded.description,
board_group = excluded.board_group,
kind = excluded.kind,
builtin = true,
available = excluded.available,
default_enabled = excluded.default_enabled,
default_schedule = excluded.default_schedule,
enabled = case when scheduled_job.customized then scheduled_job.enabled else excluded.enabled end,
schedule = case when scheduled_job.customized then scheduled_job.schedule else excluded.schedule end,
config = excluded.config || scheduled_job.config,
next_run_at = case
when not scheduled_job.customized and scheduled_job.schedule is distinct from excluded.schedule then ${now}
else coalesce(scheduled_job.next_run_at, excluded.next_run_at) end`.execute(db);
}
const keys = defs.map((d) => d.key);
await db
.updateTable("scheduled_job")
.set({ available: false })
.where("builtin", "=", true)
.where("kind", "=", "task")
.where("available", "=", true)
.$if(keys.length > 0, (q) => q.where("id", "not in", keys))
.execute();
}
export async function getJob(db: Kysely<DB>, id: string): Promise<ScheduledJobRow> {
const row = await db.selectFrom("scheduled_job").selectAll().where("id", "=", id).executeTakeFirst();
if (!row) throw new ApiException("not_found", `定时任务不存在:${id}`);
return row;
}
export async function schedulerOnline(db: Kysely<DB>): Promise<boolean> {
const r = await sql<{ on: boolean }>`
select exists(
select 1 from pg_locks
where locktype = 'advisory' and objsubid = 2 and granted
and classid = ${LOCK_NS}::int4::oid and objid = hashtext(current_schema())::oid
) as on`.execute(db);
return r.rows[0]?.on ?? false;
}
export async function board(db: Kysely<DB>): Promise<ScheduleBoard> {
const rows = await db
.selectFrom("scheduled_job")
.selectAll()
.orderBy("board_group")
.orderBy("builtin", "desc")
.orderBy("created_at")
.orderBy("id")
.execute();
const items = rows.map(jobView);
return {
items,
stats: {
total: items.length,
enabled: items.filter((j) => j.enabled && j.available).length,
failed: items.filter((j) => j.lastStatus === "failed").length,
running: items.filter((j) => j.runningSince !== null).length,
},
schedulerOnline: await schedulerOnline(db),
};
}
export async function updateJob(
db: Kysely<DB>,
id: string,
patch: ScheduleUpdate,
now: Date,
): Promise<ScheduledJobRow> {
const row = await getJob(db, id);
const set: Record<string, unknown> = {};
if (patch.reset) {
const spec = parseSpec(row.default_schedule);
Object.assign(set, {
enabled: row.default_enabled,
schedule: JSON.stringify(spec),
customized: false,
next_run_at: computeNext(spec, now),
});
}
if (patch.schedule) {
validateSpec(patch.schedule);
Object.assign(set, {
schedule: JSON.stringify(patch.schedule),
customized: true,
next_run_at: computeNext(patch.schedule, now),
});
}
if (patch.enabled !== undefined) {
if (patch.enabled && !row.available) throw new ApiException("conflict", "该任务未配置,不能开启");
set.enabled = patch.enabled;
set.customized = true;
if (patch.enabled && !row.enabled && !set.next_run_at) {
set.next_run_at = computeNext(parseSpec(set.schedule ? JSON.parse(set.schedule as string) : row.schedule), now);
}
}
if (patch.name !== undefined || patch.description !== undefined) {
if (row.builtin) throw new ApiException("conflict", "内置任务不能改名");
if (patch.name !== undefined) set.name = patch.name;
if (patch.description !== undefined) set.description = patch.description || null;
}
if (patch.config) {
if (row.kind === "agent_check") {
const parsed = AgentCheckConfig.safeParse(patch.config);
if (!parsed.success) throw new ApiException("validation_failed", parsed.error.issues[0]?.message ?? "参数无效");
set.config = JSON.stringify(parsed.data);
} else {
set.config = JSON.stringify({ ...((row.config ?? {}) as object), ...patch.config });
}
}
return await db.updateTable("scheduled_job").set(set).where("id", "=", id).returningAll().executeTakeFirstOrThrow();
}
/** 立即执行:置 run_requested_at(已在排队或运行中则合并,不重复) */
export async function requestRun(db: Kysely<DB>, id: string): Promise<{ row: ScheduledJobRow; queued: boolean }> {
const row = await getJob(db, id);
if (!row.available) throw new ApiException("conflict", "该任务未配置,不能执行");
const upd = await db
.updateTable("scheduled_job")
.set({ run_requested_at: sql`now()` })
.where("id", "=", id)
.where("run_requested_at", "is", null)
.where("running_since", "is", null)
.returningAll()
.executeTakeFirst();
return upd ? { row: upd, queued: true } : { row, queued: false };
}
/** 跳过本次:下次运行顺延一个周期,记一条 skip 历史 */
export async function skipNext(db: Kysely<DB>, id: string, now: Date): Promise<ScheduledJobRow> {
const row = await getJob(db, id);
const spec = parseSpec(row.schedule);
const base = row.next_run_at && row.next_run_at > now ? row.next_run_at : now;
const next = computeNext(spec, base);
return await db.transaction().execute(async (trx) => {
const upd = await trx
.updateTable("scheduled_job")
.set({ next_run_at: next })
.where("id", "=", id)
.returningAll()
.executeTakeFirstOrThrow();
await trx
.insertInto("scheduled_job_run")
.values({
job_id: id,
trigger: "skip",
status: "skipped",
started_at: now,
finished_at: now,
duration_ms: 0,
summary: `跳过 ${base.toISOString()},下次 ${next.toISOString()}`,
})
.execute();
await trimRuns(trx, id);
return upd;
});
}
export async function trimRuns(db: Kysely<DB>, jobId: string, keep = RUN_HISTORY_KEEP): Promise<void> {
await sql`
delete from scheduled_job_run where job_id = ${jobId} and id not in (
select id from scheduled_job_run where job_id = ${jobId} order by started_at desc limit ${keep})`.execute(db);
}
export async function listRuns(db: Kysely<DB>, id: string, limit: number): Promise<ScheduledJobRun[]> {
await getJob(db, id);
const rows = await db
.selectFrom("scheduled_job_run")
.selectAll()
.where("job_id", "=", id)
.orderBy("started_at", "desc")
.limit(limit)
.execute();
return rows.map(runView);
}
export async function createAgentCheck(db: Kysely<DB>, body: AgentCheckCreate, now: Date): Promise<ScheduledJobRow> {
const spec = parseSpec(body.schedule);
validateSpec(spec);
const config = AgentCheckConfig.parse(body.config);
const enabled = body.enabled ?? false;
return await db
.insertInto("scheduled_job")
.values({
id: `check.custom.${randomUUID().slice(0, 8)}`,
name: body.name,
description: body.description ?? null,
board_group: "agent",
kind: "agent_check",
builtin: false,
available: true,
enabled,
default_enabled: enabled,
schedule: JSON.stringify(spec),
default_schedule: JSON.stringify(spec),
customized: true,
config: JSON.stringify(config),
next_run_at: computeNext(spec, now),
})
.returningAll()
.executeTakeFirstOrThrow();
}
export async function deleteJob(db: Kysely<DB>, id: string): Promise<void> {
const row = await getJob(db, id);
if (row.builtin || row.kind !== "agent_check") throw new ApiException("conflict", "内置任务不能删除,可以停用");
await db.deleteFrom("scheduled_job").where("id", "=", id).execute();
}
@@ -1,3 +1,4 @@
import { registerScheduledJob } from "../schedules/registry.ts";
import type { ServerContext, ServerModule } from "../types.ts";
import { registerSourcesCapabilities } from "./capabilities.ts";
import { decisionRoutes } from "./routes/decisions.ts";
@@ -5,7 +6,7 @@ import { identityRoutes } from "./routes/identity.ts";
import { sourcesRoutes } from "./routes/sources.ts";
import { workbenchRoutes } from "./routes/workbench.ts";
import { type RuntimeOverrides, SourcesRuntime } from "./runtime.ts";
import { schedulerTick } from "./scheduler.ts";
import { readOnlyReconcile, tickFeeds, tickReviews, tickSnapshots } from "./scheduler.ts";
import { registerSourcesTasks, sourcesOutboxRoutes } from "./tasks.ts";
/**
@@ -25,8 +26,6 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
}
return rt;
};
let timer: ReturnType<typeof setInterval> | null = null;
let ticking: Promise<unknown> | null = null;
return {
name: "sources-identity",
@@ -45,23 +44,22 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
registerCapabilities(registry, ctx) {
registerSourcesCapabilities(registry, () => runtime(ctx));
},
/** 周期扫描登记到定时任务注册表(schedules 模块的调度器驱动);周期是总扫描频率 */
start(ctx) {
const rt = runtime(ctx);
const tick = () => {
if (ticking) return;
ticking = schedulerTick(rt)
.catch((err) => ctx.logger.warn({ err }, "sources-identity 调度失败"))
.finally(() => {
ticking = null;
});
};
timer = setInterval(tick, overrides.schedulerIntervalMs ?? 30_000);
tick();
const everyMs = overrides.schedulerIntervalMs ?? 30_000;
const job = (key: string, name: string, description: string, fn: (rt: SourcesRuntime) => Promise<number>) =>
registerScheduledJob(ctx, {
key,
defaults: { name, description, group: "collect", schedule: { type: "interval", everyMs } },
run: async () => {
if (await readOnlyReconcile(rt)) return { status: "skipped", summary: "只读对账模式" };
return { summary: `${await fn(rt)} 项` };
},
async stop() {
if (timer) clearInterval(timer);
timer = null;
await ticking;
});
job("feeds.poll", "Feed 轮询", "扫描到期的 Feed 并入队抓取(每个 Feed 仍按自己的轮询间隔)", tickFeeds);
job("sites.snapshot", "站点快照", "扫描到周期的站点并入队快照", tickSnapshots);
job("identity.review", "待识别复查", "到期的待识别条目重新识别", tickReviews);
},
};
}
@@ -4,6 +4,8 @@ import type { SourcesRuntime } from "./runtime.ts";
// worker 后台调度:按 Feed 轮询策略发 feed.due(outbox → ingestFeed)、按站点快照周期入队 snapshotSite、
// 按待识别池 next_review_at 入队复查。幂等键按时间槽生成,多 worker 并发也不会重复。
// 三项分别登记为定时任务(feeds.poll / sites.snapshot / identity.review,由 schedules 模块的调度器驱动,
// 这里的周期只是总扫描频率,每个 Feed / 站点仍按自己的策略判断是否到期);schedulerTick 依次跑三项(测试用)。
export interface TickResult {
feedsDue: number;
@@ -14,14 +16,24 @@ export interface TickResult {
const REVIEW_LEASE_MS = 10 * 60_000;
export async function schedulerTick(rt: SourcesRuntime): Promise<TickResult> {
const { ctx } = rt;
const automation = await ctx.settings.get("automation");
// 恢复后只读对账模式:禁止自动写入
if (automation.readOnlyReconcile) return { feedsDue: 0, snapshotsDue: 0, reviewsDue: 0, skipped: "read_only" };
const now = rt.now();
/** 恢复后只读对账模式:禁止自动写入 */
export async function readOnlyReconcile(rt: SourcesRuntime): Promise<boolean> {
return (await rt.ctx.settings.get("automation")).readOnlyReconcile;
}
// Feed:到期(next_fetch_at 空或已到)且不在退避期、站点(若有)开启
export async function schedulerTick(rt: SourcesRuntime): Promise<TickResult> {
if (await readOnlyReconcile(rt)) return { feedsDue: 0, snapshotsDue: 0, reviewsDue: 0, skipped: "read_only" };
return {
feedsDue: await tickFeeds(rt),
snapshotsDue: await tickSnapshots(rt),
reviewsDue: await tickReviews(rt),
};
}
/** Feed:到期(next_fetch_at 空或已到)且不在退避期、站点(若有)开启 → feed.due */
export async function tickFeeds(rt: SourcesRuntime): Promise<number> {
const { ctx } = rt;
const now = rt.now();
const feedsDue = await ctx.db.transaction().execute(async (trx) => {
const due = await sql<{ id: string; poll_interval_sec: number }>`
select f.id, f.poll_interval_sec from feed_source f left join site s on s.id = f.site_id
@@ -47,8 +59,13 @@ export async function schedulerTick(rt: SourcesRuntime): Promise<TickResult> {
}
return due.rows.length;
});
return feedsDue;
}
// 站点快照:开启快照且到周期
/** 站点快照:开启快照且到周期 → snapshotSite */
export async function tickSnapshots(rt: SourcesRuntime): Promise<number> {
const { ctx } = rt;
const now = rt.now();
const sites = await sql<{ id: string; snapshot_interval_sec: number }>`
select id, snapshot_interval_sec from site
where enabled and snapshot_enabled and snapshot_interval_sec is not null
@@ -63,8 +80,13 @@ export async function schedulerTick(rt: SourcesRuntime): Promise<TickResult> {
{ idempotencyKey: `snapshotSite:${s.id}:${slot}`, traceId: randomUUID() },
);
}
return sites.rows.length;
}
// 待识别池复查:到期的先顺延一个租约,避免下个 tick 重复入队
/** 待识别池复查:到期的先顺延一个租约,避免下个 tick 重复入队 */
export async function tickReviews(rt: SourcesRuntime): Promise<number> {
const { ctx } = rt;
const now = rt.now();
const reviews = await ctx.db.transaction().execute(async (trx) => {
const rows = await sql<{ id: string; source_release_id: string; attempts: number }>`
select id, source_release_id, attempts from identity_candidate
@@ -86,5 +108,5 @@ export async function schedulerTick(rt: SourcesRuntime): Promise<TickResult> {
{ idempotencyKey: `review:${r.id}:${r.attempts}`, traceId: randomUUID() },
);
}
return { feedsDue, snapshotsDue: sites.rows.length, reviewsDue: reviews.length };
return reviews.length;
}
+8
View File
@@ -75,6 +75,14 @@ describe("Api 注册表鉴权标注", () => {
"POST /pi/upstreams/{id}/oauth/start",
"POST /pi/upstreams/{id}/oauth/complete",
"POST /pi/upstreams/{id}/oauth/logout",
// W7-C 定时任务看板:全部只接受控制台会话
"GET /schedules",
"POST /schedules",
"PATCH /schedules/{id}",
"DELETE /schedules/{id}",
"POST /schedules/{id}/run-now",
"POST /schedules/{id}/skip",
"GET /schedules/{id}/runs",
].sort(),
);
for (const e of listEndpoints().filter((x) => x.auth === "session")) expect(e.scope).toBe("admin");
+3
View File
@@ -5,6 +5,7 @@ import { makeTorrent } from "@umt/testkit";
import { createAcquisitionOrganizeModule } from "../../../src/modules/acquisition-organize/index.ts";
import { createAgentModule } from "../../../src/modules/agent/index.ts";
import { authSettingsModule } from "../../../src/modules/auth-settings/index.ts";
import { createSchedulesModule } from "../../../src/modules/schedules/index.ts";
import { createSourcesIdentityModule } from "../../../src/modules/sources-identity/index.ts";
import type { ServerModule } from "../../../src/modules/types.ts";
@@ -64,5 +65,7 @@ export function buildModules(o: E2EModuleOptions): ServerModule[] {
...(o.fakeScripts ? { fakeScripts: o.fakeScripts } : {}),
goalPollSec: 1,
}),
// 定时任务调度器(驱动上面模块登记的周期任务;须在末尾)
createSchedulesModule({ builtinChecks: false, scheduler: { tickMs: 100, lockRetryMs: 200 } }),
];
}
+2 -1
View File
@@ -43,7 +43,7 @@ const pingModule: ServerModule = {
};
describe("ServerModule 骨架", () => {
it("清单里是四个 W2 模块 + 文件管理 + Pi 上游,名字唯一", () => {
it("清单里是四个 W2 模块 + 文件管理 + Pi 上游 + 定时任务(末尾),名字唯一", () => {
expect(SERVER_MODULES.map((m) => m.name)).toEqual([
"auth-settings",
"sources-identity",
@@ -51,6 +51,7 @@ describe("ServerModule 骨架", () => {
"agent",
"file-manager",
"pi",
"schedules",
]);
});
@@ -0,0 +1,325 @@
import { randomUUID } from "node:crypto";
import { sql } from "@umt/db";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { ensureSetupToken } from "../../src/modules/auth-settings/auth-service.ts";
import { nextCronAfter } from "../../src/modules/schedules/cron.ts";
import { createSchedulesModule } from "../../src/modules/schedules/index.ts";
import {
registerScheduledJob,
type ScheduledJobDef,
type ScheduleRegistry,
scheduleRegistry,
} from "../../src/modules/schedules/registry.ts";
import { Scheduler } from "../../src/modules/schedules/scheduler.ts";
import type { ServerContext } from "../../src/modules/types.ts";
import { Browser, bearer, createHarness, type Harness, pgAvailable } from "../auth-settings/harness.ts";
import { DEV_KEY, json } from "../helpers.ts";
// W7-C 定时任务注册表:登记不覆盖用户改动、调度单实例锁、立即执行幂等、失败记录、跳过本次、
// agent_check 入队 runAgent 与结果回填、端点鉴权(只接受控制台会话)。
const hasPg = await pgAvailable();
const PASSWORD = "correct horse battery";
describe("cron", () => {
it("按 5 段表达式取下一次时间", () => {
const base = new Date(2026, 8, 28, 10, 7, 30); // 周一
expect(nextCronAfter("*/15 * * * *", base)).toEqual(new Date(2026, 8, 28, 10, 15, 0));
expect(nextCronAfter("30 3 * * *", base)).toEqual(new Date(2026, 8, 29, 3, 30, 0));
expect(nextCronAfter("0 9 * * 0", base)).toEqual(new Date(2026, 9, 4, 9, 0, 0));
expect(nextCronAfter("0 0 1 1 *", base)).toEqual(new Date(2027, 0, 1, 0, 0, 0));
expect(() => nextCronAfter("61 * * * *", base)).toThrow();
});
});
interface Enq {
task: string;
input: unknown;
key: string;
}
describe.skipIf(!hasPg)("定时任务注册表(真实 PG)", () => {
let h: Harness;
let admin: Browser;
const enqueued: Enq[] = [];
const mod = createSchedulesModule();
let ctx: ServerContext;
let reg: ScheduleRegistry;
let sched: Scheduler;
const calls = new Map<string, number>();
const api = async (method: string, path: string, body?: unknown) => {
const res = await admin.req(method, path, body);
return { status: res.status, body: await json(res) };
};
const job = async (id: string) =>
await h.db.selectFrom("scheduled_job").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
const counting = (key: string, extra: Partial<ScheduledJobDef> = {}): ScheduledJobDef => ({
key,
defaults: { name: key, group: "maintain", schedule: { type: "interval", everyMs: 60_000 } },
run: async () => {
calls.set(key, (calls.get(key) ?? 0) + 1);
return { summary: "done" };
},
...extra,
});
beforeAll(async () => {
h = await createHarness({ extraModules: [mod] });
ctx = h.ctx;
// runAgent 入队用的假引擎(同键去重)
const keys = new Set<string>();
(ctx as { engine: ServerContext["engine"] }).engine = {
...ctx.engine,
async enqueue(task: string, input: unknown, opts: { idempotencyKey: string }) {
const dup = keys.has(opts.idempotencyKey);
if (!dup) {
keys.add(opts.idempotencyKey);
enqueued.push({ task, input, key: opts.idempotencyKey });
}
return { runId: randomUUID(), deduplicated: dup };
},
} as ServerContext["engine"];
reg = scheduleRegistry(ctx);
sched = new Scheduler(ctx, reg, { tickMs: 3_600_000, lockRetryMs: 50, harvestMs: 0 });
const token = await ensureSetupToken(ctx);
admin = new Browser(h.app);
expect(
(await admin.req("POST", "/auth/setup", { setupToken: token, username: "admin", password: PASSWORD })).status,
).toBe(200);
}, 60_000);
afterAll(async () => {
await sched?.stop();
await h?.close();
});
it("登记默认值;用户改过的开关与调度不被再次登记覆盖;reset 恢复默认", async () => {
registerScheduledJob(ctx, counting("test.keep"));
await sched.tick();
await sched.idle();
let row = await job("test.keep");
expect(row).toMatchObject({ enabled: true, customized: false, schedule: { type: "interval", everyMs: 60_000 } });
const r = await api("PATCH", "/schedules/test.keep", {
enabled: false,
schedule: { type: "interval", everyMs: 120_000 },
});
expect(r.status).toBe(200);
expect(r.body.data).toMatchObject({ enabled: false, customized: true });
// 模块再次启动,默认值变了
registerScheduledJob(
ctx,
counting("test.keep", {
defaults: { name: "改名了", group: "download", schedule: { type: "interval", everyMs: 30_000 } },
}),
);
await sched.tick();
row = await job("test.keep");
expect(row).toMatchObject({
name: "改名了",
board_group: "download",
enabled: false,
schedule: { type: "interval", everyMs: 120_000 },
default_schedule: { type: "interval", everyMs: 30_000 },
});
const reset = await api("PATCH", "/schedules/test.keep", { reset: true });
expect(reset.body.data).toMatchObject({
enabled: true,
customized: false,
schedule: { type: "interval", everyMs: 30_000 },
});
// 间隔下限与 cron 校验
expect((await api("PATCH", "/schedules/test.keep", { schedule: { type: "interval", everyMs: 500 } })).status).toBe(
422,
);
expect(
(await api("PATCH", "/schedules/test.keep", { schedule: { type: "cron", cron: "99 * * * *" } })).status,
).toBe(422);
const cron = await api("PATCH", "/schedules/test.keep", { schedule: { type: "cron", cron: "0 4 * * *" } });
expect(cron.status).toBe(200);
expect(new Date(cron.body.data.nextRunAt).getHours()).toBe(4);
});
it("立即执行幂等(合并重复请求)且不改下次运行时间", async () => {
registerScheduledJob(
ctx,
counting("test.now", { defaults: { ...counting("x").defaults, firstRun: "after_interval" } }),
);
await sched.tick();
const before = await job("test.now");
expect(before.next_run_at!.getTime()).toBeGreaterThan(Date.now());
const a = await api("POST", "/schedules/test.now/run-now");
const b = await api("POST", "/schedules/test.now/run-now");
expect(a.body.data.queued).toBe(true);
expect(b.body.data.queued).toBe(false);
const started = await sched.tick();
expect(started).toContain("test.now");
await sched.idle();
expect(await sched.tick()).not.toContain("test.now");
await sched.idle();
expect(calls.get("test.now")).toBe(1);
const after = await job("test.now");
expect(after.next_run_at).toEqual(before.next_run_at);
expect(after).toMatchObject({ last_status: "ok", run_count: 1, run_requested_at: null, running_since: null });
const runs = await api("GET", "/schedules/test.now/runs");
expect(runs.body.data.items).toHaveLength(1);
expect(runs.body.data.items[0]).toMatchObject({ trigger: "manual", status: "ok", summary: "done" });
});
it("失败写入 last_error / fail_count 与历史;跳过本次顺延一个周期", async () => {
registerScheduledJob(ctx, {
key: "test.fail",
defaults: { name: "会失败", group: "maintain", schedule: { type: "interval", everyMs: 60_000 } },
run: async () => {
throw new Error("qB 连不上");
},
});
await sched.tick();
await sched.idle();
const row = await job("test.fail");
expect(row).toMatchObject({ last_status: "failed", last_error: "qB 连不上", fail_count: 1, run_count: 1 });
// interval:下次 = 结束 + 周期
expect(row.next_run_at!.getTime() - Date.now()).toBeGreaterThan(50_000);
const skip = await api("POST", "/schedules/test.fail/skip");
expect(skip.status).toBe(200);
expect(new Date(skip.body.data.nextRunAt).getTime() - row.next_run_at!.getTime()).toBe(60_000);
const runs = (await api("GET", "/schedules/test.fail/runs")).body.data.items;
expect(runs.map((r: { trigger: string; status: string }) => `${r.trigger}:${r.status}`)).toEqual([
"skip:skipped",
"schedule:failed",
]);
expect(runs[1].error).toBe("qB 连不上");
});
it("单实例锁:两个调度器只有一个执行;执行者停止后另一个接手", async () => {
const regA = scheduleRegistry({ db: {} } as ServerContext);
const regB = scheduleRegistry({ db: {} } as ServerContext);
const ran: string[] = [];
for (const [name, r] of [
["A", regA],
["B", regB],
] as const) {
r.register({
key: "test.lock",
defaults: { name: "锁", group: "maintain", schedule: { type: "interval", everyMs: 1 } },
run: async () => {
ran.push(name);
},
});
}
const a = new Scheduler(ctx, regA, { tickMs: 20, lockRetryMs: 30 });
const b = new Scheduler(ctx, regB, { tickMs: 20, lockRetryMs: 30 });
// 表里其它测试的任务不要被 a/b 执行:只登记了 test.lock,其余 task 行没有执行体会被跳过
a.start();
await new Promise((r) => setTimeout(r, 150));
b.start();
await new Promise((r) => setTimeout(r, 400));
expect(a.leader).toBe(true);
expect(b.leader).toBe(false);
expect(ran.length).toBeGreaterThan(2);
expect(new Set(ran)).toEqual(new Set(["A"]));
const board = await api("GET", "/schedules");
expect(board.body.data.schedulerOnline).toBe(true);
await a.stop();
await new Promise((r) => setTimeout(r, 400));
expect(b.leader).toBe(true);
expect(ran).toContain("B");
await b.stop();
await sql`update scheduled_job set enabled = false where id = 'test.lock'`.execute(h.db);
});
it("agent_check:新建巡检 → 立即执行入队 runAgent;结束后回填结果并写入待处理", async () => {
const created = await api("POST", "/schedules", {
name: "自定义巡检",
schedule: { type: "interval", everyMs: 3_600_000 },
config: {
goalTemplate: "巡检 {{date}}:待处理 {{openDecisions}} 条",
capabilityAllowlist: ["task.inspect", "inventory.query", "download.submit"],
budget: { maxToolCalls: 5 },
reportAsDecision: true,
},
});
expect(created.status).toBe(200);
const id = created.body.data.id as string;
expect(created.body.data).toMatchObject({ kind: "agent_check", builtin: false, group: "agent", enabled: false });
expect((await api("POST", `/schedules/${id}/run-now`)).body.data.queued).toBe(true);
await sched.tick();
await sched.idle();
const [run] = (await api("GET", `/schedules/${id}/runs`)).body.data.items;
expect(run).toMatchObject({ trigger: "manual", status: "ok" });
expect(run.agentRunId).toBeTruthy();
const ar = await h.db
.selectFrom("agent_run")
.selectAll()
.where("id", "=", run.agentRunId)
.executeTakeFirstOrThrow();
expect(ar).toMatchObject({ trigger: "schedule", mode: "plan_only", created_by: `schedule:${id}` });
expect(ar.goal).toMatch(/^巡检 \d{4}-\d{2}-\d{2}:待处理 0 条$/);
// 只读巡检:execute 类能力(download.submit)被剔除
expect((ar.scope as { capabilities: string[] }).capabilities).toEqual(["task.inspect", "inventory.query"]);
expect(ar.budget).toMatchObject({ maxToolCalls: 5 });
expect(enqueued.filter((e) => e.task === "runAgent")).toHaveLength(1);
// runAgent 结束 → 回填
await h.db
.updateTable("agent_run")
.set({ status: "succeeded", summary: "一切正常" })
.where("id", "=", ar.id)
.execute();
expect(await sched.harvest()).toBe(1);
const [after] = (await api("GET", `/schedules/${id}/runs`)).body.data.items;
expect(after).toMatchObject({ agentStatus: "succeeded", summary: "一切正常" });
const d = await h.db.selectFrom("decision").selectAll().where("agent_run_id", "=", ar.id).executeTakeFirstOrThrow();
expect(d).toMatchObject({ kind: "other", severity: "info", summary: "一切正常" });
expect(await sched.harvest()).toBe(0);
// 自定义可改名、可删;内置不可删
expect((await api("PATCH", `/schedules/${id}`, { name: "夜间巡检" })).body.data.name).toBe("夜间巡检");
expect((await api("DELETE", "/schedules/test.keep")).status).toBe(409);
expect((await api("DELETE", `/schedules/${id}`)).status).toBe(200);
expect((await api("GET", `/schedules/${id}/runs`)).status).toBe(404);
});
it("内置巡检默认关闭;触发条件不满足时计划触发记 skipped,不唤醒 Agent", async () => {
await mod.start?.(ctx);
await mod.stop?.();
await sched.tick();
const board = (await api("GET", "/schedules")).body.data;
const checks = board.items.filter((j: { kind: string }) => j.kind === "agent_check");
expect(checks.map((j: { id: string }) => j.id).sort()).toEqual([
"check.downloads",
"check.pending",
"check.weekly",
]);
expect(checks.every((j: { enabled: boolean }) => !j.enabled)).toBe(true);
const backup = board.items.find((j: { id: string }) => j.id === "maintain.backup");
expect(backup).toMatchObject({ available: false, enabled: false });
expect((await api("PATCH", "/schedules/maintain.backup", { enabled: true })).status).toBe(409);
// 上一个用例写入的巡检报告算未关闭事项,先关掉
await sql`update decision set status = 'dismissed'`.execute(h.db);
await api("PATCH", "/schedules/check.pending", { enabled: true });
await sql`update scheduled_job set next_run_at = now() - interval '1 minute' where id = 'check.pending'`.execute(
h.db,
);
const before = enqueued.length;
await sched.tick();
await sched.idle();
const row = await job("check.pending");
expect(row.last_status).toBe("skipped");
expect(enqueued.length).toBe(before);
await api("PATCH", "/schedules/check.pending", { enabled: false });
});
it("端点只接受控制台会话", async () => {
const res = await bearer(h.app, DEV_KEY)("GET", "/schedules");
expect(res.status).toBe(403);
});
});