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
88 changes: 86 additions & 2 deletions crates/host-core/src/rpc/scheduled_rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ pub(super) fn handle_in_workspace(
.map_err(|e| rpc_err(1000, e.to_string(), "INTERNAL"))?;
if existing
.as_ref()
.is_some_and(|task| task.schedule.is_none())
.is_some_and(|task| !task.workspace_bound && task.schedule.is_none())
{
params["workspacePath"] = json!(workspace);
} else if let Some(object) = params.as_object_mut() {
Expand Down Expand Up @@ -138,7 +138,7 @@ pub(super) fn handle_in_workspace(
.get("defaultModelId")
.and_then(|v| v.as_str())
.map(str::to_string),
project_path: if task.schedule.is_some() {
project_path: if task.workspace_bound || task.schedule.is_some() {
task.workspace_path.clone()
} else {
st.workspace.get().map(|w| w.path)
Expand Down Expand Up @@ -233,6 +233,90 @@ fn validate_schedule_input(params: &Value) -> Result<(), JsonRpcError> {
mod tests {
use super::*;

#[test]
fn manual_task_keeps_saved_workspace_across_run_edit_and_restart() {
for project_bound in [true, false] {
let dir = tempfile::tempdir().unwrap();
let project_a = tempfile::tempdir().unwrap();
let project_b = tempfile::tempdir().unwrap();
let saved_path = project_bound.then(|| {
project_a
.path()
.canonicalize()
.unwrap()
.to_string_lossy()
.into_owned()
});
let state = AppState::open(dir.path()).unwrap();
let task = handle_in_workspace(
&state,
"scheduled.create",
json!({
"title":"A task", "prompt":"Reply OK", "cadence":"manual", "schedule":null
}),
saved_path.clone(),
)
.unwrap()["task"]
.clone();
let id = task["id"].as_str().unwrap();
drop(state);
let mut state = AppState::open(dir.path()).unwrap();
state.workspace.set(project_b.path());
let run = handle(&state, "scheduled.run", json!({"id":id})).unwrap();
let session = sessions::get_session(&state.db, run["sessionId"].as_str().unwrap())
.unwrap()
.unwrap();
assert_eq!(session.summary.project_path, saved_path);
handle(
&state,
"scheduled.finishRun",
json!({"runId":run["runId"],"status":"completed"}),
)
.unwrap();
let edited = handle(
&state,
"scheduled.update",
json!({
"id":id, "title":"Renamed", "cadence":"manual", "schedule":null
}),
)
.unwrap();
assert_eq!(edited["task"]["workspacePath"], json!(saved_path));
let recurring = handle(
&state,
"scheduled.update",
json!({
"id":id, "cadence":"hourly", "schedule":{"hour":9,"minute":0,"weekday":0}
}),
)
.unwrap();
assert_eq!(recurring["task"]["workspacePath"], json!(saved_path));
}
}

#[test]
fn legacy_task_uses_current_workspace_until_explicitly_configured() {
let dir = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
let mut state = AppState::open(dir.path()).unwrap();
let task = handle(
&state,
"scheduled.create",
json!({"prompt":"Reply OK","cadence":"manual"}),
)
.unwrap()["task"]
.clone();
let id = task["id"].as_str().unwrap();
let path = state.workspace.set(project.path()).path;
let run = handle(&state, "scheduled.run", json!({"id":id})).unwrap();
let session = sessions::get_session(&state.db, run["sessionId"].as_str().unwrap())
.unwrap()
.unwrap();
assert_eq!(session.summary.project_path, Some(path.clone()));
let edited = handle(&state, "scheduled.update", json!({"id":id,"schedule":null})).unwrap();
assert_eq!(edited["task"]["workspacePath"], path);
}

#[test]
fn selected_weekdays_round_trip_and_invalid_edits_preserve_saved_schedule() {
let dir = tempfile::tempdir().unwrap();
Expand Down
4 changes: 4 additions & 0 deletions crates/host-core/src/scheduled.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ pub struct ScheduledTask {
pub next_run_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace_path: Option<String>,
/// Presence distinguishes a saved project (including null) from legacy tasks.
#[serde(skip)]
pub(crate) workspace_bound: bool,
}

#[derive(Debug, Clone, Serialize)]
Expand Down Expand Up @@ -169,6 +172,7 @@ fn task_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ScheduledTask> {
.get("nextRunAt")
.and_then(Value::as_i64)
.map(ms_to_ts),
workspace_bound: config.get("workspacePath").is_some(),
workspace_path: config
.get("workspacePath")
.and_then(Value::as_str)
Expand Down
5 changes: 4 additions & 1 deletion docs/spec/04-ux/01-ui-ia.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,10 @@ rearms future occurrences only. Hourly schedules wait a full hour after saving,
enabling, startup or the preceding automatic admission; Run now leaves the
automatic occurrence unchanged. Legacy cadence-only tasks require explicit
schedule configuration. The current project is captured when first configured;
subsequent foreground project changes do not retarget it. Automatic runs use
subsequent foreground project changes do not retarget it. This includes Manual
tasks and tasks saved without a project: Run now, renaming, and cadence changes
preserve that binding, including after restart. Only legacy tasks without a saved
binding capture the current project on their first explicit configuration. Automatic runs use
Ask permissions and may wait for input in their conversation.
New tasks default to Agent. A migrated Plan or Goal task is allowed to remain
stored, but an unattended run is explicitly rejected before provider, artifact,
Expand Down
18 changes: 18 additions & 0 deletions docs/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -7929,6 +7929,7 @@ identify the platform validation still needed.

| Acceptance | Scenarios |
|---|---|
| C / F / Quality — Saved project isolation | E2E-SCHEDULED-manual-workspace-binding |
| C / F / Quality — Desktop automations | E2E-SCHEDULED-desktop-automation-lifecycle |
| A / C — Unicode stdio framing | E2E-RPC-unicode-separators |
| C / G / Quality — Plugins navigation | E2E-NAV-plugins-button-goes-back |
Expand Down Expand Up @@ -14286,3 +14287,20 @@ the latest destination. These assertions measure work counts, not device FPS.
- **Expected**: The complete URL, including `(software)`, opens the React software article. The outer prose closing parenthesis and sentence-ending punctuation after the URL parenthesis are not part of the link. Adjacent references remain independently clickable. Nested parentheses, query/fragment parentheses and percent-encoded parentheses remain intact.
- **Coverage**: `chat-links.test.mjs`; native desktop click-through with the normal browser destination.
- **Specs linked**: `04-ux/08-component-spec.md` §8.3.

### E2E-SCHEDULED-manual-workspace-binding

- **Preconditions:** Built request candidate; isolated host data and two project
directories. No provider credentials or paid API.
- **Steps:** Create Manual and Hourly tasks in project A and without a project;
restart the host, switch to B, Run now, rename through the editor payload,
and Run now again.
- **Expected:** Each result session and edited task retain the original binding,
including no-project tasks. Legacy cadence-only tasks keep their previous
fallback until explicitly configured (covered by host RPC tests).
- **Specs:** 04-ux/01-ui-ia §3.4.
- **Acceptance:** Saved workspace binding across run, edit and restart.
- **Milestone:** Maintenance.
- **Status:** Automated by `node --experimental-strip-types
scripts/e2e-scheduled-workspace.mjs`, using production Electron dispatch and
real Rust/stdio/SQLite. Only external inference is replaced with an observer.
6 changes: 5 additions & 1 deletion docs/zh-CN/spec/04-ux/01-ui-ia.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,11 @@

应用必须保持运行。每 30 秒检查一次,超过 90 秒的错过时段与重叠运行被跳过;
重启只安排未来时段。旧任务需明确保存周期后才自动执行。首次设置时保存当前项目,
前台项目切换不会改变绑定。自动执行使用 Ask 权限模式,不自动授权或抢占当前页面。
前台项目切换不会改变绑定。
Manual tasks, including tasks saved without a project, retain that binding across
Run now, renaming, cadence changes, and restart. Only legacy tasks without a
saved binding capture the current project on their first explicit configuration.
自动执行使用 Ask 权限模式,不自动授权或抢占当前页面。
新任务默认为 Agent。允许保留已迁移的 Plan 或 Goal 任务
已存储,但在提供商、工件之前明确拒绝无人值守的运行,
或使用 `PLAN_REQUIRES_INTERACTIVE_SESSION` 排队工作;它无法显示或
Expand Down
18 changes: 18 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 @@ -5184,6 +5184,7 @@ eleven-tool-round desktop paths are verified by

| 验收 | 应用场景 |
|---|---|
| C / F / Quality — Saved project isolation | E2E-SCHEDULED-manual-workspace-binding |
| C / F / Quality — 桌面定时任务 | E2E-SCHEDULED-desktop-automation-lifecycle |
| A / C — Unicode stdio 成帧 | E2E-RPC-unicode-separators |
| C / G / Quality — Plugins navigation | E2E-NAV-plugins-button-goes-back |
Expand Down Expand Up @@ -8457,3 +8458,20 @@ the latest destination. These assertions measure work counts, not device FPS.
- **预期**:打开包含 `(software)` 的完整网址,进入 React 软件词条;正文外层的右括号和紧跟 URL 右括号的句末标点不属于链接,相邻引用仍能独立点击。嵌套圆括号、查询和片段内的圆括号、百分号编码的圆括号均保持完整。
- **覆盖**:`chat-links.test.mjs`;桌面端通过正常浏览器目标实际点击验证。
- **链接规格**:`04-ux/08-component-spec.md` §8.3。

### E2E-SCHEDULED-manual-workspace-binding

- **Preconditions:** Built request candidate; isolated host data and two project
directories. No provider credentials or paid API.
- **Steps:** Create Manual and Hourly tasks in project A and without a project;
restart the host, switch to B, Run now, rename through the editor payload,
and Run now again.
- **Expected:** Each result session and edited task retain the original binding,
including no-project tasks. Legacy cadence-only tasks keep their previous
fallback until explicitly configured (covered by host RPC tests).
- **Specs:** 04-ux/01-ui-ia §3.4.
- **Acceptance:** Saved workspace binding across run, edit and restart.
- **Milestone:** Maintenance.
- **Status:** Automated by `node --experimental-strip-types
scripts/e2e-scheduled-workspace.mjs`, using production Electron dispatch and
real Rust/stdio/SQLite. Only external inference is replaced with an observer.
46 changes: 46 additions & 0 deletions scripts/e2e-scheduled-workspace.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env node
// Real Electron dispatch service, Rust host, stdio and SQLite; inference is observed only.
import assert from "node:assert/strict";
import { mkdir, mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Host, resolveHostBinary } from "./e2e/host.mjs";
import { executeScheduledTask } from "../apps/desktop/electron/main/runtime/scheduled-runner.ts";

const root = await mkdtemp(join(tmpdir(), "pi-scheduled-workspace-"));
const projectA = join(root, "a");
const projectB = join(root, "b");
await Promise.all([mkdir(projectA), mkdir(projectB)]);
const host = new Host(resolveHostBinary(), join(root, "data"));
async function dispatch(task, expected) {
const launch = await executeScheduledTask({
host, id: task.id, automatic: false, runs: new Map(), isCurrent: () => true,
prompt: async (sessionId, content) => {
assert.equal(content, "Reply OK");
const { session } = await host.call("session.get", { id: sessionId });
assert.equal(session.projectPath ?? null, expected ?? null);
},
});
await host.call("scheduled.finishRun", { runId: launch.runId, status: "completed" });
}
try {
await host.start();
for (const cadence of ["manual", "hourly"]) {
for (const bound of [true, false]) {
await host.call(bound ? "workspace.set" : "workspace.clear", bound ? { path: projectA } : {});
const input = { title: "Workspace regression", prompt: "Reply OK", cadence,
schedule: cadence === "manual" ? null : { hour: 9, minute: 0, weekday: 0 } };
const { task } = await host.call("scheduled.create", input);
await host.stop();
await host.start();
await host.call("workspace.set", { path: projectB });
await dispatch(task, task.workspacePath);
const { task: renamed } = await host.call("scheduled.update", { ...input, id: task.id, title: "Renamed" });
assert.equal(renamed.workspacePath, task.workspacePath);
await dispatch(renamed, task.workspacePath);
console.log(`PASS ${cadence}: ${bound ? "project" : "temporary"} binding survives restart, run and edit`);
}
}
} finally {
await host.stop();
}
Loading