From bc5cbde2a747375f691e1af7c4eb01f574196cf1 Mon Sep 17 00:00:00 2001 From: hui455 <2075649045@qq.com> Date: Tue, 22 Sep 2026 00:59:00 +0800 Subject: [PATCH] fix(scheduled): preserve explicit calendar intent across cadence changes Distinguish an hourly compatibility schedule from an intentional calendar time. Preserve known calendar settings through cadence changes and restart while requiring confirmation for legacy hourly values. --- .../src/rpc/scheduled_calendar_tests.rs | 104 ++++++++++++++++++ crates/host-core/src/rpc/scheduled_rpc.rs | 15 +++ crates/host-core/src/rpc/scheduled_tools.rs | 4 + crates/host-core/src/scheduled.rs | 16 ++- crates/host-core/src/scheduled/automation.rs | 7 ++ docs/adr/scheduled-desktop-automations.md | 11 ++ docs/spec/03-runtime/04-data-storage.md | 10 ++ docs/spec/03-runtime/06-host-rpc-protocol.md | 4 + docs/spec/06-delivery/04-e2e-test-plan.md | 6 + docs/zh-CN/spec/03-runtime/04-data-storage.md | 8 ++ .../spec/03-runtime/06-host-rpc-protocol.md | 9 ++ .../spec/06-delivery/04-e2e-test-plan.md | 10 ++ scripts/e2e-scheduled-calendar-intent.mjs | 26 +++++ 13 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 crates/host-core/src/rpc/scheduled_calendar_tests.rs create mode 100644 scripts/e2e-scheduled-calendar-intent.mjs diff --git a/crates/host-core/src/rpc/scheduled_calendar_tests.rs b/crates/host-core/src/rpc/scheduled_calendar_tests.rs new file mode 100644 index 0000000000..1a3b1da8af --- /dev/null +++ b/crates/host-core/src/rpc/scheduled_calendar_tests.rs @@ -0,0 +1,104 @@ +use super::*; + +fn invoke(st: &AppState, session: &str, name: &str, args: Value) -> Value { + let params = serde_json::from_value(json!({"sessionId":session,"toolCallId":"calendar-test","toolName":name,"args":args,"mode":"agent"})).unwrap(); + serde_json::to_value(execute(st, ¶ms)).unwrap() +} + +fn session(st: &AppState) -> String { + sessions::create_session(&st.db, None, Some("agent".into()), None, None, None) + .unwrap() + .id +} + +#[test] +fn hourly_calendar_conversion_requires_calendar_intent_after_restart() { + for target in ["daily", "weekly"] { + for creator in ["tool", "ui", "legacy"] { + let dir = tempfile::tempdir().unwrap(); + let st = AppState::open(dir.path()).unwrap(); + let sid = session(&st); + let created = match creator { + "tool" => invoke(&st, &sid, "ScheduledTaskCreate", json!({"title":"Review","prompt":"Reply OK","cadence":"hourly","enabled":false}))["content"].clone(), + "ui" => scheduled_rpc::handle(&st,"scheduled.create",json!({"title":"Review","prompt":"Reply OK","cadence":"hourly","enabled":false,"schedule":{"hour":0,"minute":0,"weekday":0}})).unwrap(), + _ => { + scheduled::import_tasks(&st.db, &[json!({"id":"legacy","title":"Review","prompt":"Reply OK","cadence":"hourly","enabled":false,"configJson":{"schedule":{"hour":0,"minute":0,"weekday":0}}})]).unwrap(); + json!({"task":scheduled::get_task(&st.db,"legacy").unwrap().unwrap()}) + } + }; + let id = created["task"]["id"].as_str().unwrap().to_string(); + drop(st); + let st = AppState::open(dir.path()).unwrap(); + let before = + serde_json::to_value(scheduled::get_task(&st.db, &id).unwrap().unwrap()).unwrap(); + let rejected = invoke( + &st, + &sid, + "ScheduledTaskUpdate", + json!({"id":id,"cadence":target}), + ); + assert_eq!( + rejected["errorCode"], "INVALID_PARAMS", + "{creator} -> {target}: {rejected}" + ); + assert_eq!( + serde_json::to_value(scheduled::get_task(&st.db, &id).unwrap().unwrap()).unwrap(), + before + ); + let schedule = json!({"hour":0,"minute":0,"weekday":2,"weekdays":[2,4]}); + let accepted = invoke( + &st, + &sid, + "ScheduledTaskUpdate", + json!({"id":id,"cadence":target,"schedule":schedule}), + ); + assert_eq!(accepted["ok"], true, "{accepted}"); + assert_eq!(accepted["content"]["task"]["enabled"], false); + assert_eq!(accepted["content"]["task"]["schedule"], schedule); + } + } +} + +#[test] +fn saved_calendar_survives_hourly_and_restart_including_midnight() { + for hour in [0, 15] { + for legacy in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let st = AppState::open(dir.path()).unwrap(); + let sid = session(&st); + let schedule = json!({"hour":hour,"minute":0,"weekday":0,"weekdays":[0,2]}); + let created = if legacy { + scheduled::import_tasks(&st.db,&[json!({"id":"old","prompt":"Review","cadence":"weekly","enabled":false,"configJson":{"schedule":schedule}})]).unwrap(); + json!({"content":{"task":scheduled::get_task(&st.db,"old").unwrap().unwrap()}}) + } else { + invoke( + &st, + &sid, + "ScheduledTaskCreate", + json!({"title":"Review","prompt":"Review","cadence":"weekly","enabled":false,"schedule":schedule}), + ) + }; + let id = created["content"]["task"]["id"].as_str().unwrap(); + assert_eq!( + invoke( + &st, + &sid, + "ScheduledTaskUpdate", + json!({"id":id,"cadence":"hourly"}) + )["ok"], + true + ); + drop(st); + let st = AppState::open(dir.path()).unwrap(); + let restored = invoke( + &st, + &sid, + "ScheduledTaskUpdate", + json!({"id":id,"cadence":"weekly"}), + ); + assert_eq!(restored["ok"], true, "{restored}"); + assert_eq!(restored["content"]["task"]["schedule"], schedule); + assert_eq!(restored["content"]["task"]["enabled"], false); + } + } +} diff --git a/crates/host-core/src/rpc/scheduled_rpc.rs b/crates/host-core/src/rpc/scheduled_rpc.rs index 3b47a7fef3..437773a892 100644 --- a/crates/host-core/src/rpc/scheduled_rpc.rs +++ b/crates/host-core/src/rpc/scheduled_rpc.rs @@ -35,6 +35,21 @@ pub(super) fn handle_in_workspace( "scheduled.update" => { let mut params = params; validate_schedule_input(¶ms)?; + if matches!( + params.get("cadence").and_then(Value::as_str), + Some("daily" | "weekly") + ) && params.get("schedule").is_none() + { + let id = params.get("id").and_then(Value::as_str).unwrap_or(""); + let existing = scheduled::get_task(&st.db, id) + .map_err(|e| rpc_err(1000, e.to_string(), "INTERNAL"))?; + if existing.is_some_and(|task| task.schedule.is_some() && !task.calendar_configured) + { + return Err(rpc_err(1002, + "Confirm a calendar time and provide schedule when changing this task to Daily or Weekly", + "INVALID_PARAMS")); + } + } if params.get("schedule").is_some() { let id = params.get("id").and_then(Value::as_str).unwrap_or(""); let existing = scheduled::get_task(&st.db, id) diff --git a/crates/host-core/src/rpc/scheduled_tools.rs b/crates/host-core/src/rpc/scheduled_tools.rs index 73f398ecaa..cf18d8ca23 100644 --- a/crates/host-core/src/rpc/scheduled_tools.rs +++ b/crates/host-core/src/rpc/scheduled_tools.rs @@ -5,6 +5,10 @@ use crate::{ }; use serde_json::{json, Value}; +#[cfg(test)] +#[path = "scheduled_calendar_tests.rs"] +mod calendar_tests; + pub fn recognizes(name: &str) -> bool { matches!( name, diff --git a/crates/host-core/src/scheduled.rs b/crates/host-core/src/scheduled.rs index 2b83a446b1..4fa445f664 100644 --- a/crates/host-core/src/scheduled.rs +++ b/crates/host-core/src/scheduled.rs @@ -34,6 +34,9 @@ pub struct ScheduledTask { /// Presence distinguishes a saved project (including null) from legacy tasks. #[serde(skip)] pub(crate) workspace_bound: bool, + /// Calendar intent is independent from Hourly's compatibility schedule. + #[serde(skip)] + pub(crate) calendar_configured: bool, } #[derive(Debug, Clone, Serialize)] @@ -155,11 +158,16 @@ fn updated_config_json(db: &Database, id: &str, params_json: &Value) -> Result) -> rusqlite::Result { let config = config_json_value(&row.get::<_, String>(4)?); + let cadence: String = row.get(3)?; + let calendar_configured = config + .get("calendarConfigured") + .and_then(Value::as_bool) + .unwrap_or(matches!(cadence.as_str(), "daily" | "weekly")); Ok(ScheduledTask { id: row.get(0)?, title: row.get(1)?, prompt: row.get(2)?, - cadence: row.get(3)?, + cadence, mode: mode_from_config(&config_json_value(&row.get::<_, String>(4)?)), enabled: row.get::<_, i64>(5)? != 0, created_at: ms_to_ts(row.get(6)?), @@ -173,6 +181,7 @@ fn task_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { .and_then(Value::as_i64) .map(ms_to_ts), workspace_bound: config.get("workspacePath").is_some(), + calendar_configured, workspace_path: config .get("workspacePath") .and_then(Value::as_str) @@ -260,6 +269,11 @@ pub fn update_task(db: &Database, params_json: &Value) -> Result Result<()> { if let Some(schedule) = input.get("schedule") { + let previous = config.get("schedule").cloned(); if schedule.is_null() { config["schedule"] = Value::Null; + config["calendarConfigured"] = json!(false); } else { let schedule: Schedule = serde_json::from_value(schedule.clone())?; schedule.validate()?; config["schedule"] = serde_json::to_value(schedule)?; + if matches!(cadence, "daily" | "weekly") { + config["calendarConfigured"] = json!(true); + } else if previous.as_ref() != config.get("schedule") { + config["calendarConfigured"] = json!(false); + } } } if let Some(workspace) = input.get("workspacePath") { diff --git a/docs/adr/scheduled-desktop-automations.md b/docs/adr/scheduled-desktop-automations.md index 574dc951e6..da43bbd451 100644 --- a/docs/adr/scheduled-desktop-automations.md +++ b/docs/adr/scheduled-desktop-automations.md @@ -50,6 +50,17 @@ for a fixed interval while preserving calendar semantics for daily/weekly tasks. No cloud execution, OS service, arbitrary cron or sub-hourly interval is added. Downgrading keeps the existing records readable but removes automatic execution. +## Calendar provenance compatibility + +The optional `calendarConfigured` configuration key records explicit Daily or +Weekly calendar intent separately from Hourly's required internal placeholder. +Legacy Daily/Weekly rows infer intent from their saved cadence. Legacy Hourly +rows preserve saved fields but require an explicit schedule on calendar +conversion, because their origin cannot be recovered reliably. Known calendar +intent survives an Hourly round trip and restart. This uses the existing JSON +extension boundary with no physical schema migration or new wire field. +Downgrades retain readable task data but cannot enforce the conversion guard. + ## Alternatives A plugin would duplicate lifecycle/storage ownership and make baseline diff --git a/docs/spec/03-runtime/04-data-storage.md b/docs/spec/03-runtime/04-data-storage.md index bc25dfc1f6..f643b53aec 100644 --- a/docs/spec/03-runtime/04-data-storage.md +++ b/docs/spec/03-runtime/04-data-storage.md @@ -1564,3 +1564,13 @@ and bounded asynchronous scanning remain deferred performance work. Host-core owns updates through `providers.reorder`; missing metadata preserves creation order, new IDs follow saved IDs, and deleted IDs are ignored. This preference does not rewrite provider configuration or require a schema migration. + +### Scheduled calendar provenance + +The optional `config_json.calendarConfigured` boolean records explicit calendar +intent separately from the schedule object required by Hourly intervals. +Legacy Daily/Weekly rows with a saved schedule infer calendar intent; legacy +Hourly rows retain their fields but require explicit calendar confirmation +when converted. Known intent survives cadence changes and database reopen. +This additive JSON key needs no table or schema-version migration. Older +versions ignore the key and cannot enforce the new conversion guard. diff --git a/docs/spec/03-runtime/06-host-rpc-protocol.md b/docs/spec/03-runtime/06-host-rpc-protocol.md index 7be3e8e3a4..cf621b88af 100644 --- a/docs/spec/03-runtime/06-host-rpc-protocol.md +++ b/docs/spec/03-runtime/06-host-rpc-protocol.md @@ -1211,3 +1211,7 @@ Create requires title, prompt and cadence; automatic daily/weekly tasks require a schedule. Update takes an existing ID and partial fields, preserving all unspecified configuration. Exact local times remain supported despite the UI's four period presets. No new DB schema or transport is introduced. + +### Scheduled tasks: calendar intent + +The optional config_json.calendarConfigured boolean distinguishes an explicitly configured Daily/Weekly calendar from Hourly's internal schedule placeholder. Without the key, legacy Daily/Weekly schedules are treated as configured; legacy Hourly schedules retain their values but require an explicit schedule when converting to Daily/Weekly. Known calendar intent survives Hourly and restart, including midnight. Clearing or replacing the calendar with a different non-calendar placeholder clears intent. This additive extension needs no table/schema migration; older versions ignore it and cannot enforce the new conversion guard. Metadata-only edits and Manual-to-Hourly remain unchanged. diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index e6fbc95131..801da1fd5b 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -14402,3 +14402,9 @@ the latest destination. These assertions measure work counts, not device FPS. `fork_preserves_referenced_pasted_files_independently` and `sessions::fork_files` cover ownership, deletion, repeated/bounded forks, retained checkpoint paths, expired inputs, rollback, and symlink rejection. + +### E2E-SCHEDULED-calendar-intent + +- **Scenario:** Calendar intent. +- **Expected:** The optional config_json.calendarConfigured boolean distinguishes an explicitly configured Daily/Weekly calendar from Hourly's internal schedule placeholder. Without the key, legacy Daily/Weekly schedules are treated as configured; legacy Hourly schedules retain their values but require an explicit schedule when converting to Daily/Weekly. Known calendar intent survives Hourly and restart, including midnight. Clearing or replacing the calendar with a different non-calendar placeholder clears intent. This additive extension needs no table/schema migration; older versions ignore it and cannot enforce the new conversion guard. Metadata-only edits and Manual-to-Hourly remain unchanged. +- **Automation:** `node --experimental-strip-types scripts/e2e-scheduled-calendar-intent.mjs` uses an isolated real Host and SQLite profile. Inference is not sent to a live provider. diff --git a/docs/zh-CN/spec/03-runtime/04-data-storage.md b/docs/zh-CN/spec/03-runtime/04-data-storage.md index 8c4f3e51c8..3e01d233cc 100644 --- a/docs/zh-CN/spec/03-runtime/04-data-storage.md +++ b/docs/zh-CN/spec/03-runtime/04-data-storage.md @@ -1323,3 +1323,11 @@ UI投影损失 Host-core owns updates through `providers.reorder`; missing metadata preserves creation order, new IDs follow saved IDs, and deleted IDs are ignored. This preference does not rewrite provider configuration or require a schema migration. + +### 定时任务日历配置来源 + +可选的 `config_json.calendarConfigured` 布尔值独立记录明确的日历配置意图, +不与 Hourly 间隔内部需要的 schedule 对象混用。旧版 Daily/Weekly 行只要保存了 +schedule 就推断为日历配置;旧版 Hourly 行保留字段,但转换时需要明确确认日历时间。 +已知意图在周期切换和数据库重开后仍然保留。该新增 JSON 字段不需要表或 schema +版本迁移;旧版本会忽略它,也无法执行新的转换保护。 diff --git a/docs/zh-CN/spec/03-runtime/06-host-rpc-protocol.md b/docs/zh-CN/spec/03-runtime/06-host-rpc-protocol.md index 9e2dc820d5..990457e84f 100644 --- a/docs/zh-CN/spec/03-runtime/06-host-rpc-protocol.md +++ b/docs/zh-CN/spec/03-runtime/06-host-rpc-protocol.md @@ -986,3 +986,12 @@ Host 重新检查会话的持久化模式,按调用会话的项目限制访问 提示词、非法时间和星期在写入前拒绝;不能删除运行中的任务。创建需 title、prompt、cadence; 每天/每周自动任务需 schedule。修改使用已存在的 ID 并保留未指定字段。界面虽只提供四个 时段,工具仍支持具体本地时间。不新增数据库 schema 或传输协议。 + +### 定时任务:日历配置意图 + +可选的 `config_json.calendarConfigured` 布尔值用于区分用户明确设置的每日/每周 +日历时间与 Hourly 的内部占位 schedule。缺少该字段时,旧版 Daily/Weekly +任务视为已设置日历;旧版 Hourly 保留现有值,但转换为 Daily/Weekly 时必须明确 +提供 schedule。已确认的日历配置在切换为 Hourly 和重启后仍会保留,包括午夜。 +清空日历或改为不同的非日历占位值会清除该意图。该扩展不修改表结构;旧版本会 +忽略该字段,无法执行新的转换保护。仅修改元数据以及 Manual 转 Hourly 的行为不变。 diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index 2f38aa1b06..f7ca9ad67f 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -8563,3 +8563,13 @@ the latest destination. These assertions measure work counts, not device FPS. - **规格**:`04-ux/08-component-spec.md`、`04-ux/09-interaction-patterns.md`。 - **验收 / 里程碑**:C、Quality / M6+。 - **状态**:组件与状态层用户路径由 `queue-pending-actions.test.mjs` 覆盖。 + +### E2E-SCHEDULED-calendar-intent + +- **场景**:日历配置意图。 +- **预期**:可选的 `config_json.calendarConfigured` 区分明确设置的每日/每周 + 日历时间与 Hourly 内部占位 schedule。旧版 Daily/Weekly 保留日历语义; + 旧版 Hourly 转换时必须明确提供 schedule。已知配置可跨 Hourly 与重启保留, + 包括午夜;仅修改元数据以及 Manual 转 Hourly 的行为不变。 +- **自动化**:`node --experimental-strip-types scripts/e2e-scheduled-calendar-intent.mjs` + 使用隔离的真实 Host 与 SQLite 配置,不向真实提供商发送推理请求。 diff --git a/scripts/e2e-scheduled-calendar-intent.mjs b/scripts/e2e-scheduled-calendar-intent.mjs new file mode 100644 index 0000000000..85f100c8f8 --- /dev/null +++ b/scripts/e2e-scheduled-calendar-intent.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { Host, resolveHostBinary } from "./e2e/host.mjs"; +const host=new Host(resolveHostBinary(),await mkdtemp(join(tmpdir(),"pi-scheduled-calendar-"))); +try { + await host.start();await host.call("settings.set",{defaultPermissionMode:"auto"}); + const {session}=await host.call("session.create",{mode:"agent"}); + const tool=(toolName,args)=>host.call("tools.execute",{sessionId:session.id,toolName,args,mode:"agent",toolCallId:randomUUID()}); + for(const cadence of ["daily","weekly"]) { + const created=await tool("ScheduledTaskCreate",{title:"Review",prompt:"Reply OK",cadence:"manual",enabled:false}); + const id=created.content.task.id; + assert.equal((await tool("ScheduledTaskUpdate",{id,cadence:"hourly"})).ok,true); + await host.restart(); + const before=(await host.call("scheduled.list")).tasks.find(task=>task.id===id); + assert.equal((await tool("ScheduledTaskUpdate",{id,cadence})).errorCode,"INVALID_PARAMS"); + assert.deepEqual((await host.call("scheduled.list")).tasks.find(task=>task.id===id),before); + const schedule={hour:0,minute:0,weekday:0,weekdays:[0,2]}; + const explicit=await tool("ScheduledTaskUpdate",{id,cadence,schedule});assert.equal(explicit.ok,true);assert.equal(explicit.content.task.enabled,false); + await tool("ScheduledTaskUpdate",{id,cadence:"hourly"});await host.restart(); + const restored=await tool("ScheduledTaskUpdate",{id,cadence});assert.equal(restored.ok,true);assert.deepEqual(restored.content.task.schedule,schedule); + console.log(`PASS ${cadence}: reject implicit placeholder, accept explicit midnight, preserve calendar through Hourly/restart`); + } +} finally {await host.stop();}