Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions crates/host-core/src/rpc/scheduled_calendar_tests.rs
Original file line number Diff line number Diff line change
@@ -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, &params)).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);
}
}
}
15 changes: 15 additions & 0 deletions crates/host-core/src/rpc/scheduled_rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,21 @@ pub(super) fn handle_in_workspace(
"scheduled.update" => {
let mut params = params;
validate_schedule_input(&params)?;
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)
Expand Down
4 changes: 4 additions & 0 deletions crates/host-core/src/rpc/scheduled_tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 15 additions & 1 deletion crates/host-core/src/scheduled.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -155,11 +158,16 @@ fn updated_config_json(db: &Database, id: &str, params_json: &Value) -> Result<O

fn task_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ScheduledTask> {
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)?),
Expand All @@ -173,6 +181,7 @@ fn task_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ScheduledTask> {
.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)
Expand Down Expand Up @@ -260,6 +269,11 @@ pub fn update_task(db: &Database, params_json: &Value) -> Result<Option<Schedule
};
let mut config = config_json_value(&config_json);
let existing = get_task(db, id)?;
if config.get("calendarConfigured").is_none() {
config["calendarConfigured"] = json!(existing
.as_ref()
.is_some_and(|task| task.calendar_configured));
}
let effective_cadence = cadence
.as_deref()
.or_else(|| existing.as_ref().map(|task| task.cadence.as_str()))
Expand Down
7 changes: 7 additions & 0 deletions crates/host-core/src/scheduled/automation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,19 @@ use crate::db::{now_ms, Database};

pub fn configure(config: &mut Value, input: &Value, cadence: &str, now: i64) -> 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") {
Expand Down
11 changes: 11 additions & 0 deletions docs/adr/scheduled-desktop-automations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions docs/spec/03-runtime/04-data-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 3 additions & 0 deletions docs/spec/03-runtime/06-host-rpc-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -1212,6 +1212,9 @@ 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.
### Scheduled tasks: workspace identity

Stored workspace bindings use the existing project canonicalization contract on both write and read. On Windows, slash direction, case, trailing separators and extended path prefixes do not hide a task from its own project's conversation. The distinction between missing legacy bindings and explicit null remains unchanged. Foreign-project tools cannot list or mutate bound tasks.
5 changes: 5 additions & 0 deletions docs/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -14454,6 +14454,11 @@ the latest destination. These assertions measure work counts, not device FPS.
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.
### E2E-SCHEDULED-paths

- **Scenario:** Workspace identity.
Expand Down
8 changes: 8 additions & 0 deletions docs/zh-CN/spec/03-runtime/04-data-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
版本迁移;旧版本会忽略它,也无法执行新的转换保护。
8 changes: 8 additions & 0 deletions docs/zh-CN/spec/03-runtime/06-host-rpc-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -987,6 +987,14 @@ Host 重新检查会话的持久化模式,按调用会话的项目限制访问
每天/每周自动任务需 schedule。修改使用已存在的 ID 并保留未指定字段。界面虽只提供四个
时段,工具仍支持具体本地时间。不新增数据库 schema 或传输协议。

### 定时任务:日历配置意图

可选的 `config_json.calendarConfigured` 布尔值用于区分用户明确设置的每日/每周
日历时间与 Hourly 的内部占位 schedule。缺少该字段时,旧版 Daily/Weekly
任务视为已设置日历;旧版 Hourly 保留现有值,但转换为 Daily/Weekly 时必须明确
提供 schedule。已确认的日历配置在切换为 Hourly 和重启后仍会保留,包括午夜。
清空日历或改为不同的非日历占位值会清除该意图。该扩展不修改表结构;旧版本会
忽略该字段,无法执行新的转换保护。仅修改元数据以及 Manual 转 Hourly 的行为不变。
### 定时任务:工作区身份

保存和读取工作区绑定时统一使用现有项目路径规范化规则。在 Windows 上,
Expand Down
8 changes: 8 additions & 0 deletions docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -8595,6 +8595,14 @@ the latest destination. These assertions measure work counts, not device FPS.
- **验收 / 里程碑**: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`
### E2E-SCHEDULED-paths

- **场景**:工作区身份。
Expand Down
26 changes: 26 additions & 0 deletions scripts/e2e-scheduled-calendar-intent.mjs
Original file line number Diff line number Diff line change
@@ -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();}
Loading