diff --git a/crates/host-core/src/rpc/scheduled_rpc.rs b/crates/host-core/src/rpc/scheduled_rpc.rs index 701dba6c97..3b47a7fef3 100644 --- a/crates/host-core/src/rpc/scheduled_rpc.rs +++ b/crates/host-core/src/rpc/scheduled_rpc.rs @@ -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() { @@ -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) @@ -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(); diff --git a/crates/host-core/src/scheduled.rs b/crates/host-core/src/scheduled.rs index 45a57665ce..2b83a446b1 100644 --- a/crates/host-core/src/scheduled.rs +++ b/crates/host-core/src/scheduled.rs @@ -31,6 +31,9 @@ pub struct ScheduledTask { pub next_run_at: Option, #[serde(skip_serializing_if = "Option::is_none")] pub workspace_path: Option, + /// Presence distinguishes a saved project (including null) from legacy tasks. + #[serde(skip)] + pub(crate) workspace_bound: bool, } #[derive(Debug, Clone, Serialize)] @@ -169,6 +172,7 @@ fn task_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { .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) diff --git a/docs/spec/04-ux/01-ui-ia.md b/docs/spec/04-ux/01-ui-ia.md index 813d808d31..2dca120dbf 100644 --- a/docs/spec/04-ux/01-ui-ia.md +++ b/docs/spec/04-ux/01-ui-ia.md @@ -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, diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 996981fdb9..19f5202ce1 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -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 | @@ -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. diff --git a/docs/zh-CN/spec/04-ux/01-ui-ia.md b/docs/zh-CN/spec/04-ux/01-ui-ia.md index 304fd1fad1..87e5e4c2ac 100644 --- a/docs/zh-CN/spec/04-ux/01-ui-ia.md +++ b/docs/zh-CN/spec/04-ux/01-ui-ia.md @@ -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` 排队工作;它无法显示或 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 9a68c4ab5b..babd402d59 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 @@ -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 | @@ -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. diff --git a/scripts/e2e-scheduled-workspace.mjs b/scripts/e2e-scheduled-workspace.mjs new file mode 100644 index 0000000000..7604150cf7 --- /dev/null +++ b/scripts/e2e-scheduled-workspace.mjs @@ -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(); +}