From bd0e0117485f3cf6a8f49db51c89805ab17ca621 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 29 Sep 2026 03:06:05 +0800 Subject: [PATCH] fix(schedules): refresh untouched builtin config so checks get new capabilities Built-in agent checks kept the config stored at first registration, so check.pending never received decision.list / subscription.list added in AG-3 and the patrol could not read pending items (REAL-2 scenario g). Track config_customized (migration 0045); sync replaces config with the code default unless the user PATCHed it. Needs image rebuild. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/modules/schedules/store.ts | 7 ++++-- apps/server/test/schedules/schedules.test.ts | 16 +++++++++++++ docs/runbook/schedules.md | 2 +- packages/contracts/test/db-sync.test.ts | 8 +++++-- .../0045_scheduled_job_config_customized.ts | 23 +++++++++++++++++++ packages/db/src/types.ts | 1 + 6 files changed, 52 insertions(+), 5 deletions(-) create mode 100644 packages/db/migrations/0045_scheduled_job_config_customized.ts diff --git a/apps/server/src/modules/schedules/store.ts b/apps/server/src/modules/schedules/store.ts index bf5e9356..5a603fdf 100644 --- a/apps/server/src/modules/schedules/store.ts +++ b/apps/server/src/modules/schedules/store.ts @@ -94,7 +94,8 @@ export function runView(r: ScheduledJobRunRow): ScheduledJobRun { /** * 把代码登记的定义同步进表:新行按默认值插入;已有行刷新名称/分组/默认值, - * 用户改过(customized)的 enabled/schedule 保留;config 以表内值为准、补上新增的默认键。 + * 用户改过(customized)的 enabled/schedule 保留;config 用户改过(config_customized)时以表内值为准、补上新增的默认键, + * 否则整体换成代码默认值(新增的巡检白名单能力等才能进旧行)。 * 未改过且默认调度变了的行,下次运行改为立即(随后按新周期)。 * 同步时代码里已不再登记的内置 task 行标为不可用(不会执行,看板显示"未登记")。 */ @@ -121,7 +122,8 @@ export async function syncDefinitions(db: Kysely, defs: readonly ScheduledJo 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, + config = case when scheduled_job.config_customized then excluded.config || scheduled_job.config + else excluded.config end, 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); @@ -221,6 +223,7 @@ export async function updateJob( } else { set.config = JSON.stringify({ ...((row.config ?? {}) as object), ...patch.config }); } + set.config_customized = true; } return await db.updateTable("scheduled_job").set(set).where("id", "=", id).returningAll().executeTakeFirstOrThrow(); } diff --git a/apps/server/test/schedules/schedules.test.ts b/apps/server/test/schedules/schedules.test.ts index c1e9136a..cad167fa 100644 --- a/apps/server/test/schedules/schedules.test.ts +++ b/apps/server/test/schedules/schedules.test.ts @@ -144,6 +144,22 @@ describe.skipIf(!hasPg)("定时任务注册表(真实 PG)", () => { expect(new Date(cron.body.data.nextRunAt).getHours()).toBe(4); }); + it("config:没改过的行随代码默认值整体更新;PATCH 过的保留表内值", async () => { + const def = (config: Record) => + counting("test.cfg", { + defaults: { name: "c", group: "download", schedule: { type: "interval", everyMs: 60_000 }, config }, + }); + registerScheduledJob(ctx, def({ list: ["a"] })); + await sched.tick(); + registerScheduledJob(ctx, def({ list: ["a", "b"] })); + await sched.tick(); + expect((await job("test.cfg")).config).toEqual({ list: ["a", "b"] }); + expect((await api("PATCH", "/schedules/test.cfg", { config: { list: ["x"] } })).status).toBe(200); + registerScheduledJob(ctx, def({ list: ["a", "b", "c"], extra: 1 })); + await sched.tick(); + expect((await job("test.cfg")).config).toEqual({ list: ["x"], extra: 1 }); + }); + it("立即执行幂等(合并重复请求)且不改下次运行时间", async () => { registerScheduledJob( ctx, diff --git a/docs/runbook/schedules.md b/docs/runbook/schedules.md index 73f1c872..7e87f930 100644 --- a/docs/runbook/schedules.md +++ b/docs/runbook/schedules.md @@ -53,7 +53,7 @@ ## 登记与用户修改 -- 首次登记时按默认值插入。之后再登记只刷新名称、说明、分组和默认值。`config` 以表里的值为准,只补上新增的默认键。 +- 首次登记时按默认值插入。之后再登记只刷新名称、说明、分组和默认值。`config` 用户改过(PATCH `config`,标 `config_customized`)时以表里的值为准、只补上新增的默认键;没改过时整体换成代码默认值(比如内置巡检白名单新增的能力)。 - 用户改过开关或调度后,`customized` 会被置为 true,之后再登记也不覆盖这两项。执行 `PATCH {reset:true}` 可以恢复默认。 - 如果某个内置 task 在代码里不再登记,它会被标记为 `available=false`,不再执行。 - 通过 API 设置间隔时,下限是 10 秒。内置默认值可以更短,比如测试时。 diff --git a/packages/contracts/test/db-sync.test.ts b/packages/contracts/test/db-sync.test.ts index 08771aab..02fc7d18 100644 --- a/packages/contracts/test/db-sync.test.ts +++ b/packages/contracts/test/db-sync.test.ts @@ -108,8 +108,12 @@ const ROWS: Record SlotDeferral: { row: C.SlotDeferral }, SlotUnit: { row: C.SlotUnit }, SubscriptionHistory: { row: C.SubscriptionHistory }, - // W7-C 定时任务(0028):board_group 在 API 里叫 group;default_enabled 只用于"恢复默认" - ScheduledJob: { row: C.ScheduledJob, dbOnly: ["boardGroup", "defaultEnabled"], apiOnly: ["group"] }, + // W7-C 定时任务(0028):board_group 在 API 里叫 group;default_enabled 只用于"恢复默认";config_customized(0045)只供启动同步 + ScheduledJob: { + row: C.ScheduledJob, + dbOnly: ["boardGroup", "defaultEnabled", "configCustomized"], + apiOnly: ["group"], + }, ScheduledJobRun: { row: C.ScheduledJobRun }, // W7-A Pi 上游(0027):secret_ref 不出 API // 0044 远程模型表:经 GET /pi/upstreams/{id}/models 单独返回,不进上游行 diff --git a/packages/db/migrations/0045_scheduled_job_config_customized.ts b/packages/db/migrations/0045_scheduled_job_config_customized.ts new file mode 100644 index 00000000..c403349d --- /dev/null +++ b/packages/db/migrations/0045_scheduled_job_config_customized.ts @@ -0,0 +1,23 @@ +import type { Kysely } from "kysely"; +import { execDdl } from "../src/migration-helpers.ts"; + +// 内置定时任务的 config 只在用户改过(PATCH config)时保留表内值;没改过的行在启动同步时整体换成代码默认值, +// 否则代码新增的默认项(如巡检白名单新增的 decision.list / subscription.list)永远进不了旧行。 +// 已有行:内置巡检的白名单与 0.2.2 默认值不同的视为用户改过。 + +// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型 +export async function up(db: Kysely): Promise { + await execDdl( + db, + `alter table scheduled_job add column config_customized boolean not null default false; + update scheduled_job set config_customized = true + where not builtin + or (kind = 'agent_check' and config->'capabilityAllowlist' is distinct from + '["catalog.lookup","release.search","release.inspect","inventory.query","task.inspect","identity.resolve"]'::jsonb);`, + ); +} + +// biome-ignore lint/suspicious/noExplicitAny: 同上 +export async function down(db: Kysely): Promise { + await execDdl(db, `alter table scheduled_job drop column if exists config_customized;`); +} diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 7b7b42ff..157d680c 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -1265,6 +1265,7 @@ export interface ScheduledJobTable { schedule: Json; default_schedule: Json; customized: Generated; + config_customized: Generated; config: Generated; last_run_at: Timestamptz | null; last_status: ScheduledJobLastStatus | null;