From 7007b2cc7e74eab4c18e662ae3283b0150423fe4 Mon Sep 17 00:00:00 2001 From: Blomios Date: Fri, 11 Sep 2026 10:32:01 +0200 Subject: [PATCH] =?UTF-8?q?feat(backend,frontend):=20t=C3=A2ches=20planifi?= =?UTF-8?q?=C3=A9es=20projet=20=E2=80=94=20tool=20MCP=20idea=5Fscheduled?= =?UTF-8?q?=5Ftask=5F*=20et=20menu=20utilisateur=20#305,=20fix=20transcrip?= =?UTF-8?q?t=20li=C3=A9=20indisponible=20apr=C3=A8s=20reboot=20#314=20(QA?= =?UTF-8?q?=20verte)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #305: nouvel agrégat durable ScheduledProjectTask dans domain (scheduled_task.rs, ids, ports ScheduledProjectTaskStore), source de vérité /scheduled-project-tasks/.json, schedules bornés V1 interval/daily/weekly avec fuseau, cycle À approuver/Active/Suspendue avec approbation obligatoire des tâches demandées par agent; use cases application/scheduled_task (list/create/update/delete/run_now/reconcile/fire) réutilisant BackgroundTask pour les runs avec distinction planifié/manuel; store infrastructure dédié + scheduler reconcile au boot sans rattrapage en rafale, cwd confiné au projet; 7 tools MCP idea_scheduled_task_* (list/read lecture seule, create/update/delete/run_now/set_enabled write-actions soumises aux permissions natives); commandes Tauri + routes HTTP aux contrats DTO identiques (camelCase nextRunAtMs/lastRunAtMs/lastTaskId/createdBy/updatedBy); frontend: gateways Tauri/HTTP/mock + types ports/domain, surface projet 'Tâches planifiées' (liste utilisateur+agent unifiée, création/édition avec récapitulatif avant activation, approbation/refus, exécuter maintenant, suspendre/reprendre, suppression), catalogage surface web (webSurfaceCatalog/viewPlacement/ViewPanelBody/WebWorkspace); docs/scheduled-project-tasks.md et sdk docs/services.md (047c38b: pas un service plugin V1, pas d'émulation via cron système). #314: correction de 'Conversation liée — transcript indisponible' après reboot: restauration du transcript lié côté application (orchestrator service, lifecycle, session_limit, background sink, terminal registry) et côté frontend (adapters agent/http frames/stream/requestResponse, workStateNormalization, CustomAgentChatView avec état et action utile), tests dto_chat/agent_lifecycle/structured_registry_d1/background_completion_sink étendus. QA verte rapportée par QA sur feature/305-scheduled-project-tasks-mcp, sans bloquant, pour #305 et #314. Hors périmètre worktree: tickets non liés, attachments/, scripts/; aucun push. --- Cargo.lock | 50 +- Cargo.toml | 2 + crates/app-tauri/src/commands.rs | 129 ++- crates/app-tauri/src/lib.rs | 6 + crates/app-tauri/tests/dto_chat.rs | 36 +- crates/application/Cargo.toml | 2 + crates/application/src/agent/lifecycle.rs | 3 + crates/application/src/agent/session_limit.rs | 7 +- crates/application/src/background/mod.rs | 12 +- crates/application/src/lib.rs | 5 + .../application/src/orchestrator/service.rs | 166 ++++ crates/application/src/plugin/mod.rs | 1 + crates/application/src/scheduled_task/mod.rs | 762 ++++++++++++++++++ crates/application/src/terminal/registry.rs | 28 + crates/application/tests/agent_lifecycle.rs | 6 +- .../tests/structured_registry_d1.rs | 12 + crates/backend/src/dto.rs | 114 ++- crates/backend/src/lib.rs | 175 +++- crates/domain/src/background_task.rs | 33 +- crates/domain/src/ids.rs | 4 + crates/domain/src/lib.rs | 26 +- crates/domain/src/orchestrator.rs | 172 +++- crates/domain/src/ports.rs | 58 ++ crates/domain/src/scheduled_task.rs | 288 +++++++ .../src/background_task/sink.rs | 25 +- crates/infrastructure/src/lib.rs | 10 +- .../src/orchestrator/mcp/server.rs | 2 +- .../src/orchestrator/mcp/tools.rs | 171 ++++ crates/infrastructure/src/scheduler/mod.rs | 10 +- crates/infrastructure/src/store/mod.rs | 2 + .../src/store/scheduled_task.rs | 282 +++++++ .../tests/background_completion_sink.rs | 41 + crates/web-server/src/lib.rs | 261 +++++- docs/scheduled-project-tasks.md | 31 + frontend/src/adapters/agent.test.ts | 2 + frontend/src/adapters/agent.ts | 4 + .../src/adapters/http/agentGateway.test.ts | 4 + frontend/src/adapters/http/frames.ts | 1 + frontend/src/adapters/http/index.ts | 2 + .../adapters/http/requestResponseGateways.ts | 42 + .../http/scheduledTaskGateway.test.ts | 19 + frontend/src/adapters/http/streamGateways.ts | 6 + frontend/src/adapters/index.ts | 3 + frontend/src/adapters/mock/index.ts | 110 +++ frontend/src/adapters/mock/mock.test.ts | 1 + frontend/src/adapters/scheduledTask.test.ts | 39 + frontend/src/adapters/scheduledTask.ts | 45 ++ .../src/adapters/workStateNormalization.ts | 13 + frontend/src/domain/index.ts | 55 ++ .../agents/CustomAgentChatView.test.tsx | 55 +- .../features/agents/CustomAgentChatView.tsx | 80 +- .../src/features/projects/ProjectsView.tsx | 3 + .../src/features/projects/ViewPanelBody.tsx | 6 + .../src/features/projects/viewPlacement.ts | 2 + .../scheduled-tasks/ScheduledTaskForm.tsx | 185 +++++ .../scheduled-tasks/ScheduledTasksPanel.tsx | 146 ++++ .../src/features/scheduled-tasks/index.ts | 2 + .../scheduled-tasks/scheduledTasks.test.tsx | 64 ++ .../scheduled-tasks/useScheduledTasks.ts | 109 +++ frontend/src/features/web/WebWorkspace.tsx | 5 + .../src/features/web/webSurfaceCatalog.ts | 9 + frontend/src/ports/index.ts | 41 + sdk/IdeaSDK | 2 +- 63 files changed, 3887 insertions(+), 100 deletions(-) create mode 100644 crates/application/src/scheduled_task/mod.rs create mode 100644 crates/domain/src/scheduled_task.rs create mode 100644 crates/infrastructure/src/store/scheduled_task.rs create mode 100644 docs/scheduled-project-tasks.md create mode 100644 frontend/src/adapters/http/scheduledTaskGateway.test.ts create mode 100644 frontend/src/adapters/scheduledTask.test.ts create mode 100644 frontend/src/adapters/scheduledTask.ts create mode 100644 frontend/src/features/scheduled-tasks/ScheduledTaskForm.tsx create mode 100644 frontend/src/features/scheduled-tasks/ScheduledTasksPanel.tsx create mode 100644 frontend/src/features/scheduled-tasks/index.ts create mode 100644 frontend/src/features/scheduled-tasks/scheduledTasks.test.tsx create mode 100644 frontend/src/features/scheduled-tasks/useScheduledTasks.ts diff --git a/Cargo.lock b/Cargo.lock index 247290f7..dad55926 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -98,6 +98,8 @@ name = "application" version = "0.3.0" dependencies = [ "async-trait", + "chrono", + "chrono-tz", "dirs", "domain", "serde", @@ -448,6 +450,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + [[package]] name = "combine" version = "4.6.7" @@ -625,7 +637,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf", + "phf 0.13.1", "smallvec", ] @@ -2963,6 +2975,15 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + [[package]] name = "phf" version = "0.13.1" @@ -2970,7 +2991,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ "phf_macros", - "phf_shared", + "phf_shared 0.13.1", "serde", ] @@ -2981,7 +3002,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.13.1", ] [[package]] @@ -2991,7 +3012,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", ] [[package]] @@ -3001,12 +3022,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.13.1", "proc-macro2", "quote", "syn 2.0.117", ] +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.13.1" @@ -3714,7 +3744,7 @@ dependencies = [ "derive_more", "log", "new_debug_unreachable", - "phf", + "phf 0.13.1", "phf_codegen", "precomputed-hash", "rustc-hash", @@ -4087,7 +4117,7 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared", + "phf_shared 0.13.1", "precomputed-hash", ] @@ -4098,7 +4128,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.13.1", "proc-macro2", "quote", ] @@ -4477,7 +4507,7 @@ dependencies = [ "json-patch", "log", "memchr", - "phf", + "phf 0.13.1", "plist", "proc-macro2", "quote", @@ -5348,7 +5378,7 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" dependencies = [ - "phf", + "phf 0.13.1", "phf_codegen", "string_cache", "string_cache_codegen", diff --git a/Cargo.toml b/Cargo.toml index 3b16b953..362b511e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,8 @@ http = "1" # only local operations (status/commit/branch/checkout/log) are in scope; remote # push/pull and static vendoring for the AppImage are deferred to L9/L11. git2 = { version = "0.20", default-features = false } +chrono = { version = "0.4", default-features = false, features = ["clock"] } +chrono-tz = "0.10" # Internal crates domain = { path = "crates/domain" } diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 8cf30f0f..288618fb 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -3719,11 +3719,12 @@ pub fn reattach_agent_chat( // this live channel — generation supersede, no double emission). let _gen = state.chat_bridge.register(sid, on_reply); - Ok(ReattachChatDto { - session_id: sid.to_string(), + Ok(ReattachChatDto::from_scrollback( + sid.to_string(), conversation_id, scrollback, - }) + state.structured_sessions.transcript_restore_expected(&sid), + )) } /// `detach_agent_chat` — detach only the custom chat view transport from a live @@ -5201,6 +5202,7 @@ pub async fn spawn_background_command( rendezvous: None, parent_task_id: None, deadline_ms: request.deadline_ms, + origin: domain::BackgroundTaskOrigin::Manual, }) .await .map(|out| BackgroundTaskDto::from(out.task)) @@ -5346,3 +5348,124 @@ pub async fn list_background_tasks( tasks.sort_by_key(|task| (task.created_at_ms, task.id)); Ok(tasks.into_iter().map(BackgroundTaskDto::from).collect()) } + +// --------------------------------------------------------------------------- +// Scheduled project tasks (#305) +// --------------------------------------------------------------------------- + +/// Lists durable scheduled definitions for one project. +#[tauri::command] +pub async fn list_scheduled_project_tasks( + project_id: String, + state: State<'_, AppState>, +) -> Result, ErrorDto> { + state + .scheduled_project_tasks + .list(parse_project_id(&project_id)?) + .await + .map_err(ErrorDto::from) +} + +/// Creates a human-approved scheduled project task definition. +#[tauri::command] +pub async fn create_scheduled_project_task( + request: crate::dto::CreateScheduledProjectTaskRequestDto, + state: State<'_, AppState>, +) -> Result { + state + .scheduled_project_tasks + .create(application::CreateScheduledProjectTaskInput { + project_id: parse_project_id(&request.project_id)?, + name: request.name, + description: request.description, + command: request.command, + schedule: request.schedule, + timezone: request.timezone, + enabled: request.enabled, + timeout_ms: request.timeout_ms, + actor: domain::ScheduledTaskActor::Human, + }) + .await + .map_err(ErrorDto::from) +} + +/// Replaces a scheduled project task definition under revision control. +#[tauri::command] +pub async fn update_scheduled_project_task( + request: crate::dto::UpdateScheduledProjectTaskRequestDto, + state: State<'_, AppState>, +) -> Result { + state + .scheduled_project_tasks + .update(application::UpdateScheduledProjectTaskInput { + project_id: parse_project_id(&request.project_id)?, + scheduled_task_id: crate::dto::parse_scheduled_task_id(&request.scheduled_task_id)?, + name: request.name, + description: request.description, + command: request.command, + schedule: request.schedule, + timezone: request.timezone, + enabled: request.enabled, + timeout_ms: request.timeout_ms, + audit_revision: request.audit_revision, + actor: domain::ScheduledTaskActor::Human, + }) + .await + .map_err(ErrorDto::from) +} + +/// Deletes a definition without cancelling a run already in progress. +#[tauri::command] +pub async fn delete_scheduled_project_task( + project_id: String, + scheduled_task_id: String, + state: State<'_, AppState>, +) -> Result<(), ErrorDto> { + state + .scheduled_project_tasks + .delete( + parse_project_id(&project_id)?, + crate::dto::parse_scheduled_task_id(&scheduled_task_id)?, + domain::ScheduledTaskActor::Human, + ) + .await + .map_err(ErrorDto::from) +} + +/// Starts an immediate background run for an approved definition. +#[tauri::command] +pub async fn run_scheduled_project_task_now( + project_id: String, + scheduled_task_id: String, + state: State<'_, AppState>, +) -> Result { + state + .scheduled_project_tasks + .run_now( + parse_project_id(&project_id)?, + crate::dto::parse_scheduled_task_id(&scheduled_task_id)?, + domain::ScheduledTaskActor::Human, + ) + .await + .map_err(ErrorDto::from) +} + +/// Enables/approves or disables a scheduled definition. +#[tauri::command] +pub async fn set_scheduled_project_task_enabled( + project_id: String, + scheduled_task_id: String, + enabled: bool, + state: State<'_, AppState>, +) -> Result { + state + .scheduled_project_tasks + .set_enabled( + parse_project_id(&project_id)?, + crate::dto::parse_scheduled_task_id(&scheduled_task_id)?, + enabled, + domain::ScheduledTaskActor::Human, + ) + .await + .map_err(ErrorDto::from) +} diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index be909afa..a317fc9d 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -418,6 +418,12 @@ pub fn run() { commands::retry_background_task, commands::attach_background_task, commands::list_background_tasks, + commands::list_scheduled_project_tasks, + commands::create_scheduled_project_task, + commands::update_scheduled_project_task, + commands::delete_scheduled_project_task, + commands::run_scheduled_project_task_now, + commands::set_scheduled_project_task_enabled, plugins::plugin_list_plugins, plugins::plugin_list_project_settings, plugins::plugin_set_project_enabled, diff --git a/crates/app-tauri/tests/dto_chat.rs b/crates/app-tauri/tests/dto_chat.rs index ac82c7d1..6a125673 100644 --- a/crates/app-tauri/tests/dto_chat.rs +++ b/crates/app-tauri/tests/dto_chat.rs @@ -8,9 +8,10 @@ use app_tauri_lib::dto::{ CellKind, ChatAttachmentDto, ChatAttachmentInputDto, ChatDraftScopeRequestDto, - ChatPromptAttachmentDto, ImportChatAttachmentsRequestDto, ImportChatAttachmentsResponseDto, - ReadChatDraftResponseDto, ReattachChatDto, ReplyChunk, ReplyProgressDto, ReplyProgressKindDto, - ReplyProgressSourceDto, ReplyProgressStageDto, SaveChatDraftRequestDto, TerminalSessionDto, + ChatPromptAttachmentDto, ConversationTranscriptUnavailableReasonDto, + ImportChatAttachmentsRequestDto, ImportChatAttachmentsResponseDto, ReadChatDraftResponseDto, + ReattachChatDto, ReplyChunk, ReplyProgressDto, ReplyProgressKindDto, ReplyProgressSourceDto, + ReplyProgressStageDto, SaveChatDraftRequestDto, TerminalSessionDto, }; use application::{LaunchAgentOutput, StructuredSessionDescriptor}; use domain::project::ProjectPath; @@ -407,10 +408,10 @@ fn import_chat_attachments_request_response_use_camel_case() { #[test] fn reattach_chat_dto_serialises_camel_case_with_typed_scrollback() { - let dto = ReattachChatDto { - session_id: "sess-1".into(), - conversation_id: Some("conv-1".into()), - scrollback: vec![ + let dto = ReattachChatDto::from_scrollback( + "sess-1".into(), + Some("conv-1".into()), + vec![ ReplyChunk::UserPrompt { at_ms: None, text: "Hi".into(), @@ -426,7 +427,8 @@ fn reattach_chat_dto_serialises_camel_case_with_typed_scrollback() { content: "Hi".into(), }, ], - }; + true, + ); let v = serde_json::to_value(&dto).unwrap(); assert_eq!( v, @@ -445,14 +447,22 @@ fn reattach_chat_dto_serialises_camel_case_with_typed_scrollback() { #[test] fn reattach_chat_dto_empty_scrollback_is_empty_array() { - let dto = ReattachChatDto { - session_id: "s".into(), - conversation_id: None, - scrollback: vec![], - }; + let dto = ReattachChatDto::from_scrollback("s".into(), None, vec![], true); let v = serde_json::to_value(&dto).unwrap(); assert_eq!(v["scrollback"], json!([])); assert!(v.get("conversationId").is_none()); + assert_eq!(v["transcriptUnavailableReason"], "missingTranscript"); + assert_eq!( + dto.transcript_unavailable_reason, + Some(ConversationTranscriptUnavailableReasonDto::MissingTranscript) + ); +} + +#[test] +fn reattach_chat_dto_distinguishes_fresh_empty_conversation() { + let dto = ReattachChatDto::from_scrollback("s".into(), None, vec![], false); + let v = serde_json::to_value(dto).unwrap(); + assert_eq!(v["transcriptUnavailableReason"], "emptyConversation"); } // --------------------------------------------------------------------------- diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml index bac78c9d..c78ebea3 100644 --- a/crates/application/Cargo.toml +++ b/crates/application/Cargo.toml @@ -21,6 +21,8 @@ subtle = { workspace = true } dirs = { workspace = true } # `v5` derives stable reference-profile ids from a fixed namespace (catalogue). uuid = { workspace = true } +chrono = { workspace = true } +chrono-tz = { workspace = true } # `time` feature only : borne le rendez-vous synchrone `send_blocking` (§17.4). # Déjà le runtime async du workspace ; pas une nouvelle dépendance externe. tokio = { workspace = true } diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 8b64276f..fa9eea4b 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -2752,6 +2752,7 @@ impl LaunchAgent { &input.project.root, input.node_id, size, + launch_conversation_id.is_some(), &spec.env, None, structured_policy.as_ref(), @@ -2855,6 +2856,7 @@ impl LaunchAgent { root: &ProjectPath, node_id: Option, size: PtySize, + transcript_restore_expected: bool, env: &[(String, String)], sandbox: Option<&SandboxPlan>, structured_policy: Option<&StructuredProviderLaunchPolicy>, @@ -2898,6 +2900,7 @@ impl LaunchAgent { model_server_guard, launch_managed_temp.map(|temp| temp.handle), ); + structured.set_transcript_restore_expected(session_id, transcript_restore_expected); // ── SÉPARATION DES DEUX CLÉS (ARCHITECTURE §19.7, lot P8a) ── // - **id de paire** (`pair_conversation_id`) : clé **logique** persistée sur diff --git a/crates/application/src/agent/session_limit.rs b/crates/application/src/agent/session_limit.rs index f87bd8d0..97436079 100644 --- a/crates/application/src/agent/session_limit.rs +++ b/crates/application/src/agent/session_limit.rs @@ -243,7 +243,12 @@ impl SessionLimitService { agent_id, node_id, conversation_id, - } = task; + } = task + else { + return Err(AppError::Invalid( + "session-limit service received a non-resume scheduled task".to_owned(), + )); + }; // Le réveil a tiré : l'entrée armée n'a plus lieu d'être (qu'on réussisse ou non). self.disarm(RuntimeAgentKey::new(project_id, agent_id)); diff --git a/crates/application/src/background/mod.rs b/crates/application/src/background/mod.rs index f3aefd5e..e11b56a6 100644 --- a/crates/application/src/background/mod.rs +++ b/crates/application/src/background/mod.rs @@ -21,9 +21,9 @@ use domain::ports::{ EventBus, IdGenerator, SpawnSpec, }; use domain::{ - AgentId, BackgroundTask, BackgroundTaskKind, BackgroundTaskRendezvousLink, - BackgroundTaskResult, BackgroundTaskState, BackgroundTaskWakePolicy, DomainEvent, ProjectId, - TaskId, + AgentId, BackgroundTask, BackgroundTaskKind, BackgroundTaskOrigin, + BackgroundTaskRendezvousLink, BackgroundTaskResult, BackgroundTaskState, + BackgroundTaskWakePolicy, DomainEvent, ProjectId, TaskId, }; use crate::error::AppError; @@ -60,6 +60,8 @@ pub struct SpawnBackgroundCommandInput { pub parent_task_id: Option, /// Optional absolute deadline, epoch milliseconds. pub deadline_ms: Option, + /// Manual or scheduled occurrence provenance. + pub origin: BackgroundTaskOrigin, } /// Output of [`SpawnBackgroundCommand`] / [`RetryBackgroundTask`]. @@ -121,6 +123,7 @@ impl SpawnBackgroundCommand { input.rendezvous, input.parent_task_id, input.deadline_ms, + input.origin, ) .await?; Ok(SpawnBackgroundCommandOutput { task }) @@ -137,6 +140,7 @@ impl SpawnBackgroundCommand { rendezvous: Option, parent_task_id: Option, deadline_ms: Option, + origin: BackgroundTaskOrigin, ) -> Result { let task_id = TaskId::from_uuid(self.ids.new_uuid()); let now = u64::try_from(self.clock.now_millis().max(0)).unwrap_or(0); @@ -151,6 +155,7 @@ impl SpawnBackgroundCommand { deadline_ms, ) .map_err(|e| AppError::Invalid(e.to_string()))?; + task = task.with_origin(origin); if let Some(link) = rendezvous { task = task.with_rendezvous(link); } @@ -485,6 +490,7 @@ impl RetryBackgroundTask { None, None, None, + BackgroundTaskOrigin::Manual, ) .await?; Ok(SpawnBackgroundCommandOutput { task }) diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 89f2940e..35891527 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -35,6 +35,7 @@ pub mod plugin; pub mod project; pub mod project_plugin; pub mod remote; +pub mod scheduled_task; pub mod settings; pub mod skill; pub mod slash_command; @@ -220,6 +221,10 @@ pub use project_plugin::{ ProjectPluginSettingsView, SetProjectPluginEnabled, }; pub use remote::{ConnectRemote, ConnectRemoteInput, ConnectRemoteOutput}; +pub use scheduled_task::{ + next_occurrence, CreateScheduledProjectTaskInput, ScheduledProjectTaskService, + UpdateScheduledProjectTaskInput, +}; pub use settings::{ ReadProjectSettings, ReadUserSettings, UpdateProjectSettings, UpdateUserSettings, }; diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index 3cc2c68d..f1cda8e7 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -54,6 +54,9 @@ use crate::orchestrator::{ ReadMemoryInput, UpdateProjectContext, UpdateProjectContextInput, WriteMemory, WriteMemoryInput, }; +use crate::scheduled_task::{ + CreateScheduledProjectTaskInput, ScheduledProjectTaskService, UpdateScheduledProjectTaskInput, +}; use crate::skill::{CreateSkill, CreateSkillInput, ReadSkill, ReadSkillInput}; use crate::terminal::{ stop_live_agent_runtime_in_project, CloseTerminal, LiveSessions, StructuredSessions, @@ -1205,6 +1208,8 @@ pub struct OrchestratorService { /// Use case B8 qui produit une tâche de fond command-backed depuis la surface /// agent MCP `idea_run_in_background`. `None` ⇒ outil non configuré. spawn_background_command: Option>, + /// Durable project schedule use cases backing `idea_scheduled_task_*`. + scheduled_project_tasks: Option>, /// Detached self-restart coordinator, wired at the composition root. self_restart: Option>, /// Stable control-plane handoff between an active ask and a detached restart. @@ -1308,6 +1313,7 @@ impl OrchestratorService { background_runner: None, session_limits: None, spawn_background_command: None, + scheduled_project_tasks: None, self_restart: None, restart_rendezvous: Arc::new(RestartRendezvousCoordinator::new()), } @@ -1334,6 +1340,16 @@ impl OrchestratorService { self } + /// Wires requester-scoped scheduled-task tools. + #[must_use] + pub fn with_scheduled_project_tasks( + mut self, + service: Arc, + ) -> Self { + self.scheduled_project_tasks = Some(service); + self + } + /// Wires the requester-scoped detached self-restart coordinator. #[must_use] pub fn with_self_restart(mut self, restart: Arc) -> Self { @@ -2112,9 +2128,158 @@ impl OrchestratorService { self.run_in_background(project, owner, label, command, args, cwd, deadline_ms) .await } + OrchestratorCommand::ListScheduledTasks => self.scheduled_list(project).await, + OrchestratorCommand::ReadScheduledTask { scheduled_task_id } => { + self.scheduled_read(project, scheduled_task_id).await + } + OrchestratorCommand::CreateScheduledTask { + requester, + name, + description, + command, + schedule, + timezone, + timeout_ms, + } => { + let task = self + .require_scheduled_tasks()? + .create(CreateScheduledProjectTaskInput { + project_id: project.id, + name, + description, + command, + schedule, + timezone, + enabled: false, + timeout_ms, + actor: domain::ScheduledTaskActor::Agent { + agent_id: requester, + }, + }) + .await?; + Self::scheduled_outcome(task, "scheduled task request saved pending human approval") + } + OrchestratorCommand::UpdateScheduledTask { + requester, + scheduled_task_id, + name, + description, + command, + schedule, + timezone, + timeout_ms, + audit_revision, + } => { + let task = self + .require_scheduled_tasks()? + .update(UpdateScheduledProjectTaskInput { + project_id: project.id, + scheduled_task_id, + name, + description, + command, + schedule, + timezone, + enabled: false, + timeout_ms, + audit_revision, + actor: domain::ScheduledTaskActor::Agent { + agent_id: requester, + }, + }) + .await?; + Self::scheduled_outcome(task, "scheduled task update saved pending human approval") + } + OrchestratorCommand::DeleteScheduledTask { + requester, + scheduled_task_id, + } => { + self.require_scheduled_tasks()? + .delete( + project.id, + scheduled_task_id, + domain::ScheduledTaskActor::Agent { + agent_id: requester, + }, + ) + .await?; + Ok(OrchestratorOutcome { + detail: "scheduled task deleted".to_owned(), + reply: Some("{\"status\":\"deleted\"}".to_owned()), + }) + } + OrchestratorCommand::RunScheduledTaskNow { + requester, + scheduled_task_id, + } => { + let task = self + .require_scheduled_tasks()? + .run_now( + project.id, + scheduled_task_id, + domain::ScheduledTaskActor::Agent { + agent_id: requester, + }, + ) + .await?; + Self::scheduled_outcome(task, "scheduled task run started") + } + OrchestratorCommand::SetScheduledTaskEnabled { + requester, + scheduled_task_id, + enabled, + } => { + let task = self + .require_scheduled_tasks()? + .set_enabled( + project.id, + scheduled_task_id, + enabled, + domain::ScheduledTaskActor::Agent { + agent_id: requester, + }, + ) + .await?; + Self::scheduled_outcome(task, "scheduled task enabled state updated") + } } } + fn require_scheduled_tasks(&self) -> Result<&Arc, AppError> { + self.scheduled_project_tasks.as_ref().ok_or_else(|| { + AppError::Invalid("scheduled project tasks are not configured".to_owned()) + }) + } + + async fn scheduled_list(&self, project: &Project) -> Result { + let tasks = self.require_scheduled_tasks()?.list(project.id).await?; + let reply = serde_json::to_string(&tasks).map_err(|e| AppError::Invalid(e.to_string()))?; + Ok(OrchestratorOutcome { + detail: format!("listed {} scheduled task(s)", tasks.len()), + reply: Some(reply), + }) + } + + async fn scheduled_read( + &self, + project: &Project, + id: domain::ScheduledTaskId, + ) -> Result { + let task = self.require_scheduled_tasks()?.get(project.id, id).await?; + Self::scheduled_outcome(task, "scheduled task read") + } + + fn scheduled_outcome( + task: domain::ScheduledProjectTask, + detail: &str, + ) -> Result { + let reply = serde_json::to_string(&task).map_err(|e| AppError::Invalid(e.to_string()))?; + Ok(OrchestratorOutcome { + detail: detail.to_owned(), + reply: Some(reply), + }) + } + fn require_project_store(&self) -> Result<&Arc, AppError> { self.projects.as_ref().ok_or_else(|| { AppError::Invalid("cross-project MCP tools are not configured".to_owned()) @@ -2341,6 +2506,7 @@ impl OrchestratorService { rendezvous: rendezvous.clone(), parent_task_id, deadline_ms, + origin: domain::BackgroundTaskOrigin::Manual, }) .await?; if let Some(link) = rendezvous { diff --git a/crates/application/src/plugin/mod.rs b/crates/application/src/plugin/mod.rs index 11725ac9..921c2145 100644 --- a/crates/application/src/plugin/mod.rs +++ b/crates/application/src/plugin/mod.rs @@ -2048,6 +2048,7 @@ impl PluginCommandTasks { rendezvous: None, parent_task_id: None, deadline_ms: input.deadline_ms, + origin: domain::BackgroundTaskOrigin::Manual, }) .await .map(|out| out.task) diff --git a/crates/application/src/scheduled_task/mod.rs b/crates/application/src/scheduled_task/mod.rs new file mode 100644 index 00000000..3fe1ca7e --- /dev/null +++ b/crates/application/src/scheduled_task/mod.rs @@ -0,0 +1,762 @@ +//! Project scheduled task use cases and one-shot reconciliation. + +use std::collections::HashMap; +use std::path::{Component, Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use chrono::{Datelike, TimeZone, Timelike, Utc}; +use chrono_tz::Tz; +use domain::ports::{Clock, FileSystem, IdGenerator, ProjectStore, Scheduler, SpawnSpec}; +use domain::{ + AgentId, BackgroundTaskWakePolicy, PathAccess, PathGrant, Posture, ProjectId, ProjectPath, + RemotePath, SandboxPlan, ScheduleId, ScheduledProjectTask, ScheduledProjectTaskStore, + ScheduledTask, ScheduledTaskActor, ScheduledTaskApproval, ScheduledTaskAuditEntry, + ScheduledTaskCommand, ScheduledTaskId, ScheduledTaskSandbox, ScheduledTaskSchedule, + ScheduledTaskStoreError, Weekday, +}; +use tokio::sync::Mutex as AsyncMutex; + +use crate::{AppError, SpawnBackgroundCommand, SpawnBackgroundCommandInput}; + +fn map_store(error: ScheduledTaskStoreError) -> AppError { + match error { + ScheduledTaskStoreError::NotFound => { + AppError::NotFound("scheduled project task".to_owned()) + } + ScheduledTaskStoreError::Invalid(message) => AppError::Invalid(message), + ScheduledTaskStoreError::Store(message) => AppError::Store(message), + } +} + +/// Full create payload used by both human commands and requester-scoped MCP. +#[derive(Debug, Clone)] +pub struct CreateScheduledProjectTaskInput { + /// Owning project. + pub project_id: ProjectId, + /// Display name. + pub name: String, + /// Optional review context. + pub description: Option, + /// Command to execute without free-form environment. + pub command: ScheduledTaskCommand, + /// Bounded recurring schedule. + pub schedule: ScheduledTaskSchedule, + /// IANA timezone. + pub timezone: String, + /// Requested initial enabled state; agent requests are forced off. + pub enabled: bool, + /// Optional per-run deadline. + pub timeout_ms: Option, + /// Request origin used for approval enforcement and audit. + pub actor: ScheduledTaskActor, +} + +/// Full replacement payload. `audit_revision` is optimistic concurrency control. +#[derive(Debug, Clone)] +pub struct UpdateScheduledProjectTaskInput { + /// Owning project. + pub project_id: ProjectId, + /// Definition to replace. + pub scheduled_task_id: ScheduledTaskId, + /// Display name. + pub name: String, + /// Optional review context. + pub description: Option, + /// Command to execute without free-form environment. + pub command: ScheduledTaskCommand, + /// Bounded recurring schedule. + pub schedule: ScheduledTaskSchedule, + /// IANA timezone. + pub timezone: String, + /// Requested enabled state; agent changes are forced off. + pub enabled: bool, + /// Optional per-run deadline. + pub timeout_ms: Option, + /// Expected current revision for optimistic concurrency. + pub audit_revision: u64, + /// Request origin used for approval enforcement and audit. + pub actor: ScheduledTaskActor, +} + +/// Coordinates durable definitions with an in-memory one-shot scheduler. +pub struct ScheduledProjectTaskService { + store: Arc, + projects: Arc, + fs: Arc, + scheduler: Arc, + spawn: Arc, + clock: Arc, + ids: Arc, + arms: Mutex>, + mutation: AsyncMutex<()>, +} + +impl ScheduledProjectTaskService { + /// Builds the scheduled-task application service from inward-facing ports. + #[must_use] + pub fn new( + store: Arc, + projects: Arc, + fs: Arc, + scheduler: Arc, + spawn: Arc, + clock: Arc, + ids: Arc, + ) -> Self { + Self { + store, + projects, + fs, + scheduler, + spawn, + clock, + ids, + arms: Mutex::new(HashMap::new()), + mutation: AsyncMutex::new(()), + } + } + + fn now(&self) -> u64 { + self.clock.now_millis().max(0) as u64 + } + + /// Lists definitions for a registered project. + pub async fn list(&self, project_id: ProjectId) -> Result, AppError> { + self.projects.load_project(project_id).await?; + self.store.list(project_id).await.map_err(map_store) + } + + /// Reads one definition from a registered project. + pub async fn get( + &self, + project_id: ProjectId, + id: ScheduledTaskId, + ) -> Result { + self.projects.load_project(project_id).await?; + self.store + .get(project_id, id) + .await + .map_err(map_store)? + .ok_or_else(|| AppError::NotFound(format!("scheduled project task {id}"))) + } + + /// Creates a definition, forcing agent requests into pending approval. + pub async fn create( + &self, + input: CreateScheduledProjectTaskInput, + ) -> Result { + let _guard = self.mutation.lock().await; + self.projects.load_project(input.project_id).await?; + validate_relative_cwd(input.command.cwd.as_deref())?; + let now = self.now(); + let (approval, enabled) = match input.actor { + ScheduledTaskActor::Agent { .. } => (ScheduledTaskApproval::Pending, false), + _ => (ScheduledTaskApproval::Approved, input.enabled), + }; + let mut task = ScheduledProjectTask { + id: ScheduledTaskId::from_uuid(self.ids.new_uuid()), + project_id: input.project_id, + name: input.name, + description: input.description, + enabled, + approval, + sandbox: ScheduledTaskSandbox::ProjectReadWrite, + command: input.command, + schedule: input.schedule, + timezone: input.timezone, + next_run_at_ms: None, + last_run_at_ms: None, + last_task_id: None, + created_by: input.actor.clone(), + updated_by: input.actor.clone(), + created_at_ms: now, + updated_at_ms: now, + audit_revision: 1, + timeout_ms: input.timeout_ms, + }; + validate_timezone(&task.timezone)?; + task.validate() + .map_err(|e| AppError::Invalid(e.to_string()))?; + task.next_run_at_ms = enabled + .then(|| next_occurrence(&task.schedule, &task.timezone, now)) + .transpose()?; + let audit = audit(&task, input.actor, "create", "scheduled task created", now); + self.store.save(&task, audit).await.map_err(map_store)?; + self.arm_definition(&task); + Ok(task) + } + + /// Replaces a definition under optimistic concurrency control. + pub async fn update( + &self, + input: UpdateScheduledProjectTaskInput, + ) -> Result { + let _guard = self.mutation.lock().await; + validate_relative_cwd(input.command.cwd.as_deref())?; + validate_timezone(&input.timezone)?; + let mut task = self + .store + .get(input.project_id, input.scheduled_task_id) + .await + .map_err(map_store)? + .ok_or_else(|| { + AppError::NotFound(format!( + "scheduled project task {}", + input.scheduled_task_id + )) + })?; + if task.audit_revision != input.audit_revision { + return Err(AppError::Invalid( + "scheduled task revision conflict".to_owned(), + )); + } + let actor_is_agent = matches!(input.actor, ScheduledTaskActor::Agent { .. }); + task.name = input.name; + task.description = input.description; + task.command = input.command; + task.schedule = input.schedule; + task.timezone = input.timezone; + task.timeout_ms = input.timeout_ms; + task.updated_by = input.actor.clone(); + task.updated_at_ms = self.now(); + task.audit_revision += 1; + if actor_is_agent { + task.approval = ScheduledTaskApproval::Pending; + task.enabled = false; + task.next_run_at_ms = None; + } else { + task.approval = ScheduledTaskApproval::Approved; + task.enabled = input.enabled; + task.next_run_at_ms = input + .enabled + .then(|| next_occurrence(&task.schedule, &task.timezone, task.updated_at_ms)) + .transpose()?; + } + task.validate() + .map_err(|e| AppError::Invalid(e.to_string()))?; + let audit = audit( + &task, + input.actor, + "update", + "definition replaced", + task.updated_at_ms, + ); + self.store.save(&task, audit).await.map_err(map_store)?; + self.cancel_arm(task.id); + self.arm_definition(&task); + Ok(task) + } + + /// Enables, approves, rejects, or suspends a definition according to actor policy. + pub async fn set_enabled( + &self, + project_id: ProjectId, + id: ScheduledTaskId, + enabled: bool, + actor: ScheduledTaskActor, + ) -> Result { + let _guard = self.mutation.lock().await; + let mut task = self + .store + .get(project_id, id) + .await + .map_err(map_store)? + .ok_or_else(|| AppError::NotFound(format!("scheduled project task {id}")))?; + if enabled + && matches!(actor, ScheduledTaskActor::Agent { .. }) + && task.approval != ScheduledTaskApproval::Approved + { + return Err(AppError::Invalid( + "only a human can approve an agent-requested scheduled task".to_owned(), + )); + } + let now = self.now(); + task.enabled = enabled; + task.updated_by = actor.clone(); + task.updated_at_ms = now; + task.audit_revision += 1; + if enabled { + task.approval = ScheduledTaskApproval::Approved; + task.next_run_at_ms = Some(next_occurrence(&task.schedule, &task.timezone, now)?); + } else { + if task.approval == ScheduledTaskApproval::Pending + && matches!(actor, ScheduledTaskActor::Human) + { + task.approval = ScheduledTaskApproval::Rejected; + } + task.next_run_at_ms = None; + } + let action = if enabled { "enable" } else { "disable" }; + self.store + .save(&task, audit(&task, actor, action, action, now)) + .await + .map_err(map_store)?; + self.cancel_arm(id); + self.arm_definition(&task); + Ok(task) + } + + /// Deletes a definition without interrupting any concrete run already started. + pub async fn delete( + &self, + project_id: ProjectId, + id: ScheduledTaskId, + actor: ScheduledTaskActor, + ) -> Result<(), AppError> { + let _guard = self.mutation.lock().await; + let task = self + .store + .get(project_id, id) + .await + .map_err(map_store)? + .ok_or_else(|| AppError::NotFound(format!("scheduled project task {id}")))?; + let now = self.now(); + self.store + .delete( + project_id, + id, + ScheduledTaskAuditEntry { + revision: task.audit_revision + 1, + project_id, + scheduled_task_id: id, + actor, + action: "delete".to_owned(), + summary: + "definition deleted; an already-running BackgroundTask is not interrupted" + .to_owned(), + at_ms: now, + }, + ) + .await + .map_err(map_store)?; + self.cancel_arm(id); + Ok(()) + } + + /// Creates an immediate concrete background run for an approved definition. + pub async fn run_now( + &self, + project_id: ProjectId, + id: ScheduledTaskId, + actor: ScheduledTaskActor, + ) -> Result { + let _guard = self.mutation.lock().await; + let task = self + .store + .get(project_id, id) + .await + .map_err(map_store)? + .ok_or_else(|| AppError::NotFound(format!("scheduled project task {id}")))?; + if task.approval != ScheduledTaskApproval::Approved { + return Err(AppError::Invalid( + "scheduled task requires human approval".to_owned(), + )); + } + self.spawn_run(task, self.now(), actor, "runNow").await + } + + /// Recomputes missed deadlines and arms exactly one future occurrence. + pub async fn reconcile_project(&self, project_id: ProjectId) -> Result<(), AppError> { + let _guard = self.mutation.lock().await; + let now = self.now(); + for mut task in self.store.list(project_id).await.map_err(map_store)? { + self.cancel_arm(task.id); + if !task.enabled || task.approval != ScheduledTaskApproval::Approved { + continue; + } + if task.next_run_at_ms.is_none_or(|at| at <= now) { + task.next_run_at_ms = Some(next_occurrence(&task.schedule, &task.timezone, now)?); + task.updated_at_ms = now; + task.audit_revision += 1; + let entry = audit( + &task, + ScheduledTaskActor::System, + "reconcile", + "missed occurrence skipped; next future occurrence armed", + now, + ); + self.store.save(&task, entry).await.map_err(map_store)?; + } + self.arm_definition(&task); + } + Ok(()) + } + + /// Cancels the in-memory one-shot arms owned by a closing project. + pub fn cancel_project(&self, project_id: ProjectId) { + if let Ok(mut arms) = self.arms.lock() { + let ids = arms + .iter() + .filter_map(|(id, (owner, _))| (*owner == project_id).then_some(*id)) + .collect::>(); + let handles = ids + .into_iter() + .filter_map(|id| arms.remove(&id).map(|(_, handle)| handle)) + .collect::>(); + drop(arms); + for handle in handles { + self.scheduler.cancel(handle); + } + } + } + + /// Handles a one-shot wake. Persisting the next deadline before spawning + /// makes duplicate/stale delivery harmless. + pub async fn fire( + &self, + project_id: ProjectId, + id: ScheduledTaskId, + planned_at_ms: u64, + ) -> Result<(), AppError> { + let _guard = self.mutation.lock().await; + self.arms.lock().expect("scheduled arms mutex").remove(&id); + let Some(mut task) = self.store.get(project_id, id).await.map_err(map_store)? else { + return Ok(()); + }; + if !task.enabled + || task.approval != ScheduledTaskApproval::Approved + || task.next_run_at_ms != Some(planned_at_ms) + { + return Ok(()); + } + let now = self.now(); + task.next_run_at_ms = Some(next_occurrence( + &task.schedule, + &task.timezone, + now.max(planned_at_ms), + )?); + task.updated_at_ms = now; + task.audit_revision += 1; + self.store + .save( + &task, + audit( + &task, + ScheduledTaskActor::System, + "fire", + "next occurrence persisted before spawn", + now, + ), + ) + .await + .map_err(map_store)?; + self.arm_definition(&task); + self.spawn_run(task, planned_at_ms, ScheduledTaskActor::System, "scheduled") + .await + .map(|_| ()) + } + + async fn spawn_run( + &self, + mut task: ScheduledProjectTask, + planned_at_ms: u64, + actor: ScheduledTaskActor, + action: &str, + ) -> Result { + let project = self.projects.load_project(task.project_id).await?; + let cwd = match confined_cwd(self.fs.as_ref(), &project.root, task.command.cwd.as_deref()) + .await + { + Ok(cwd) => cwd, + Err(error) => { + let now = self.now(); + task.updated_at_ms = now; + task.audit_revision += 1; + let entry = audit( + &task, + ScheduledTaskActor::System, + "skip", + "run skipped because the project or working directory is inaccessible", + now, + ); + self.store.save(&task, entry).await.map_err(map_store)?; + return Err(error); + } + }; + let now = self.now(); + let owner = match task.created_by { + ScheduledTaskActor::Agent { agent_id } => agent_id, + _ => AgentId::from_uuid(uuid::Uuid::nil()), + }; + let deadline_ms = task.timeout_ms.map(|timeout| now.saturating_add(timeout)); + let output = self + .spawn + .execute(SpawnBackgroundCommandInput { + project_id: task.project_id, + owner_agent_id: owner, + label: task.name.clone(), + command: SpawnSpec { + command: task.command.command.clone(), + args: task.command.args.clone(), + cwd, + env: Vec::new(), + context_plan: None, + sandbox: Some(confined_command_sandbox(&project.root)), + }, + wake_policy: BackgroundTaskWakePolicy::RecordOnly, + rendezvous: None, + parent_task_id: None, + deadline_ms, + origin: domain::BackgroundTaskOrigin::Scheduled { + scheduled_task_id: task.id, + planned_at_ms, + }, + }) + .await?; + task.last_run_at_ms = Some(now); + task.last_task_id = Some(output.task.id); + task.updated_by = actor.clone(); + task.updated_at_ms = now; + task.audit_revision += 1; + let summary = format!( + "{action} created BackgroundTask {} for occurrence {planned_at_ms}", + output.task.id + ); + self.store + .save(&task, audit(&task, actor, action, &summary, now)) + .await + .map_err(map_store)?; + Ok(task) + } + + fn arm_definition(&self, task: &ScheduledProjectTask) { + let Some(at) = task + .next_run_at_ms + .filter(|_| task.enabled && task.approval == ScheduledTaskApproval::Approved) + else { + return; + }; + let handle = self.scheduler.arm( + at.min(i64::MAX as u64) as i64, + ScheduledTask::FireProjectTask { + project_id: task.project_id, + scheduled_task_id: task.id, + planned_at_ms: at, + }, + ); + self.arms + .lock() + .expect("scheduled arms mutex") + .insert(task.id, (task.project_id, handle)); + } + fn cancel_arm(&self, id: ScheduledTaskId) { + if let Some((_, handle)) = self.arms.lock().expect("scheduled arms mutex").remove(&id) { + self.scheduler.cancel(handle); + } + } +} + +fn audit( + task: &ScheduledProjectTask, + actor: ScheduledTaskActor, + action: &str, + summary: &str, + at_ms: u64, +) -> ScheduledTaskAuditEntry { + ScheduledTaskAuditEntry { + revision: task.audit_revision, + project_id: task.project_id, + scheduled_task_id: task.id, + actor, + action: action.to_owned(), + summary: summary.to_owned(), + at_ms, + } +} + +fn validate_timezone(value: &str) -> Result { + value + .parse::() + .map_err(|_| AppError::Invalid(format!("unknown IANA timezone `{value}`"))) +} + +/// Returns the first future minute matching the local wall-clock schedule. +pub fn next_occurrence( + schedule: &ScheduledTaskSchedule, + timezone: &str, + after_ms: u64, +) -> Result { + if let ScheduledTaskSchedule::Interval { every_minutes } = schedule { + schedule + .validate() + .map_err(|e| AppError::Invalid(e.to_string()))?; + return Ok(after_ms.saturating_add(u64::from(*every_minutes) * 60_000)); + } + let tz = validate_timezone(timezone)?; + schedule + .validate() + .map_err(|e| AppError::Invalid(e.to_string()))?; + let start = (after_ms / 60_000 + 1) * 60_000; + for offset in 0..=(8 * 24 * 60) { + let millis = start.saturating_add(offset * 60_000); + if millis > i64::MAX as u64 { + return Err(AppError::Invalid( + "schedule timestamp out of range".to_owned(), + )); + } + let utc = Utc + .timestamp_millis_opt(millis as i64) + .single() + .ok_or_else(|| AppError::Invalid("schedule timestamp out of range".to_owned()))?; + let local = utc.with_timezone(&tz); + let wanted_time = match schedule { + ScheduledTaskSchedule::Daily { time } | ScheduledTaskSchedule::Weekly { time, .. } => { + time + } + _ => unreachable!(), + }; + let hour: u32 = wanted_time[0..2].parse().unwrap_or(99); + let minute: u32 = wanted_time[3..5].parse().unwrap_or(99); + if local.hour() != hour || local.minute() != minute { + continue; + } + let day_matches = match schedule { + ScheduledTaskSchedule::Daily { .. } => true, + ScheduledTaskSchedule::Weekly { days, .. } => days + .iter() + .any(|day| weekday_number(*day) == local.weekday().number_from_monday()), + _ => false, + }; + if day_matches { + return Ok(millis); + } + } + Err(AppError::Invalid( + "could not resolve next scheduled occurrence".to_owned(), + )) +} + +fn weekday_number(day: Weekday) -> u32 { + match day { + Weekday::Monday => 1, + Weekday::Tuesday => 2, + Weekday::Wednesday => 3, + Weekday::Thursday => 4, + Weekday::Friday => 5, + Weekday::Saturday => 6, + Weekday::Sunday => 7, + } +} + +fn validate_relative_cwd(cwd: Option<&str>) -> Result<(), AppError> { + let value = cwd.unwrap_or(".").trim(); + let path = Path::new(value); + if value.is_empty() + || path.is_absolute() + || path.components().any(|c| { + matches!( + c, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + { + return Err(AppError::Invalid( + "scheduled task cwd must be project-relative and cannot contain `..`".to_owned(), + )); + } + Ok(()) +} + +async fn confined_cwd( + fs: &dyn FileSystem, + root: &ProjectPath, + cwd: Option<&str>, +) -> Result { + validate_relative_cwd(cwd)?; + let root_canonical = fs + .canonicalize(&RemotePath::new(root.as_str().to_owned())) + .await + .map_err(|_| AppError::NotFound("scheduled task project is inaccessible".to_owned()))?; + let candidate = PathBuf::from(root.as_str()).join(cwd.unwrap_or(".")); + let candidate_canonical = fs + .canonicalize(&RemotePath::new(candidate.to_string_lossy().into_owned())) + .await + .map_err(|_| { + AppError::NotFound("scheduled task working directory is inaccessible".to_owned()) + })?; + if !Path::new(candidate_canonical.as_str()).starts_with(Path::new(root_canonical.as_str())) { + return Err(AppError::Invalid( + "scheduled task cwd escapes the project root through a symlink".to_owned(), + )); + } + ProjectPath::new(candidate_canonical.as_str().to_owned()) + .map_err(|e| AppError::Invalid(e.to_string())) +} + +fn confined_command_sandbox(root: &ProjectPath) -> SandboxPlan { + let mut allowed = vec![PathGrant { + abs_root: root.as_str().to_owned(), + access: PathAccess::RO | PathAccess::RW | PathAccess::EXEC, + }]; + // Runtime/toolchain roots are read/execute-only; only the project can be mutated. + for system_root in ["/bin", "/usr", "/lib", "/lib64", "/etc"] { + allowed.push(PathGrant { + abs_root: system_root.to_owned(), + access: PathAccess::RO | PathAccess::EXEC, + }); + } + SandboxPlan { + allowed, + default_posture: Posture::Deny, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{NaiveDate, TimeZone}; + + #[test] + fn interval_is_strictly_future_and_never_catches_up() { + let next = next_occurrence( + &ScheduledTaskSchedule::Interval { every_minutes: 5 }, + "UTC", + 1_000, + ) + .unwrap(); + assert_eq!(next, 301_000); + } + + #[test] + fn daily_schedule_uses_iana_timezone() { + let paris: Tz = "Europe/Paris".parse().unwrap(); + let after = paris + .from_local_datetime( + &NaiveDate::from_ymd_opt(2026, 7, 1) + .unwrap() + .and_hms_opt(8, 59, 30) + .unwrap(), + ) + .single() + .unwrap() + .timestamp_millis() as u64; + let expected = paris + .from_local_datetime( + &NaiveDate::from_ymd_opt(2026, 7, 1) + .unwrap() + .and_hms_opt(9, 0, 0) + .unwrap(), + ) + .single() + .unwrap() + .timestamp_millis() as u64; + assert_eq!( + next_occurrence( + &ScheduledTaskSchedule::Daily { + time: "09:00".to_owned() + }, + "Europe/Paris", + after + ) + .unwrap(), + expected + ); + } + + #[test] + fn cwd_rejects_absolute_and_parent_traversal() { + assert!(validate_relative_cwd(Some("/tmp")).is_err()); + assert!(validate_relative_cwd(Some("build/../outside")).is_err()); + assert!(validate_relative_cwd(Some("build/cache")).is_ok()); + } +} diff --git a/crates/application/src/terminal/registry.rs b/crates/application/src/terminal/registry.rs index 9af1b5af..928f4c32 100644 --- a/crates/application/src/terminal/registry.rs +++ b/crates/application/src/terminal/registry.rs @@ -530,6 +530,9 @@ struct StructuredEntry { agent_id: AgentId, /// La cellule (feuille de layout) qui héberge actuellement la vue. node_id: NodeId, + /// The launch resumed a logical conversation already linked to the cell. + /// A fresh in-memory chat bridge therefore cannot provide its transcript. + transcript_restore_expected: bool, /// Launch-scoped managed-temp handle retained for release. managed_temp_handle: Option, /// Optional local-model-server usage guard retained for the live session. @@ -654,6 +657,7 @@ impl StructuredSessions { session, agent_id, node_id, + transcript_restore_expected: false, managed_temp_handle, _model_server_guard: model_server_guard, }, @@ -688,6 +692,30 @@ impl StructuredSessions { }) } + /// Records whether this runtime was opened from an existing logical conversation. + pub fn set_transcript_restore_expected(&self, id: SessionId, expected: bool) { + if let Ok(mut entries) = self.entries.lock() { + if let Some(entry) = entries.get_mut(&id) { + entry.transcript_restore_expected = expected; + } + } + } + + /// Returns whether an empty volatile scrollback means a linked transcript + /// should have been restored, rather than a genuinely fresh conversation. + #[must_use] + pub fn transcript_restore_expected(&self, id: &SessionId) -> bool { + self.entries + .lock() + .ok() + .and_then(|entries| { + entries + .get(id) + .map(|entry| entry.transcript_restore_expected) + }) + .unwrap_or(false) + } + /// Enregistre une session assistant ticket éphémère, keyée par `issue_ref`. /// /// Retourne l'éventuelle session remplacée pour que l'appelant puisse la fermer diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index 486b1a8f..0b0cfa25 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -5038,9 +5038,13 @@ async fn custom_chat_codex_resume_recomputes_project_and_managed_tmp_writable_ro let mut input = launch_input_with_project_root(agent.id, &root); input.require_structured = true; input.conversation_id = Some("pair-conversation-306".to_owned()); - launch.execute(input).await.expect("structured launch"); + let out = launch.execute(input).await.expect("structured launch"); assert!(pty.spawns().is_empty()); + assert!( + structured.transcript_restore_expected(&out.session.id), + "a structured resume from a linked cell must expose that transcript restoration was expected" + ); let env = factory.envs().remove(0); let env: HashMap<_, _> = env.into_iter().collect(); assert_eq!(env.get("TMPDIR"), Some(&tmp)); diff --git a/crates/application/tests/structured_registry_d1.rs b/crates/application/tests/structured_registry_d1.rs index 717d046c..2ff63bf6 100644 --- a/crates/application/tests/structured_registry_d1.rs +++ b/crates/application/tests/structured_registry_d1.rs @@ -122,6 +122,18 @@ fn structured_insert_resolve_and_remove() { assert!(reg.session_for_agent(&a).is_none()); } +#[test] +fn structured_transcript_restore_expectation_defaults_false_and_can_be_marked() { + let reg = StructuredSessions::new(); + let session_id = sid(1); + reg.insert(fake(session_id), aid(10), nid(100)); + + assert!(!reg.transcript_restore_expected(&session_id)); + reg.set_transcript_restore_expected(session_id, true); + assert!(reg.transcript_restore_expected(&session_id)); + assert!(!reg.transcript_restore_expected(&sid(999))); +} + #[test] fn structured_meta_for_session_resolves_agent_node_and_conversation() { // LS7 (§21.10) : le tap niveau 1 n'a que le `SessionId` du tour et doit retrouver diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index a0bb60e6..3ab13f13 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -3917,11 +3917,32 @@ pub enum ReplyChunk { }, } +/// Reason why a successful structured attach has no replayable turns. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ConversationTranscriptUnavailableReasonDto { + /// A linked conversation was resumed, but the volatile live transcript was absent. + MissingTranscript, + /// The session is fresh and has not produced a conversation turn yet. + EmptyConversation, +} + +impl ConversationTranscriptUnavailableReasonDto { + /// Returns an additive reason only when no replayable chunk exists. + #[must_use] + pub fn for_scrollback(is_empty: bool, transcript_restore_expected: bool) -> Option { + is_empty.then_some(if transcript_restore_expected { + Self::MissingTranscript + } else { + Self::EmptyConversation + }) + } +} + /// Response DTO for `reattach_agent_chat`: the retained **conversation -/// scrollback** of a still-live structured session, replayed into the -/// re-mounting `AgentChatView` before the new reply stream is wired (§17.6). The -/// typed twin of [`ReattachResultDto`] (PTY scrollback bytes) — here the -/// scrollback is the ordered list of [`ReplyChunk`]s already streamed. +/// scrollback** of a still-live structured session, replayed before the new +/// reply stream is wired. Unlike a transport error, an empty successful result +/// carries a typed reason so the UI can offer an appropriate recovery action. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ReattachChatDto { @@ -3936,6 +3957,32 @@ pub struct ReattachChatDto { /// replays them to rebuild the visible turns, then receives subsequent chunks /// over the freshly-registered channel. pub scrollback: Vec, + /// Structured reason for an empty scrollback. Absent when replayable chunks exist. + #[serde(skip_serializing_if = "Option::is_none")] + pub transcript_unavailable_reason: Option, +} + +impl ReattachChatDto { + /// Builds the additive read/open contract from runtime transcript metadata. + #[must_use] + pub fn from_scrollback( + session_id: String, + conversation_id: Option, + scrollback: Vec, + transcript_restore_expected: bool, + ) -> Self { + let transcript_unavailable_reason = + ConversationTranscriptUnavailableReasonDto::for_scrollback( + scrollback.is_empty(), + transcript_restore_expected, + ); + Self { + session_id, + conversation_id, + scrollback, + transcript_unavailable_reason, + } + } } /// Request DTO for `inspect_conversation` (T7). @@ -5751,7 +5798,7 @@ mod ls6_pagination_tests { turns: vec![ TurnView { id: tid(1), - at_ms: 1_700_000_000_000, + at_ms: Some(1_700_000_000_000), role: TurnRole::Prompt, source: TurnSource::Human, text: big.clone(), @@ -5759,7 +5806,7 @@ mod ls6_pagination_tests { }, TurnView { id: tid(2), - at_ms: 1_700_000_000_001, + at_ms: Some(1_700_000_000_001), role: TurnRole::Response, source: TurnSource::Agent { agent_id: domain::AgentId::from_uuid(uuid::Uuid::from_u128(7)), @@ -5822,7 +5869,8 @@ mod ls6_pagination_tests { // --------------------------------------------------------------------------- use domain::{ - BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskState, TaskId, + BackgroundTask, BackgroundTaskKind, BackgroundTaskOrigin, BackgroundTaskResult, + BackgroundTaskState, TaskId, }; /// One first-class background task as seen by the frontend (camelCase wire shape). @@ -5846,6 +5894,8 @@ pub struct BackgroundTaskDto { /// Kind discriminant (`"command"`, `"headlessRendezvous"`, `"sessionResume"`, /// `"maintenance"`). pub kind: String, + /// Manual launch or scheduled occurrence provenance. + pub origin: BackgroundTaskOrigin, /// Lifecycle state (`"queued"`, `"running"`, `"waiting"`, `"completed"`, /// `"failed"`, `"cancelled"`, `"expired"`). pub state: String, @@ -5955,6 +6005,7 @@ impl From for BackgroundTaskDto { project_id: task.project_id.to_string(), parent_task_id: task.parent_task_id.map(|id| id.to_string()), kind: background_kind_label(&task.kind).to_owned(), + origin: task.origin.clone(), state: background_state_label(task.state).to_owned(), exit_code, summary, @@ -6000,6 +6051,55 @@ pub fn parse_task_id(raw: &str) -> Result { }) } +/// Parses a durable scheduled-task id. +pub fn parse_scheduled_task_id(raw: &str) -> Result { + uuid::Uuid::parse_str(raw) + .map(domain::ScheduledTaskId::from_uuid) + .map_err(|_| ErrorDto { + code: "INVALID".to_owned(), + message: format!("invalid scheduled task id: {raw}"), + }) +} + +/// Stable scheduled task response; domain serialization already uses camelCase. +pub type ScheduledProjectTaskDto = domain::ScheduledProjectTask; + +/// Create request shared by Tauri and HTTP. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateScheduledProjectTaskRequestDto { + pub project_id: String, + pub name: String, + #[serde(default)] + pub description: Option, + pub command: domain::ScheduledTaskCommand, + pub schedule: domain::ScheduledTaskSchedule, + pub timezone: String, + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub timeout_ms: Option, +} + +/// Full replacement request with optimistic audit revision. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateScheduledProjectTaskRequestDto { + pub project_id: String, + pub scheduled_task_id: String, + pub name: String, + #[serde(default)] + pub description: Option, + pub command: domain::ScheduledTaskCommand, + pub schedule: domain::ScheduledTaskSchedule, + pub timezone: String, + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub timeout_ms: Option, + pub audit_revision: u64, +} + /// Request DTO for `spawn_background_command`. /// /// Builds a command-backed [`BackgroundTask`]: the command line runs under the diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 925f4766..49f3f7fc 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -55,17 +55,18 @@ use application::{ RestoreOpenProjectsOutput, RestoreOpenWindows, RetryBackgroundTask, RetryOpenProjectRestore, ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveChatDraft, SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveOpenProjectRestoreOrder, - SaveProfile, SessionLimitService, SetActiveLayout, SetAgentMcpToolAllowed, SetPluginEnabled, - SetProjectPluginEnabled, SlashCommandRegistry, SnapshotOpenWindows, SnapshotRunningAgents, - SnapshotRunningAgentsInput, SpawnBackgroundCommand, StopAgentRuntime, StructuredRoutingMode, - StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice, - UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, - UpdateAgentContactable, UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, - UpdateAgentPermissions, UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, - UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectMcpToolPermissions, - UpdateProjectOrchestrator, UpdateProjectPermissions, UpdateProjectSettings, - UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, UpdateUserSettings, - WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, + SaveProfile, ScheduledProjectTaskService, SessionLimitService, SetActiveLayout, + SetAgentMcpToolAllowed, SetPluginEnabled, SetProjectPluginEnabled, SlashCommandRegistry, + SnapshotOpenWindows, SnapshotRunningAgents, SnapshotRunningAgentsInput, SpawnBackgroundCommand, + StopAgentRuntime, StructuredRoutingMode, StructuredSessions, SuggestedThisSession, + SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent, + UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContactable, + UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, UpdateAgentPermissions, + UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, + UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectOrchestrator, + UpdateProjectPermissions, UpdateProjectSettings, UpdateProjectSystemPermissions, UpdateSkill, + UpdateTemplate, UpdateUserSettings, WakeSessionProvider, WriteMemory, WriteToTerminal, + AGENT_MEMORY_RECALL_BUDGET, }; use async_trait::async_trait; use domain::events::AgentRestartPhase; @@ -107,9 +108,9 @@ use infrastructure::{ FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, FsOpenProjectRestoreStore, FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore, FsProfileStore, FsProjectPluginSettingsStore, FsProjectSettingsStore, - FsProjectStore, FsProviderSessionStore, FsSecretStore, FsSkillStore, FsSprintStore, - FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore, FsWindowStateStore, - Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader, + FsProjectStore, FsProviderSessionStore, FsScheduledProjectTaskStore, FsSecretStore, + FsSkillStore, FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore, + FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, @@ -2165,6 +2166,8 @@ pub struct BackendCore { pub background_command_runner: Arc, /// Store handle used by `list_background_tasks` to read the task read-model. pub background_task_store: Arc, + /// Durable definitions plus their runtime one-shot reconciliation. + pub scheduled_project_tasks: Arc, // --- Plugins (#43) --- /// Review a local plugin package before install. pub review_plugin_package: Arc, @@ -2470,6 +2473,9 @@ impl BackendCore { Arc::clone(&fs) as Arc, app_data_dir.to_string_lossy().into_owned(), )); + let scheduled_task_store = Arc::new(FsScheduledProjectTaskStore::new(&app_data_dir)); + let scheduled_task_store_port = + Arc::clone(&scheduled_task_store) as Arc; let pair_attempt_limiter = Arc::new(InMemoryPairAttemptLimiter::new( Arc::clone(&clock) as Arc )); @@ -3945,10 +3951,19 @@ impl BackendCore { }) as Arc; let session_limit_service = Arc::new(SessionLimitService::new( Arc::clone(&clock) as Arc, - scheduler, + Arc::clone(&scheduler), Arc::clone(&events_port), resumer, )); + let scheduled_project_tasks = Arc::new(ScheduledProjectTaskService::new( + Arc::clone(&scheduled_task_store_port), + Arc::clone(&store_port), + Arc::clone(&fs_port), + Arc::clone(&scheduler), + Arc::clone(&spawn_background_command), + Arc::clone(&clock) as Arc, + Arc::clone(&ids) as Arc, + )); let restart_rendezvous = Arc::new(application::RestartRendezvousCoordinator::new()); let self_restart = Arc::new(AppAgentSelfRestarter { launch_agent: Arc::clone(&launch_agent), @@ -3967,9 +3982,24 @@ impl BackendCore { // peut tourner sans runtime ambiant). Detache ⇒ vit autant que l'app. { let service = Arc::clone(&session_limit_service); + let scheduled = Arc::clone(&scheduled_project_tasks); spawn_detached(async move { while let Some(task) = resume_rx.recv().await { - if let Err(e) = service.execute_resume(task).await { + let result = match task { + task @ ScheduledTask::ResumeAgent { .. } => { + service.execute_resume(task).await + } + ScheduledTask::FireProjectTask { + project_id, + scheduled_task_id, + planned_at_ms, + } => { + scheduled + .fire(project_id, scheduled_task_id, planned_at_ms) + .await + } + }; + if let Err(e) = result { // Best-effort : une relance qui échoue ne fige pas le drain. eprintln!("[session-limit] reprise auto échouée : {e}"); } @@ -4129,7 +4159,8 @@ impl BackendCore { .with_session_limits(Arc::clone(&session_limit_service)) // Producteur B8 côté agent MCP : `idea_run_in_background` passe par le // même use case que la commande desktop, sans aller-retour frontend. - .with_spawn_background_command(Arc::clone(&spawn_background_command)), + .with_spawn_background_command(Arc::clone(&spawn_background_command)) + .with_scheduled_project_tasks(Arc::clone(&scheduled_project_tasks)), ); openai_tool_invoker.bind(Arc::new(AppOpenAiToolInvoker::new( Arc::clone(&orchestrator_service), @@ -4375,6 +4406,7 @@ impl BackendCore { retry_background_task, background_command_runner: Arc::clone(&background_runner), background_task_store: Arc::clone(&background_tasks_port), + scheduled_project_tasks, review_plugin_package, install_plugin_from_archive, install_plugin_from_directory, @@ -4435,6 +4467,10 @@ impl BackendCore { .execute(application::ReconcileLiveStateInput { project_id }) .await; let _ = self.reconcile_background_tasks.execute(project_id).await; + let _ = self + .scheduled_project_tasks + .reconcile_project(project_id) + .await; self.ensure_orchestrator_watch(project); self.reconcile_claude_run_dirs(project).await; } @@ -4708,6 +4744,7 @@ impl BackendCore { /// Symmetrically stops the project's MCP server (its twin) so both entry doors /// are torn down together. pub fn stop_orchestrator_watch(&self, project_id: &ProjectId) { + self.scheduled_project_tasks.cancel_project(*project_id); if let Some(handle) = self .orchestrator_watchers .lock() @@ -8797,3 +8834,107 @@ mod open_project_restore_tests { let _ = std::fs::remove_dir_all(&root); } } + +#[cfg(test)] +mod ticket305_scheduled_task_tests { + use super::BackendCore; + use application::CreateProjectInput; + use domain::{ + AgentId, OrchestratorCommand, ScheduledTaskApproval, ScheduledTaskCommand, + ScheduledTaskSchedule, + }; + use uuid::Uuid; + + #[tokio::test] + async fn agent_request_is_machine_local_pending_and_cannot_be_enabled_by_agent() { + let app_data = std::env::temp_dir().join(format!("idea-ticket305-app-{}", Uuid::new_v4())); + let root = std::env::temp_dir().join(format!("idea-ticket305-project-{}", Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + let core = BackendCore::build(app_data.clone()); + let project = core + .create_project + .execute(CreateProjectInput { + name: "scheduled".to_owned(), + root: root.to_string_lossy().into_owned(), + remote: None, + default_profile_id: None, + }) + .await + .unwrap() + .project; + let requester = AgentId::new_random(); + let outcome = core + .orchestrator_service + .dispatch( + &project, + OrchestratorCommand::CreateScheduledTask { + requester, + name: "cleanup".to_owned(), + description: Some("remove generated files".to_owned()), + command: ScheduledTaskCommand { + command: "find".to_owned(), + args: vec!["build".to_owned()], + cwd: Some(".".to_owned()), + }, + schedule: ScheduledTaskSchedule::Interval { every_minutes: 15 }, + timezone: "UTC".to_owned(), + timeout_ms: Some(60_000), + }, + ) + .await + .unwrap(); + let task: domain::ScheduledProjectTask = + serde_json::from_str(outcome.reply.as_deref().unwrap()).unwrap(); + assert_eq!(task.approval, ScheduledTaskApproval::Pending); + assert!(!task.enabled); + assert!(task.next_run_at_ms.is_none()); + assert!(app_data + .join("scheduled-project-tasks") + .join(format!("{}.json", project.id)) + .is_file()); + let error = core + .scheduled_project_tasks + .set_enabled( + project.id, + task.id, + true, + domain::ScheduledTaskActor::Agent { + agent_id: requester, + }, + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("only a human can approve")); + + let approved = core + .scheduled_project_tasks + .set_enabled(project.id, task.id, true, domain::ScheduledTaskActor::Human) + .await + .unwrap(); + let planned_at_ms = approved.next_run_at_ms.unwrap(); + core.scheduled_project_tasks + .fire(project.id, task.id, planned_at_ms) + .await + .unwrap(); + let fired = core + .scheduled_project_tasks + .get(project.id, task.id) + .await + .unwrap(); + let concrete_task = core + .background_task_store + .get(fired.last_task_id.unwrap()) + .await + .unwrap() + .unwrap(); + assert!(matches!( + concrete_task.origin, + domain::BackgroundTaskOrigin::Scheduled { + scheduled_task_id, + planned_at_ms: occurrence, + } if scheduled_task_id == task.id && occurrence == planned_at_ms + )); + let _ = tokio::fs::remove_dir_all(app_data).await; + let _ = tokio::fs::remove_dir_all(root).await; + } +} diff --git a/crates/domain/src/background_task.rs b/crates/domain/src/background_task.rs index 49a1a485..1f44efc9 100644 --- a/crates/domain/src/background_task.rs +++ b/crates/domain/src/background_task.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::conversation::ConversationId; -use crate::ids::{AgentId, ProjectId, RendezvousId, ScheduleId, TaskId}; +use crate::ids::{AgentId, ProjectId, RendezvousId, ScheduleId, ScheduledTaskId, TaskId}; use crate::mailbox::TicketId; /// Maximum length for human-facing task labels. @@ -63,6 +63,26 @@ pub enum BackgroundTaskWakePolicy { RecordOnly, } +/// Provenance of one concrete run. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde( + rename_all = "camelCase", + rename_all_fields = "camelCase", + tag = "kind" +)] +pub enum BackgroundTaskOrigin { + /// Direct/manual launch or legacy task. + #[default] + Manual, + /// Occurrence of a durable scheduled definition. + Scheduled { + /// Durable definition that produced this run. + scheduled_task_id: ScheduledTaskId, + /// Persisted occurrence timestamp represented by this run. + planned_at_ms: u64, + }, +} + /// Structural link from a background task to a composite inter-agent rendezvous. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -137,6 +157,9 @@ pub struct BackgroundTask { pub state: BackgroundTaskState, /// Completion wake policy. pub wake_policy: BackgroundTaskWakePolicy, + /// Manual launch or scheduled occurrence provenance. + #[serde(default)] + pub origin: BackgroundTaskOrigin, /// Creation timestamp, epoch milliseconds. pub created_at_ms: u64, /// Last update timestamp, epoch milliseconds. @@ -173,6 +196,7 @@ impl BackgroundTask { parent_task_id: None, state: BackgroundTaskState::Queued, wake_policy, + origin: BackgroundTaskOrigin::Manual, created_at_ms: now_ms, updated_at_ms: now_ms, deadline_ms, @@ -183,6 +207,13 @@ impl BackgroundTask { Ok(task) } + /// Attaches immutable run provenance before persistence. + #[must_use] + pub fn with_origin(mut self, origin: BackgroundTaskOrigin) -> Self { + self.origin = origin; + self + } + /// Returns a copy linked to a composite inter-agent rendezvous. #[must_use] pub fn with_rendezvous(mut self, link: BackgroundTaskRendezvousLink) -> Self { diff --git a/crates/domain/src/ids.rs b/crates/domain/src/ids.rs index 0fcaabf7..977f04df 100644 --- a/crates/domain/src/ids.rs +++ b/crates/domain/src/ids.rs @@ -111,6 +111,10 @@ typed_id!( /// [`crate::ports::Scheduler::cancel`]. ScheduleId ); +typed_id!( + /// Identifies a durable project-scoped scheduled task definition. + ScheduledTaskId +); typed_id!( /// Identifies a first-class background task. TaskId diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 319c72f9..b63f7d39 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -66,6 +66,7 @@ pub mod project_restore; pub mod readiness; pub mod remote; pub mod sandbox; +pub mod scheduled_task; pub mod session_limit; pub mod settings; pub mod skill; @@ -86,7 +87,8 @@ pub use events::RendezvousContext; pub use ids::{ AgentId, IssueId, LayoutId, LocalModelServerId, NodeId, ProfileId, ProjectId, RendezvousId, - RuntimeAgentKey, ScheduleId, SessionId, SkillId, SprintId, TabId, TaskId, TemplateId, WindowId, + RuntimeAgentKey, ScheduleId, ScheduledTaskId, SessionId, SkillId, SprintId, TabId, TaskId, + TemplateId, WindowId, }; pub use project::{Project, ProjectPath}; @@ -105,10 +107,10 @@ pub use mcp_tool_permissions::{ }; pub use background_task::{ - BackgroundTask, BackgroundTaskError, BackgroundTaskKind, BackgroundTaskRendezvousLink, - BackgroundTaskResult, BackgroundTaskState, BackgroundTaskWakePolicy, - BACKGROUND_TASK_LABEL_MAX_CHARS, BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES, - BACKGROUND_TASK_TEXT_MAX_BYTES, + BackgroundTask, BackgroundTaskError, BackgroundTaskKind, BackgroundTaskOrigin, + BackgroundTaskRendezvousLink, BackgroundTaskResult, BackgroundTaskState, + BackgroundTaskWakePolicy, BACKGROUND_TASK_LABEL_MAX_CHARS, + BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES, BACKGROUND_TASK_TEXT_MAX_BYTES, }; pub use chat_attachment::{ @@ -259,6 +261,12 @@ pub use sandbox::{ SandboxKind, SandboxPlan, SandboxStatus, }; +pub use scheduled_task::{ + ScheduledProjectTask, ScheduledProjectTaskError, ScheduledTaskActor, ScheduledTaskApproval, + ScheduledTaskAuditEntry, ScheduledTaskCommand, ScheduledTaskSandbox, ScheduledTaskSchedule, + Weekday, MIN_SCHEDULE_INTERVAL_MINUTES, +}; + pub use settings::{DefaultAgentTerminalInterface, IdeaUserSettings, ProjectSettings, ThemeColor}; pub use orchestrator::{ @@ -282,8 +290,8 @@ pub use ports::{ PluginStorageStore, PluginStoreError, PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectPluginSettingsStore, ProjectSettingsStore, ProjectStore, ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, - RuntimeError, RuntimePermissionProbe, ScheduledTask, Scheduler, SpawnSpec, SprintStore, - SprintStoreError, StoreError, StructuredSessionEnvironment, - StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, UserSettingsStore, - WindowStateStore, + RuntimeError, RuntimePermissionProbe, ScheduledProjectTaskStore, ScheduledTask, + ScheduledTaskStoreError, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError, + StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, SystemPermissionStore, + TemplateStore, UserSettingsStore, WindowStateStore, }; diff --git a/crates/domain/src/orchestrator.rs b/crates/domain/src/orchestrator.rs index 328940bf..3912ef78 100644 --- a/crates/domain/src/orchestrator.rs +++ b/crates/domain/src/orchestrator.rs @@ -14,10 +14,11 @@ use serde::{Deserialize, Serialize}; use crate::conversation::ConversationParty; -use crate::ids::{AgentId, NodeId, SkillId}; +use crate::ids::{AgentId, NodeId, ScheduledTaskId, SkillId}; use crate::live_state::WorkStatus; use crate::mailbox::TicketId; use crate::plugin::{PluginId, PluginSkillId}; +use crate::scheduled_task::{ScheduledTaskCommand, ScheduledTaskSchedule}; use crate::skill::{SkillKind, SkillScope}; /// Plugin origin for a resolved assigned skill. `None` on @@ -252,6 +253,27 @@ pub struct OrchestratorRequest { /// Optional absolute deadline for `background.run`, epoch milliseconds. #[serde(default, skip_serializing_if = "Option::is_none")] pub deadline_ms: Option, + /// Durable scheduled-task id for scheduled-task read/update/action tools. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scheduled_task_id: Option, + /// Optional description for scheduled-task create/update. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Bounded recurring schedule for scheduled-task create/update. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub schedule: Option, + /// IANA timezone for scheduled-task create/update. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timezone: Option, + /// Desired enabled state. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + /// Per-run timeout in milliseconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option, + /// Optimistic audit revision for scheduled-task updates. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub audit_revision: Option, } /// A validated orchestrator command — the only thing the application layer acts on. @@ -454,6 +476,74 @@ pub enum OrchestratorCommand { /// Optional absolute deadline, epoch milliseconds. deadline_ms: Option, }, + /// List the current project's durable schedule definitions. + ListScheduledTasks, + /// Read one current-project schedule definition. + ReadScheduledTask { + /// Definition to read. + scheduled_task_id: ScheduledTaskId, + }, + /// Request a schedule definition. Agent requests are approval-pending. + CreateScheduledTask { + /// Requesting agent from the authenticated handshake. + requester: AgentId, + /// Display name. + name: String, + /// Optional human review context. + description: Option, + /// Exact invocation without a free-form environment. + command: ScheduledTaskCommand, + /// Bounded recurring schedule. + schedule: ScheduledTaskSchedule, + /// IANA timezone. + timezone: String, + /// Optional per-run deadline duration. + timeout_ms: Option, + }, + /// Replace a definition; agent updates revoke approval. + UpdateScheduledTask { + /// Requesting agent from the authenticated handshake. + requester: AgentId, + /// Definition to replace. + scheduled_task_id: ScheduledTaskId, + /// Display name. + name: String, + /// Optional human review context. + description: Option, + /// Exact invocation without a free-form environment. + command: ScheduledTaskCommand, + /// Bounded recurring schedule. + schedule: ScheduledTaskSchedule, + /// IANA timezone. + timezone: String, + /// Optional per-run deadline duration. + timeout_ms: Option, + /// Expected revision for optimistic concurrency. + audit_revision: u64, + }, + /// Delete a definition without cancelling an already-running run. + DeleteScheduledTask { + /// Requesting agent from the authenticated handshake. + requester: AgentId, + /// Definition to delete. + scheduled_task_id: ScheduledTaskId, + }, + /// Run an approved definition now. + RunScheduledTaskNow { + /// Requesting agent from the authenticated handshake. + requester: AgentId, + /// Definition to run. + scheduled_task_id: ScheduledTaskId, + }, + /// Enable/disable an approved definition. MCP cannot grant initial approval. + SetScheduledTaskEnabled { + /// Requesting agent from the authenticated handshake. + requester: AgentId, + /// Definition to mutate. + scheduled_task_id: ScheduledTaskId, + /// Requested enabled state. + enabled: bool, + }, } /// Where IdeA should place a launched agent session. @@ -593,6 +683,73 @@ impl OrchestratorRequest { .map(str::to_owned), deadline_ms: self.deadline_ms, }), + "scheduled_task.list" => Ok(OrchestratorCommand::ListScheduledTasks), + "scheduled_task.read" => Ok(OrchestratorCommand::ReadScheduledTask { + scheduled_task_id: self.require_scheduled_task_id(action)?, + }), + "scheduled_task.create" => Ok(OrchestratorCommand::CreateScheduledTask { + requester: self.require_from(action)?, + name: self.require_name(action)?, + description: self.description.clone(), + command: ScheduledTaskCommand { + command: self.require("command", action, self.command.as_deref())?, + args: self.args.clone(), + cwd: self.cwd.clone(), + }, + schedule: self + .schedule + .clone() + .ok_or_else(|| OrchestratorError::MissingField { + action: action.to_owned(), + field: "schedule".to_owned(), + })?, + timezone: self.require("timezone", action, self.timezone.as_deref())?, + timeout_ms: self.timeout_ms, + }), + "scheduled_task.update" => Ok(OrchestratorCommand::UpdateScheduledTask { + requester: self.require_from(action)?, + scheduled_task_id: self.require_scheduled_task_id(action)?, + name: self.require_name(action)?, + description: self.description.clone(), + command: ScheduledTaskCommand { + command: self.require("command", action, self.command.as_deref())?, + args: self.args.clone(), + cwd: self.cwd.clone(), + }, + schedule: self + .schedule + .clone() + .ok_or_else(|| OrchestratorError::MissingField { + action: action.to_owned(), + field: "schedule".to_owned(), + })?, + timezone: self.require("timezone", action, self.timezone.as_deref())?, + timeout_ms: self.timeout_ms, + audit_revision: self.audit_revision.ok_or_else(|| { + OrchestratorError::MissingField { + action: action.to_owned(), + field: "auditRevision".to_owned(), + } + })?, + }), + "scheduled_task.delete" => Ok(OrchestratorCommand::DeleteScheduledTask { + requester: self.require_from(action)?, + scheduled_task_id: self.require_scheduled_task_id(action)?, + }), + "scheduled_task.run_now" => Ok(OrchestratorCommand::RunScheduledTaskNow { + requester: self.require_from(action)?, + scheduled_task_id: self.require_scheduled_task_id(action)?, + }), + "scheduled_task.set_enabled" => Ok(OrchestratorCommand::SetScheduledTaskEnabled { + requester: self.require_from(action)?, + scheduled_task_id: self.require_scheduled_task_id(action)?, + enabled: self + .enabled + .ok_or_else(|| OrchestratorError::MissingField { + action: action.to_owned(), + field: "enabled".to_owned(), + })?, + }), "list_agents" | "agent.list" => Ok(OrchestratorCommand::ListAgents), "create_skill" | "skill.create" => Ok(OrchestratorCommand::CreateSkill { name: self.require_name(action)?, @@ -603,6 +760,19 @@ impl OrchestratorRequest { } } + fn require_scheduled_task_id( + &self, + action: &str, + ) -> Result { + let value = self.require("scheduledTaskId", action, self.scheduled_task_id.as_deref())?; + uuid::Uuid::parse_str(&value) + .map(ScheduledTaskId::from_uuid) + .map_err(|_| OrchestratorError::MissingField { + action: action.to_owned(), + field: "valid scheduledTaskId".to_owned(), + }) + } + /// Parses the optional `scope` field into a [`SkillScope`], defaulting to /// [`SkillScope::Project`] when absent or empty. /// diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 1956d379..3ea0229c 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -1037,6 +1037,16 @@ pub enum ScheduledTask { /// id, comme le reste du chemin de reprise §15). conversation_id: Option, }, + /// Fire a durable project task. The application re-reads the definition + /// before execution so stale one-shot payloads cannot bypass updates. + FireProjectTask { + /// Owning project. + project_id: ProjectId, + /// Durable definition id. + scheduled_task_id: crate::ScheduledTaskId, + /// Occurrence this wake-up represents. + planned_at_ms: u64, + }, } // --------------------------------------------------------------------------- @@ -1272,6 +1282,20 @@ pub enum BackgroundTaskPortError { Store(String), } +/// Errors from the machine-local scheduled task store. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ScheduledTaskStoreError { + /// Requested definition is absent. + #[error("scheduled project task not found")] + NotFound, + /// Persisted or supplied data is invalid. + #[error("scheduled project task invalid: {0}")] + Invalid(String), + /// Persistence failed. + #[error("scheduled project task store failed: {0}")] + Store(String), +} + /// Errors from the owner wake port. #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum WakeError { @@ -2790,6 +2814,40 @@ pub trait BackgroundTaskStore: Send + Sync { ) -> Result<(), BackgroundTaskPortError>; } +/// Persistence port for durable project-scoped schedule definitions and audit. +#[async_trait] +pub trait ScheduledProjectTaskStore: Send + Sync { + /// Lists one project's definitions. + async fn list( + &self, + project_id: ProjectId, + ) -> Result, ScheduledTaskStoreError>; + /// Reads one definition. + async fn get( + &self, + project_id: ProjectId, + scheduled_task_id: crate::ScheduledTaskId, + ) -> Result, ScheduledTaskStoreError>; + /// Upserts a definition and appends its audit entry atomically. + async fn save( + &self, + task: &crate::ScheduledProjectTask, + audit: crate::ScheduledTaskAuditEntry, + ) -> Result<(), ScheduledTaskStoreError>; + /// Deletes a definition and appends its audit entry atomically. + async fn delete( + &self, + project_id: ProjectId, + scheduled_task_id: crate::ScheduledTaskId, + audit: crate::ScheduledTaskAuditEntry, + ) -> Result<(), ScheduledTaskStoreError>; + /// Lists enabled, approved definitions whose persisted deadline is due. + async fn list_due( + &self, + now_ms: u64, + ) -> Result, ScheduledTaskStoreError>; +} + /// Execution port for first-class background tasks. #[async_trait] pub trait BackgroundTaskRunner: Send + Sync { diff --git a/crates/domain/src/scheduled_task.rs b/crates/domain/src/scheduled_task.rs new file mode 100644 index 00000000..cc53a8a2 --- /dev/null +++ b/crates/domain/src/scheduled_task.rs @@ -0,0 +1,288 @@ +//! Durable, project-scoped scheduled task definitions. + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{AgentId, ProjectId, ScheduledTaskId, TaskId}; + +/// Lowest interval accepted by the V1 scheduler. +pub const MIN_SCHEDULE_INTERVAL_MINUTES: u32 = 5; +/// Maximum command/name/description lengths keep the machine-local document bounded. +pub const SCHEDULED_TASK_NAME_MAX_CHARS: usize = 160; +/// Maximum size accepted for each persisted free-text or command value. +pub const SCHEDULED_TASK_TEXT_MAX_BYTES: usize = 64 * 1024; + +/// Day names used by weekly schedules, independent from locale. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Weekday { + /// Monday. + Monday, + /// Tuesday. + Tuesday, + /// Wednesday. + Wednesday, + /// Thursday. + Thursday, + /// Friday. + Friday, + /// Saturday. + Saturday, + /// Sunday. + Sunday, +} + +/// V1 bounded schedule. Free-form cron expressions are deliberately absent. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + rename_all = "camelCase", + rename_all_fields = "camelCase", + tag = "kind" +)] +pub enum ScheduledTaskSchedule { + /// Recur after a fixed number of minutes. + Interval { + /// Interval length, subject to [`MIN_SCHEDULE_INTERVAL_MINUTES`]. + every_minutes: u32, + }, + /// Recur once each local calendar day. + Daily { + /// Local wall-clock time formatted as `HH:mm`. + time: String, + }, + /// Recur on selected local weekdays. + Weekly { + /// Non-empty set of distinct weekdays. + days: Vec, + /// Local wall-clock time formatted as `HH:mm`. + time: String, + }, +} + +impl ScheduledTaskSchedule { + /// Validates the bounded schedule syntax. + pub fn validate(&self) -> Result<(), ScheduledProjectTaskError> { + match self { + Self::Interval { every_minutes } if *every_minutes < MIN_SCHEDULE_INTERVAL_MINUTES => { + Err(ScheduledProjectTaskError::IntervalTooShort) + } + Self::Interval { .. } => Ok(()), + Self::Daily { time } => validate_time(time), + Self::Weekly { days, time } => { + validate_time(time)?; + if days.is_empty() { + return Err(ScheduledProjectTaskError::EmptyWeekdays); + } + let mut unique = std::collections::HashSet::new(); + if !days.iter().all(|day| unique.insert(*day)) { + return Err(ScheduledProjectTaskError::DuplicateWeekday); + } + Ok(()) + } + } + } +} + +fn validate_time(value: &str) -> Result<(), ScheduledProjectTaskError> { + let Some((hour, minute)) = value.split_once(':') else { + return Err(ScheduledProjectTaskError::InvalidTime(value.to_owned())); + }; + if hour.len() != 2 + || minute.len() != 2 + || hour.parse::().map_or(true, |h| h > 23) + || minute.parse::().map_or(true, |m| m > 59) + { + return Err(ScheduledProjectTaskError::InvalidTime(value.to_owned())); + } + Ok(()) +} + +/// Exact invocation persisted for a scheduled task. Environment injection is +/// intentionally absent in V1. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScheduledTaskCommand { + /// Executable passed directly to the background runner. + pub command: String, + /// Literal argument vector; no shell interpolation is performed here. + #[serde(default)] + pub args: Vec, + /// Project-relative working directory (`.` by default). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option, +} + +/// Actor that created or last updated a schedule. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + rename_all = "camelCase", + rename_all_fields = "camelCase", + tag = "kind" +)] +pub enum ScheduledTaskActor { + /// A human used a trusted application surface. + Human, + /// An agent used its requester-scoped MCP surface. + Agent { + /// Requesting agent identity. + agent_id: AgentId, + }, + /// An internal scheduler or reconciliation action. + System, +} + +/// Explicit approval state prevents agent-originated definitions from running silently. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ScheduledTaskApproval { + /// A human approved this exact definition. + Approved, + /// An agent-created or modified definition awaits human review. + Pending, + /// A human explicitly rejected the proposal. + Rejected, +} + +/// Fixed V1 permission profile: project files may be modified, while filesystem +/// access outside the project is denied except read/execute system toolchains. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum ScheduledTaskSandbox { + /// Confined automatic project maintenance. + #[default] + ProjectReadWrite, +} + +/// Durable recurring intent, distinct from every concrete [`crate::BackgroundTask`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScheduledProjectTask { + /// Stable definition identity. + pub id: ScheduledTaskId, + /// Owning project. + pub project_id: ProjectId, + /// Human-readable task name. + pub name: String, + /// Optional explanation shown during review. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Whether the next occurrence may be armed. + pub enabled: bool, + /// Human approval state for this exact revision. + pub approval: ScheduledTaskApproval, + /// Explicit, immutable V1 sandbox profile used by every run. + #[serde(default)] + pub sandbox: ScheduledTaskSandbox, + /// Exact command definition, with no free-form environment. + pub command: ScheduledTaskCommand, + /// Bounded recurring schedule. + pub schedule: ScheduledTaskSchedule, + /// IANA timezone used for daily and weekly schedules. + pub timezone: String, + /// Persisted next future occurrence, in Unix milliseconds. + pub next_run_at_ms: Option, + /// Time the most recent concrete run was created. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_run_at_ms: Option, + /// Most recently created background task, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_task_id: Option, + /// Creator of the durable definition. + pub created_by: ScheduledTaskActor, + /// Actor responsible for its current revision. + pub updated_by: ScheduledTaskActor, + /// Creation time in Unix milliseconds. + pub created_at_ms: u64, + /// Last mutation time in Unix milliseconds. + pub updated_at_ms: u64, + /// Optimistic concurrency and audit sequence. + pub audit_revision: u64, + /// Hard execution limit for each concrete run. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option, +} + +impl ScheduledProjectTask { + /// Validates domain invariants independent of persistence and runtime I/O. + pub fn validate(&self) -> Result<(), ScheduledProjectTaskError> { + let name = self.name.trim(); + if name.is_empty() || name.chars().count() > SCHEDULED_TASK_NAME_MAX_CHARS { + return Err(ScheduledProjectTaskError::InvalidName); + } + if self.command.command.trim().is_empty() { + return Err(ScheduledProjectTaskError::EmptyCommand); + } + if self.command.command.len() > SCHEDULED_TASK_TEXT_MAX_BYTES + || self + .command + .args + .iter() + .any(|arg| arg.len() > SCHEDULED_TASK_TEXT_MAX_BYTES) + || self + .description + .as_ref() + .is_some_and(|v| v.len() > SCHEDULED_TASK_TEXT_MAX_BYTES) + { + return Err(ScheduledProjectTaskError::TextTooLong); + } + if self.timezone.trim().is_empty() { + return Err(ScheduledProjectTaskError::InvalidTimezone); + } + if self.enabled && self.approval != ScheduledTaskApproval::Approved { + return Err(ScheduledProjectTaskError::ApprovalRequired); + } + self.schedule.validate() + } +} + +/// One append-only audit record stored beside the project definitions. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScheduledTaskAuditEntry { + /// Monotonic definition revision associated with the action. + pub revision: u64, + /// Owning project. + pub project_id: ProjectId, + /// Definition affected by the action. + pub scheduled_task_id: ScheduledTaskId, + /// Actor responsible for the action. + pub actor: ScheduledTaskActor, + /// Stable action key. + pub action: String, + /// Human-readable outcome summary. + pub summary: String, + /// Action time in Unix milliseconds. + pub at_ms: u64, +} + +/// Invalid scheduled task definition. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ScheduledProjectTaskError { + /// Name is empty or exceeds the length limit. + #[error("scheduled task name must be non-empty and at most 160 characters")] + InvalidName, + /// Executable is empty. + #[error("scheduled task command must not be empty")] + EmptyCommand, + /// One persisted text value exceeds its size limit. + #[error("scheduled task text is too long")] + TextTooLong, + /// Interval is below the V1 minimum. + #[error("scheduled task interval must be at least 5 minutes")] + IntervalTooShort, + /// Weekly schedule does not select any day. + #[error("weekly schedule must contain at least one day")] + EmptyWeekdays, + /// Weekly schedule repeats a day. + #[error("weekly schedule contains a duplicate day")] + DuplicateWeekday, + /// Daily or weekly wall-clock time is not valid `HH:mm`. + #[error("invalid HH:mm schedule time `{0}`")] + InvalidTime(String), + /// Timezone is absent or invalid. + #[error("timezone must be a valid non-empty IANA timezone")] + InvalidTimezone, + /// Enabled definition has not been approved by a human. + #[error("scheduled task requires human approval before it can be enabled")] + ApprovalRequired, +} diff --git a/crates/infrastructure/src/background_task/sink.rs b/crates/infrastructure/src/background_task/sink.rs index 022ec54b..825442ac 100644 --- a/crates/infrastructure/src/background_task/sink.rs +++ b/crates/infrastructure/src/background_task/sink.rs @@ -3,8 +3,9 @@ //! The sink is the durable boundary between a runner completion and later //! mailbox/wake work: it writes the terminal task state to the //! [`BackgroundTaskStore`](domain::ports::BackgroundTaskStore) first, then emits -//! a lightweight "ready to deliver" signal. It never wakes an agent and never -//! enqueues mailbox items; B4/B5 consume the ready signal. +//! a lightweight "ready to deliver" signal only for `WakeOwner` tasks. +//! `RecordOnly` completions are durably marked delivered without a signal. The +//! sink never wakes an agent or enqueues mailbox items; B4/B5 consume signals. use std::collections::HashSet; use std::sync::Arc; @@ -36,6 +37,11 @@ pub struct BackgroundTaskReadyToDeliver { pub enum BackgroundCompletionSinkOutcome { /// Completion was persisted and signalled as ready to deliver. PersistedAndSignalled(BackgroundTaskReadyToDeliver), + /// Completion was persisted silently according to the `RecordOnly` policy. + PersistedRecordOnly { + /// Persisted task id. + task_id: TaskId, + }, /// Task was already terminal, so the duplicate completion was ignored. IgnoredAlreadyTerminal { /// Ignored task id. @@ -64,7 +70,7 @@ pub enum BackgroundCompletionSinkError { ReadySignalClosed, } -/// Consumes runner completions, persists terminal state, then signals delivery. +/// Consumes runner completions, persists terminal state, then applies wake policy. pub struct BackgroundCompletionSink { store: Arc, ready: UnboundedSender, @@ -154,9 +160,20 @@ impl BackgroundCompletionSink { }); } - let terminal = task.complete(completion.result).map_err(|e| { + let mut terminal = task.complete(completion.result).map_err(|e| { BackgroundCompletionSinkError::Store(BackgroundTaskPortError::Invalid(e.to_string())) })?; + if terminal.wake_policy == domain::BackgroundTaskWakePolicy::RecordOnly { + terminal = terminal.mark_completion_delivered().map_err(|e| { + BackgroundCompletionSinkError::Store(BackgroundTaskPortError::Invalid( + e.to_string(), + )) + })?; + self.store.save(&terminal).await?; + return Ok(BackgroundCompletionSinkOutcome::PersistedRecordOnly { + task_id: terminal.id, + }); + } self.store.save(&terminal).await?; let ready = BackgroundTaskReadyToDeliver { diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index 8e0f35d3..f9241099 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -120,9 +120,9 @@ pub use store::{ AdaptiveMemoryRecall, BackgroundTaskReconcileReport, EmbedderEnvProbe, FsBackgroundTaskStore, FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore, FsMcpToolPermissionStore, FsMemoryStore, FsOpenProjectRestoreStore, FsPermissionStore, - FsProfileStore, FsProjectSettingsStore, FsProjectStore, FsSecretStore, FsSkillStore, - FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore, FsWindowStateStore, - HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo, StubEmbedder, - VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, - VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, + FsProfileStore, FsProjectSettingsStore, FsProjectStore, FsScheduledProjectTaskStore, + FsSecretStore, FsSkillStore, FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore, + FsWindowStateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo, + StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, + RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, }; diff --git a/crates/infrastructure/src/orchestrator/mcp/server.rs b/crates/infrastructure/src/orchestrator/mcp/server.rs index 9d0524c4..9c043612 100644 --- a/crates/infrastructure/src/orchestrator/mcp/server.rs +++ b/crates/infrastructure/src/orchestrator/mcp/server.rs @@ -46,7 +46,7 @@ const MCP_PROTOCOL_VERSION: &str = "2024-11-05"; /// Compact MCP instructions advertised at initialization. The first 512 chars are /// intentionally self-contained because some clients truncate this field. -const MCP_INSTRUCTIONS: &str = "Use IdeA tools by intent: ticket work -> idea_ticket_*; ask/delegate to one agent -> idea_ask_agent, several -> idea_ask_agents; durable project facts -> idea_memory_*; project/agent context -> idea_context_*. Then use idea_skill_* for assigned workflows, idea_template_* for agent templates, idea_workstate_* for live status, idea_run_in_background/background tools for long commands, agent lifecycle tools to launch/stop/resume/swap agents, and sprint tools for planning."; +const MCP_INSTRUCTIONS: &str = "Use IdeA tools by intent: ticket work -> idea_ticket_*; ask/delegate to one agent -> idea_ask_agent, several -> idea_ask_agents; durable project facts -> idea_memory_*; project/agent context -> idea_context_*; recurring project maintenance -> idea_scheduled_task_* (never system cron). Then use idea_skill_* for assigned workflows, idea_template_* for agent templates, idea_workstate_* for live status, idea_run_in_background/background tools for long commands, agent lifecycle tools to launch/stop/resume/swap agents, and sprint tools for planning."; /// The IdeA MCP server: an entry adapter over [`OrchestratorService::dispatch`]. /// diff --git a/crates/infrastructure/src/orchestrator/mcp/tools.rs b/crates/infrastructure/src/orchestrator/mcp/tools.rs index 785cfb80..0c2ad0e0 100644 --- a/crates/infrastructure/src/orchestrator/mcp/tools.rs +++ b/crates/infrastructure/src/orchestrator/mcp/tools.rs @@ -61,6 +61,8 @@ pub const READ_ONLY_TOOLS: &[&str] = &[ "idea_template_list", "idea_template_read", "idea_managed_temp_resolve", + "idea_scheduled_task_list", + "idea_scheduled_task_read", ]; /// Canonical write/action MCP tools denied by default. @@ -99,6 +101,11 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[ "idea_managed_temp_gc", "idea_plugin_install_from_directory", "idea_plugin_reload", + "idea_scheduled_task_create", + "idea_scheduled_task_update", + "idea_scheduled_task_delete", + "idea_scheduled_task_run_now", + "idea_scheduled_task_set_enabled", ]; /// All MCP tool names that have an explicit access classification. @@ -158,6 +165,13 @@ pub fn tool_returns_reply(tool: &str) -> bool { | "idea_run_in_background" | "idea_restart_self" | "idea_workstate_read" + | "idea_scheduled_task_list" + | "idea_scheduled_task_read" + | "idea_scheduled_task_create" + | "idea_scheduled_task_update" + | "idea_scheduled_task_delete" + | "idea_scheduled_task_run_now" + | "idea_scheduled_task_set_enabled" ) || is_ticket_tool(tool) || is_template_tool(tool) || is_managed_temp_tool(tool) @@ -342,6 +356,41 @@ pub fn catalogue() -> Vec { "additionalProperties": false }), }, + ToolDef { + name: "idea_scheduled_task_list", + description: "List durable scheduled tasks for the current project, including approval and next/last run state.", + input_schema: json!({"type":"object","properties":{},"additionalProperties":false}), + }, + ToolDef { + name: "idea_scheduled_task_read", + description: "Read one durable scheduled task from the current project.", + input_schema: json!({"type":"object","properties":{"scheduledTaskId":{"type":"string","format":"uuid"}},"required":["scheduledTaskId"],"additionalProperties":false}), + }, + ToolDef { + name: "idea_scheduled_task_create", + description: "Request a recurring project command. Agent-created tasks are saved disabled and pending human approval; this tool never activates them silently.", + input_schema: scheduled_mutation_schema(false), + }, + ToolDef { + name: "idea_scheduled_task_update", + description: "Replace a scheduled task proposal. Agent updates disable it and require fresh human approval.", + input_schema: scheduled_mutation_schema(true), + }, + ToolDef { + name: "idea_scheduled_task_delete", + description: "Delete a scheduled definition. An already-running BackgroundTask is not interrupted.", + input_schema: json!({"type":"object","properties":{"scheduledTaskId":{"type":"string","format":"uuid"}},"required":["scheduledTaskId"],"additionalProperties":false}), + }, + ToolDef { + name: "idea_scheduled_task_run_now", + description: "Run an approved scheduled task now using its exact confined command and sandbox.", + input_schema: json!({"type":"object","properties":{"scheduledTaskId":{"type":"string","format":"uuid"}},"required":["scheduledTaskId"],"additionalProperties":false}), + }, + ToolDef { + name: "idea_scheduled_task_set_enabled", + description: "Suspend or resume an already-approved scheduled task. This cannot approve an agent proposal.", + input_schema: json!({"type":"object","properties":{"scheduledTaskId":{"type":"string","format":"uuid"},"enabled":{"type":"boolean"}},"required":["scheduledTaskId","enabled"],"additionalProperties":false}), + }, ToolDef { name: "idea_launch_agent", description: "Launch (or attach) an IdeA agent. Fire-and-forget: returns once IdeA \ @@ -595,6 +644,61 @@ pub fn map_tool_call( deadline_ms: optional_u64(args.get("deadline_ms"), name)?, ..base() }, + "idea_scheduled_task_list" => OrchestratorRequest { + request_type: Some("scheduled_task.list".to_owned()), + ..base() + }, + "idea_scheduled_task_read" => OrchestratorRequest { + request_type: Some("scheduled_task.read".to_owned()), + scheduled_task_id: s("scheduledTaskId"), + ..base() + }, + "idea_scheduled_task_create" | "idea_scheduled_task_update" => { + let is_update = name.ends_with("update"); + OrchestratorRequest { + request_type: Some( + if is_update { + "scheduled_task.update" + } else { + "scheduled_task.create" + } + .to_owned(), + ), + requested_by: Some(requester.to_owned()), + scheduled_task_id: s("scheduledTaskId"), + name: s("name"), + description: s("description"), + command: s("command"), + args: string_array(args.get("args"), name)?, + cwd: s("cwd"), + timezone: s("timezone"), + schedule: args + .get("schedule") + .cloned() + .map(serde_json::from_value) + .transpose() + .map_err(|_| ToolMapError::BadArguments(name.to_owned()))?, + timeout_ms: optional_u64(args.get("timeoutMs"), name)?, + audit_revision: optional_u64(args.get("auditRevision"), name)?, + ..base() + } + } + "idea_scheduled_task_delete" + | "idea_scheduled_task_run_now" + | "idea_scheduled_task_set_enabled" => OrchestratorRequest { + request_type: Some( + match name { + "idea_scheduled_task_delete" => "scheduled_task.delete", + "idea_scheduled_task_run_now" => "scheduled_task.run_now", + _ => "scheduled_task.set_enabled", + } + .to_owned(), + ), + requested_by: Some(requester.to_owned()), + scheduled_task_id: s("scheduledTaskId"), + enabled: args.get("enabled").and_then(Value::as_bool), + ..base() + }, "idea_stop_agent" => OrchestratorRequest { request_type: Some("agent.stop".to_owned()), requested_by: Some(requester.to_owned()), @@ -713,9 +817,44 @@ fn base() -> OrchestratorRequest { args: Vec::new(), cwd: None, deadline_ms: None, + scheduled_task_id: None, + description: None, + schedule: None, + timezone: None, + enabled: None, + timeout_ms: None, + audit_revision: None, } } +fn scheduled_mutation_schema(update: bool) -> Value { + let mut required = vec![ + json!("name"), + json!("command"), + json!("schedule"), + json!("timezone"), + ]; + if update { + required.insert(0, json!("scheduledTaskId")); + required.push(json!("auditRevision")); + } + json!({ + "type":"object", + "properties":{ + "scheduledTaskId":{"type":"string","format":"uuid"}, "name":{"type":"string"}, "description":{"type":"string"}, + "command":{"type":"string"}, "args":{"type":"array","items":{"type":"string"}}, + "cwd":{"type":"string","description":"Project-relative working directory; absolute paths and .. are rejected."}, + "schedule":{"oneOf":[ + {"type":"object","properties":{"kind":{"const":"interval"},"everyMinutes":{"type":"integer","minimum":5}},"required":["kind","everyMinutes"],"additionalProperties":false}, + {"type":"object","properties":{"kind":{"const":"daily"},"time":{"type":"string","pattern":"^[0-2][0-9]:[0-5][0-9]$"}},"required":["kind","time"],"additionalProperties":false}, + {"type":"object","properties":{"kind":{"const":"weekly"},"days":{"type":"array","minItems":1,"items":{"type":"string","enum":["monday","tuesday","wednesday","thursday","friday","saturday","sunday"]}},"time":{"type":"string","pattern":"^[0-2][0-9]:[0-5][0-9]$"}},"required":["kind","days","time"],"additionalProperties":false} + ]}, + "timezone":{"type":"string","description":"IANA timezone, for example Europe/Paris."}, + "timeoutMs":{"type":"integer","minimum":1}, "auditRevision":{"type":"integer","minimum":1} + }, "required":required, "additionalProperties":false + }) +} + fn reject_unknown_keys( tool: &str, args: &serde_json::Map, @@ -781,6 +920,38 @@ mod tests { map_tool_call(name, args, "") } + #[test] + fn scheduled_create_is_write_action_and_carries_requester_identity() { + assert_eq!( + tool_access("idea_scheduled_task_create"), + Some(McpToolAccess::WriteAction) + ); + let command = map_tool_call( + "idea_scheduled_task_create", + &json!({ + "name":"cleanup", "command":"find", "args":["build"], "cwd":".", + "schedule":{"kind":"interval","everyMinutes":15}, "timezone":"Europe/Paris" + }), + REQ, + ) + .unwrap(); + assert!(matches!(command, OrchestratorCommand::CreateScheduledTask { + requester, schedule: domain::ScheduledTaskSchedule::Interval { every_minutes: 15 }, .. + } if requester.to_string() == REQ)); + } + + #[test] + fn scheduled_read_tools_are_read_only() { + assert_eq!( + tool_access("idea_scheduled_task_list"), + Some(McpToolAccess::Read) + ); + assert_eq!( + tool_access("idea_scheduled_task_read"), + Some(McpToolAccess::Read) + ); + } + #[test] fn catalogue_tools_have_explicit_read_or_write_access() { let catalogue_names = catalogue() diff --git a/crates/infrastructure/src/scheduler/mod.rs b/crates/infrastructure/src/scheduler/mod.rs index 301c6781..746d65b8 100644 --- a/crates/infrastructure/src/scheduler/mod.rs +++ b/crates/infrastructure/src/scheduler/mod.rs @@ -258,7 +258,10 @@ mod tests { // `ScheduledTask` est mono-variante : déstructuration directe (pas de `if let`). let ScheduledTask::ResumeAgent { conversation_id, .. - } = task; + } = task + else { + panic!("expected resume task") + }; got.push(conversation_id.expect("conv présent")); } got.sort(); @@ -288,7 +291,10 @@ mod tests { .expect("canal ouvert"); let ScheduledTask::ResumeAgent { conversation_id, .. - } = task; + } = task + else { + panic!("expected resume task") + }; got.push(conversation_id.expect("conv présent")); } got.sort(); diff --git a/crates/infrastructure/src/store/mod.rs b/crates/infrastructure/src/store/mod.rs index 470a2f09..fd620025 100644 --- a/crates/infrastructure/src/store/mod.rs +++ b/crates/infrastructure/src/store/mod.rs @@ -16,6 +16,7 @@ mod permission; mod profile; mod project; mod project_plugin; +mod scheduled_task; mod secrets; mod settings; mod skill; @@ -44,6 +45,7 @@ pub use permission::FsPermissionStore; pub use profile::{FsEmbedderProfileStore, FsProfileStore}; pub use project::FsProjectStore; pub use project_plugin::FsProjectPluginSettingsStore; +pub use scheduled_task::FsScheduledProjectTaskStore; pub use secrets::FsSecretStore; pub use settings::{FsProjectSettingsStore, FsUserSettingsStore}; pub use skill::FsSkillStore; diff --git a/crates/infrastructure/src/store/scheduled_task.rs b/crates/infrastructure/src/store/scheduled_task.rs new file mode 100644 index 00000000..79bccd1d --- /dev/null +++ b/crates/infrastructure/src/store/scheduled_task.rs @@ -0,0 +1,282 @@ +//! Machine-local JSON persistence for project scheduled task definitions. + +use std::path::{Path, PathBuf}; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use tokio::sync::Mutex; + +use domain::{ + ProjectId, ScheduledProjectTask, ScheduledProjectTaskStore, ScheduledTaskApproval, + ScheduledTaskAuditEntry, ScheduledTaskId, ScheduledTaskStoreError, +}; + +const DOCUMENT_VERSION: u32 = 1; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Document { + version: u32, + project_id: ProjectId, + #[serde(default)] + tasks: Vec, + #[serde(default)] + audit: Vec, +} + +/// JSON store rooted at `/scheduled-project-tasks`. +pub struct FsScheduledProjectTaskStore { + dir: PathBuf, + lock: Mutex<()>, +} + +impl FsScheduledProjectTaskStore { + /// Creates a store rooted at `/scheduled-project-tasks`. + #[must_use] + pub fn new(app_data_dir: impl AsRef) -> Self { + Self { + dir: app_data_dir.as_ref().join("scheduled-project-tasks"), + lock: Mutex::new(()), + } + } + + /// Returns the machine-local storage directory. + #[must_use] + pub fn dir(&self) -> &Path { + &self.dir + } + + fn path(&self, project_id: ProjectId) -> PathBuf { + self.dir.join(format!("{project_id}.json")) + } + + async fn read_unlocked( + &self, + project_id: ProjectId, + ) -> Result { + let path = self.path(project_id); + let bytes = match tokio::fs::read(&path).await { + Ok(bytes) => bytes, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Ok(Document { + version: DOCUMENT_VERSION, + project_id, + tasks: Vec::new(), + audit: Vec::new(), + }) + } + Err(err) => return Err(store_err(err)), + }; + let document: Document = serde_json::from_slice(&bytes) + .map_err(|err| ScheduledTaskStoreError::Store(err.to_string()))?; + if document.version != DOCUMENT_VERSION || document.project_id != project_id { + return Err(ScheduledTaskStoreError::Invalid(format!( + "unexpected scheduled task document identity/version in {}", + path.display() + ))); + } + for task in &document.tasks { + if task.project_id != project_id { + return Err(ScheduledTaskStoreError::Invalid( + "foreign task in project document".to_owned(), + )); + } + task.validate() + .map_err(|err| ScheduledTaskStoreError::Invalid(err.to_string()))?; + } + Ok(document) + } + + async fn write_unlocked(&self, document: &Document) -> Result<(), ScheduledTaskStoreError> { + tokio::fs::create_dir_all(&self.dir) + .await + .map_err(store_err)?; + let path = self.path(document.project_id); + let tmp = self.dir.join(format!( + ".{}.{}.tmp", + document.project_id, + uuid::Uuid::new_v4() + )); + let bytes = serde_json::to_vec_pretty(document) + .map_err(|err| ScheduledTaskStoreError::Store(err.to_string()))?; + tokio::fs::write(&tmp, bytes).await.map_err(store_err)?; + if let Err(err) = tokio::fs::rename(&tmp, &path).await { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(store_err(err)); + } + Ok(()) + } +} + +fn store_err(err: std::io::Error) -> ScheduledTaskStoreError { + ScheduledTaskStoreError::Store(err.to_string()) +} + +#[async_trait] +impl ScheduledProjectTaskStore for FsScheduledProjectTaskStore { + async fn list( + &self, + project_id: ProjectId, + ) -> Result, ScheduledTaskStoreError> { + let _guard = self.lock.lock().await; + let mut tasks = self.read_unlocked(project_id).await?.tasks; + tasks.sort_by_key(|task| (task.created_at_ms, task.id)); + Ok(tasks) + } + + async fn get( + &self, + project_id: ProjectId, + scheduled_task_id: ScheduledTaskId, + ) -> Result, ScheduledTaskStoreError> { + let _guard = self.lock.lock().await; + Ok(self + .read_unlocked(project_id) + .await? + .tasks + .into_iter() + .find(|task| task.id == scheduled_task_id)) + } + + async fn save( + &self, + task: &ScheduledProjectTask, + audit: ScheduledTaskAuditEntry, + ) -> Result<(), ScheduledTaskStoreError> { + task.validate() + .map_err(|err| ScheduledTaskStoreError::Invalid(err.to_string()))?; + if audit.project_id != task.project_id + || audit.scheduled_task_id != task.id + || audit.revision != task.audit_revision + { + return Err(ScheduledTaskStoreError::Invalid( + "audit identity/revision mismatch".to_owned(), + )); + } + let _guard = self.lock.lock().await; + let mut document = self.read_unlocked(task.project_id).await?; + if let Some(existing) = document + .tasks + .iter_mut() + .find(|existing| existing.id == task.id) + { + if task.audit_revision <= existing.audit_revision { + return Err(ScheduledTaskStoreError::Invalid( + "stale scheduled task revision".to_owned(), + )); + } + *existing = task.clone(); + } else { + document.tasks.push(task.clone()); + } + document.audit.push(audit); + self.write_unlocked(&document).await + } + + async fn delete( + &self, + project_id: ProjectId, + scheduled_task_id: ScheduledTaskId, + audit: ScheduledTaskAuditEntry, + ) -> Result<(), ScheduledTaskStoreError> { + let _guard = self.lock.lock().await; + let mut document = self.read_unlocked(project_id).await?; + let before = document.tasks.len(); + document.tasks.retain(|task| task.id != scheduled_task_id); + if before == document.tasks.len() { + return Err(ScheduledTaskStoreError::NotFound); + } + document.audit.push(audit); + self.write_unlocked(&document).await + } + + async fn list_due( + &self, + now_ms: u64, + ) -> Result, ScheduledTaskStoreError> { + let _guard = self.lock.lock().await; + let mut due = Vec::new(); + let mut entries = match tokio::fs::read_dir(&self.dir).await { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(err) => return Err(store_err(err)), + }; + while let Some(entry) = entries.next_entry().await.map_err(store_err)? { + if entry.path().extension().and_then(|v| v.to_str()) != Some("json") { + continue; + } + let bytes = tokio::fs::read(entry.path()).await.map_err(store_err)?; + let document: Document = serde_json::from_slice(&bytes) + .map_err(|err| ScheduledTaskStoreError::Store(err.to_string()))?; + due.extend(document.tasks.into_iter().filter(|task| { + task.enabled + && task.approval == ScheduledTaskApproval::Approved + && task.next_run_at_ms.is_some_and(|at| at <= now_ms) + })); + } + Ok(due) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use domain::{ScheduledTaskActor, ScheduledTaskCommand, ScheduledTaskSchedule}; + + fn task(project_id: ProjectId) -> ScheduledProjectTask { + ScheduledProjectTask { + id: ScheduledTaskId::new_random(), + project_id, + name: "cleanup".to_owned(), + description: None, + enabled: true, + approval: ScheduledTaskApproval::Approved, + sandbox: domain::ScheduledTaskSandbox::ProjectReadWrite, + command: ScheduledTaskCommand { + command: "find".to_owned(), + args: vec!["build".to_owned()], + cwd: Some(".".to_owned()), + }, + schedule: ScheduledTaskSchedule::Interval { every_minutes: 15 }, + timezone: "UTC".to_owned(), + next_run_at_ms: Some(1_000), + last_run_at_ms: None, + last_task_id: None, + created_by: ScheduledTaskActor::Human, + updated_by: ScheduledTaskActor::Human, + created_at_ms: 1, + updated_at_ms: 1, + audit_revision: 1, + timeout_ms: None, + } + } + + #[tokio::test] + async fn persists_in_app_data_project_document_and_lists_due() { + let root = + std::env::temp_dir().join(format!("idea-scheduled-store-{}", uuid::Uuid::new_v4())); + let store = FsScheduledProjectTaskStore::new(&root); + let project_id = ProjectId::new_random(); + let task = task(project_id); + store + .save( + &task, + ScheduledTaskAuditEntry { + revision: 1, + project_id, + scheduled_task_id: task.id, + actor: ScheduledTaskActor::Human, + action: "create".to_owned(), + summary: "created".to_owned(), + at_ms: 1, + }, + ) + .await + .unwrap(); + assert_eq!(store.dir(), root.join("scheduled-project-tasks")); + assert_eq!(store.list(project_id).await.unwrap(), vec![task.clone()]); + assert_eq!(store.list_due(999).await.unwrap(), Vec::new()); + assert_eq!(store.list_due(1_000).await.unwrap(), vec![task]); + let _ = tokio::fs::remove_dir_all(root).await; + } +} diff --git a/crates/infrastructure/tests/background_completion_sink.rs b/crates/infrastructure/tests/background_completion_sink.rs index eada2b80..0b896c98 100644 --- a/crates/infrastructure/tests/background_completion_sink.rs +++ b/crates/infrastructure/tests/background_completion_sink.rs @@ -70,6 +70,23 @@ fn running_task(id: u128) -> BackgroundTask { .unwrap() } +fn running_record_only_task(id: u128) -> BackgroundTask { + BackgroundTask::new( + task_id(id), + project_id(10), + agent_id(100), + BackgroundTaskKind::Command { + label: format!("task-{id}"), + }, + BackgroundTaskWakePolicy::RecordOnly, + 1_000, + None, + ) + .unwrap() + .transition(BackgroundTaskState::Running, 1_010) + .unwrap() +} + fn success_completion(id: u128) -> BackgroundTaskCompletion { BackgroundTaskCompletion { task_id: task_id(id), @@ -138,6 +155,30 @@ async fn persistence_happens_before_ready_signal() { assert_eq!(*order.lock().unwrap(), vec!["save", "ready"]); } +#[tokio::test] +async fn record_only_completion_is_persisted_delivered_without_signal() { + let tmp = TempDir::new(); + let store = Arc::new(FsBackgroundTaskStore::new(&tmp.project_path())); + let task = running_record_only_task(2); + store.create(&task).await.unwrap(); + let (ready_tx, mut ready_rx) = unbounded_channel(); + let sink = BackgroundCompletionSink::new(store.clone(), ready_tx); + + let outcome = sink + .process_completion(success_completion(2)) + .await + .unwrap(); + + assert_eq!( + outcome, + BackgroundCompletionSinkOutcome::PersistedRecordOnly { task_id: task.id } + ); + assert!(ready_rx.try_recv().is_err()); + let persisted = store.get(task.id).await.unwrap().unwrap(); + assert_eq!(persisted.state, BackgroundTaskState::Completed); + assert!(persisted.completion_delivered); +} + #[tokio::test] async fn duplicate_completion_same_task_signals_once() { let tmp = TempDir::new(); diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index 4cb87061..bdc9cf58 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -77,17 +77,19 @@ use domain::{ use backend::dto::{ create_input, paginate, parse_agent_id, parse_agent_id_dto, parse_attachment_id_dto, parse_layout_id, parse_link_kind_dto, parse_node_id, parse_profile_id, parse_project_id, - parse_ref_dto, parse_session_id, parse_skill_id, parse_sprint_id_dto, parse_sprint_status_dto, - parse_task_id, parse_template_id, sort_ticket_rows, sprint_version_dto, update_input, - version_dto, AgentDriftListDto, AgentDto, AgentListDto, AssignSkillRequestDto, - AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, BackgroundTaskDto, - ChangeAgentProfileDto, ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto, - ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto, + parse_ref_dto, parse_scheduled_task_id, parse_session_id, parse_skill_id, parse_sprint_id_dto, + parse_sprint_status_dto, parse_task_id, parse_template_id, sort_ticket_rows, + sprint_version_dto, update_input, version_dto, AgentDriftListDto, AgentDto, AgentListDto, + AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, + BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto, + CloneOpenCodeProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto, + ConversationTranscriptUnavailableReasonDto, CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto, - CreateSkillRequestDto, CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto, - DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto, - EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto, - GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, + CreateScheduledProjectTaskRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto, + DeleteLayoutRequestDto, DeleteLayoutResultDto, DetectProfilesRequestDto, + DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, + ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, + GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto, @@ -117,7 +119,8 @@ use backend::dto::{ UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto, UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto, UpdateProjectSettingsRequestDto, UpdateProjectSystemPermissionsRequestDto, - UpdateSkillRequestDto, UpdateTemplateRequestDto, UserSettingsDto, + UpdateScheduledProjectTaskRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto, + UserSettingsDto, }; use backend::events::DomainEventDto; type PtyChunk = Vec; @@ -1587,11 +1590,21 @@ async fn send_launch_agent_attached( output: application::LaunchAgentOutput, ) -> Result<(), ErrorDto> { if let Some(structured) = output.structured.as_ref() { + let transcript_unavailable_reason = + ConversationTranscriptUnavailableReasonDto::for_scrollback( + true, + state + .app + .structured_sessions + .transcript_restore_expected(&structured.session_id), + ); tx.send(ServerFrame::chat_attached( &frame.id, structured, output.assigned_conversation_id.as_deref(), output.engine_session_id.as_deref(), + Vec::new(), + transcript_unavailable_reason, )) .await .map_err(|_| ErrorDto { @@ -1868,6 +1881,13 @@ async fn ws_attach_chat( })?; (sid, session) }; + let transcript_unavailable_reason = ConversationTranscriptUnavailableReasonDto::for_scrollback( + true, + state + .app + .structured_sessions + .transcript_restore_expected(&sid), + ); tx.send(ServerFrame::chat_attached( &frame.id, &application::StructuredSessionDescriptor { @@ -1878,6 +1898,8 @@ async fn ws_attach_chat( }, None, session.conversation_id().as_deref(), + Vec::new(), + transcript_unavailable_reason, )) .await .map_err(|_| ErrorDto { @@ -3513,6 +3535,24 @@ async fn invoke( "list_background_tasks" => invoke_list_background_tasks(&request.args, &state.app).await, "cancel_background_task" => invoke_cancel_background_task(&request.args, &state.app).await, "retry_background_task" => invoke_retry_background_task(&request.args, &state.app).await, + "list_scheduled_project_tasks" => { + invoke_list_scheduled_project_tasks(&request.args, &state.app).await + } + "create_scheduled_project_task" => { + invoke_create_scheduled_project_task(&request.args, &state.app).await + } + "update_scheduled_project_task" => { + invoke_update_scheduled_project_task(&request.args, &state.app).await + } + "delete_scheduled_project_task" => { + invoke_delete_scheduled_project_task(&request.args, &state.app).await + } + "run_scheduled_project_task_now" => { + invoke_run_scheduled_project_task_now(&request.args, &state.app).await + } + "set_scheduled_project_task_enabled" => { + invoke_set_scheduled_project_task_enabled(&request.args, &state.app).await + } "ticket_create" => invoke_ticket_create(&request.args, &state.app).await, "ticket_read" => invoke_ticket_read(&request.args, &state.app).await, "ticket_list" => invoke_ticket_list(&request.args, &state.app).await, @@ -4280,6 +4320,142 @@ async fn invoke_retry_background_task( serde_json::to_value(BackgroundTaskDto::from(output.task)).map_err(serialization_error) } +fn required_string_arg<'a>( + args: &'a Value, + field: &str, + command: &str, +) -> Result<&'a str, ErrorDto> { + args.get(field) + .and_then(Value::as_str) + .ok_or_else(|| ErrorDto { + code: "INVALID".to_owned(), + message: format!("{command} requires args.{field}"), + }) +} + +async fn invoke_list_scheduled_project_tasks( + args: &Value, + state: &BackendCore, +) -> Result { + let project_id = required_string_arg(args, "projectId", "list_scheduled_project_tasks")?; + let tasks = state + .scheduled_project_tasks + .list(parse_project_id(project_id)?) + .await + .map_err(ErrorDto::from)?; + serde_json::to_value(tasks).map_err(serialization_error) +} + +async fn invoke_create_scheduled_project_task( + args: &Value, + state: &BackendCore, +) -> Result { + let request = required_request::( + "create_scheduled_project_task", + args, + )?; + let task = state + .scheduled_project_tasks + .create(application::CreateScheduledProjectTaskInput { + project_id: parse_project_id(&request.project_id)?, + name: request.name, + description: request.description, + command: request.command, + schedule: request.schedule, + timezone: request.timezone, + enabled: request.enabled, + timeout_ms: request.timeout_ms, + actor: domain::ScheduledTaskActor::Human, + }) + .await + .map_err(ErrorDto::from)?; + serde_json::to_value(task).map_err(serialization_error) +} + +async fn invoke_update_scheduled_project_task( + args: &Value, + state: &BackendCore, +) -> Result { + let request = required_request::( + "update_scheduled_project_task", + args, + )?; + let task = state + .scheduled_project_tasks + .update(application::UpdateScheduledProjectTaskInput { + project_id: parse_project_id(&request.project_id)?, + scheduled_task_id: parse_scheduled_task_id(&request.scheduled_task_id)?, + name: request.name, + description: request.description, + command: request.command, + schedule: request.schedule, + timezone: request.timezone, + enabled: request.enabled, + timeout_ms: request.timeout_ms, + audit_revision: request.audit_revision, + actor: domain::ScheduledTaskActor::Human, + }) + .await + .map_err(ErrorDto::from)?; + serde_json::to_value(task).map_err(serialization_error) +} + +fn scheduled_identity( + args: &Value, + command: &str, +) -> Result<(domain::ProjectId, domain::ScheduledTaskId), ErrorDto> { + Ok(( + parse_project_id(required_string_arg(args, "projectId", command)?)?, + parse_scheduled_task_id(required_string_arg(args, "scheduledTaskId", command)?)?, + )) +} + +async fn invoke_delete_scheduled_project_task( + args: &Value, + state: &BackendCore, +) -> Result { + let (project_id, id) = scheduled_identity(args, "delete_scheduled_project_task")?; + state + .scheduled_project_tasks + .delete(project_id, id, domain::ScheduledTaskActor::Human) + .await + .map_err(ErrorDto::from)?; + Ok(Value::Null) +} + +async fn invoke_run_scheduled_project_task_now( + args: &Value, + state: &BackendCore, +) -> Result { + let (project_id, id) = scheduled_identity(args, "run_scheduled_project_task_now")?; + let task = state + .scheduled_project_tasks + .run_now(project_id, id, domain::ScheduledTaskActor::Human) + .await + .map_err(ErrorDto::from)?; + serde_json::to_value(task).map_err(serialization_error) +} + +async fn invoke_set_scheduled_project_task_enabled( + args: &Value, + state: &BackendCore, +) -> Result { + let (project_id, id) = scheduled_identity(args, "set_scheduled_project_task_enabled")?; + let enabled = args + .get("enabled") + .and_then(Value::as_bool) + .ok_or_else(|| ErrorDto { + code: "INVALID".to_owned(), + message: "set_scheduled_project_task_enabled requires args.enabled".to_owned(), + })?; + let task = state + .scheduled_project_tasks + .set_enabled(project_id, id, enabled, domain::ScheduledTaskActor::Human) + .await + .map_err(ErrorDto::from)?; + serde_json::to_value(task).map_err(serialization_error) +} + async fn invoke_ticket_create(args: &Value, state: &BackendCore) -> Result { let request = required_request::("ticket_create", args)?; let project = resolve_project_readonly(&request.project_id, state).await?; @@ -6068,6 +6244,8 @@ impl ServerFrame { session: &application::StructuredSessionDescriptor, assigned_conversation_id: Option<&str>, engine_session_id: Option<&str>, + scrollback: Vec, + transcript_unavailable_reason: Option, ) -> Self { Self { kind: "chat.attached".to_owned(), @@ -6081,6 +6259,8 @@ impl ServerFrame { "cellKind": "chat", "assignedConversationId": assigned_conversation_id, "engineSessionId": engine_session_id, + "scrollback": scrollback, + "transcriptUnavailableReason": transcript_unavailable_reason, }), } } @@ -7924,6 +8104,11 @@ mod tests { "pair-conversation" ); assert_eq!(attached.payload["engineSessionId"], "engine-session"); + assert_eq!(attached.payload["scrollback"], json!([])); + assert_eq!( + attached.payload["transcriptUnavailableReason"], + "emptyConversation" + ); assert_eq!(state.ws_pty_bridge.active_sessions(), 0); } @@ -7973,6 +8158,11 @@ mod tests { ); assert_eq!(attached.payload["cellKind"], "chat"); assert_eq!(attached.payload["engineSessionId"], "engine-chat-session"); + assert_eq!(attached.payload["scrollback"], json!([])); + assert_eq!( + attached.payload["transcriptUnavailableReason"], + "emptyConversation" + ); assert_eq!(state.ws_chat_bridge.active_sessions(), 1); } @@ -7996,6 +8186,10 @@ mod tests { agent_id, node_id, ); + state + .app + .structured_sessions + .set_transcript_restore_expected(live_session_id, true); let (tx, mut rx) = mpsc::channel(8); let owned = Arc::new(Mutex::new(Vec::new())); @@ -8022,6 +8216,11 @@ mod tests { attached.payload["session"]["sessionId"], live_session_id.to_string() ); + assert_eq!(attached.payload["scrollback"], json!([])); + assert_eq!( + attached.payload["transcriptUnavailableReason"], + "missingTranscript" + ); assert!(state.ws_chat_bridge.send_output( &live_session_id, json!({ "kind": "textDelta", "text": "after restart" }) @@ -10347,6 +10546,46 @@ mod tests { assert_eq!(tasks[0]["state"], "queued"); } + #[tokio::test] + async fn authorized_scheduled_task_invokes_use_camel_case_contract() { + let state = state(); + let project_id = create_project_for_test(&state, "Scheduled HTTP").await; + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let (status, created) = invoke_request_for_test( + Arc::clone(&state), + &cookie, + "create_scheduled_project_task", + json!({ + "request": { + "projectId": project_id, + "name": "cleanup", + "command": { "command": "true", "args": [], "cwd": "." }, + "schedule": { "kind": "interval", "everyMinutes": 15 }, + "timezone": "UTC", + "enabled": false + } + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "create failed: {created}"); + assert_eq!(created["projectId"], project_id); + assert_eq!(created["approval"], "approved"); + assert_eq!(created["schedule"]["everyMinutes"], 15); + assert!(created.get("scheduled_task_id").is_none()); + + let (status, listed) = invoke_request_for_test( + state, + &cookie, + "list_scheduled_project_tasks", + json!({ "projectId": project_id }), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(listed.as_array().map(Vec::len), Some(1)); + assert_eq!(listed[0]["id"], created["id"]); + } + #[tokio::test] async fn authorized_settings_invoke_round_trips_user_and_project_contracts() { let state = state(); diff --git a/docs/scheduled-project-tasks.md b/docs/scheduled-project-tasks.md new file mode 100644 index 00000000..1520a65a --- /dev/null +++ b/docs/scheduled-project-tasks.md @@ -0,0 +1,31 @@ +# Scheduled project tasks + +IdeA owns recurring project maintenance. Agents and users should use scheduled +project tasks instead of installing `cron`, `systemd` timers, or executable files +inside a repository. + +Definitions are machine-local and persist in +`/scheduled-project-tasks/.json`. They are distinct from +`BackgroundTask`: every manual or scheduled occurrence creates a new background +task carrying an `origin` (`manual` or `scheduled` with the definition id and +planned timestamp). + +Scheduled runs use `RecordOnly`: their terminal state and output remain durable, +but a successful completion does not wake an agent or enqueue a completion +message. + +V1 accepts only intervals of at least five minutes, daily `HH:mm`, and weekly +day/`HH:mm` schedules with an IANA timezone. Missed occurrences are skipped at +startup; only the next future occurrence is armed, and only while the project is +open. IdeA must be running to trigger tasks. + +The MCP surface is `idea_scheduled_task_list`, `read`, `create`, `update`, +`delete`, `run_now`, and `set_enabled`. Read operations are allowed by the native +read policy; mutations are write actions. An agent-created or agent-modified +definition is always disabled and `pending` human approval. MCP enable cannot +grant that approval. + +Commands persist no free-form environment. Their working directory is relative +to the project, checked lexically on write and canonically before every run to +reject symlink escapes. Runs use a deny-by-default OS sandbox: the project is the +only writable root and system toolchain paths are read/execute-only. diff --git a/frontend/src/adapters/agent.test.ts b/frontend/src/adapters/agent.test.ts index 172c7a54..775a423e 100644 --- a/frontend/src/adapters/agent.test.ts +++ b/frontend/src/adapters/agent.test.ts @@ -328,6 +328,7 @@ describe("TauriAgentGateway invoke payloads", () => { cols: 80, cellKind: "chat", assignedConversationId: "conversation-1", + transcriptUnavailableReason: "missingTranscript", }); const out = await new TauriAgentGateway().launchAgentChat("proj-1", "agent-2", { @@ -355,6 +356,7 @@ describe("TauriAgentGateway invoke payloads", () => { sessionId: "chat-session-1", cellKind: "chat", assignedConversationId: "conversation-1", + transcriptUnavailableReason: "missingTranscript", }); }); diff --git a/frontend/src/adapters/agent.ts b/frontend/src/adapters/agent.ts index d650a53d..89b9f9dd 100644 --- a/frontend/src/adapters/agent.ts +++ b/frontend/src/adapters/agent.ts @@ -52,6 +52,7 @@ interface LaunchAgentResponse { cellKind?: "chat" | "pty"; /** Conversation id minted by this launch (omitted when nothing was assigned). */ assignedConversationId?: string; + transcriptUnavailableReason?: AgentChatHandle["transcriptUnavailableReason"]; } function structuredLaunchRoutedToPtyError( @@ -269,6 +270,9 @@ export class TauriAgentGateway implements AgentGateway { ...(res.assignedConversationId ? { assignedConversationId: res.assignedConversationId } : {}), + ...(res.transcriptUnavailableReason + ? { transcriptUnavailableReason: res.transcriptUnavailableReason } + : {}), }; } diff --git a/frontend/src/adapters/http/agentGateway.test.ts b/frontend/src/adapters/http/agentGateway.test.ts index ef451447..7d3cd030 100644 --- a/frontend/src/adapters/http/agentGateway.test.ts +++ b/frontend/src/adapters/http/agentGateway.test.ts @@ -294,12 +294,14 @@ describe("HttpAgentGateway WS round-trip (B6 frames)", () => { sessionId: "chat-1", cellKind: "chat", assignedConversationId: "conv-1", + transcriptUnavailableReason: "emptyConversation", }, })); await expect(launched).resolves.toEqual({ sessionId: "chat-1", cellKind: "chat", assignedConversationId: "conv-1", + transcriptUnavailableReason: "emptyConversation", }); expect(launch.kind).toBe("agent.launch"); expect(launch.payload).toEqual({ @@ -324,12 +326,14 @@ describe("HttpAgentGateway WS round-trip (B6 frames)", () => { sessionId: "chat-1", conversationId: "conv-replacement", scrollback: [{ kind: "final", content: "Recovered reply" }], + transcriptUnavailableReason: "missingTranscript", }, })); await expect(reattached).resolves.toEqual({ sessionId: "chat-1", conversationId: "conv-replacement", scrollback: [{ kind: "final", content: "Recovered reply" }], + transcriptUnavailableReason: "missingTranscript", }); expect(attach.kind).toBe("chat.attach"); expect(attach.payload).toEqual({ diff --git a/frontend/src/adapters/http/frames.ts b/frontend/src/adapters/http/frames.ts index 53bc5918..5be6cac2 100644 --- a/frontend/src/adapters/http/frames.ts +++ b/frontend/src/adapters/http/frames.ts @@ -155,4 +155,5 @@ export interface ChatAttachedPayload { conversationId?: string; engineSessionId?: string; scrollback?: ReplyChunk[]; + transcriptUnavailableReason?: "missingTranscript" | "emptyConversation"; } diff --git a/frontend/src/adapters/http/index.ts b/frontend/src/adapters/http/index.ts index b982f543..e54566d7 100644 --- a/frontend/src/adapters/http/index.ts +++ b/frontend/src/adapters/http/index.ts @@ -35,6 +35,7 @@ import { HttpSkillGateway, HttpTemplateGateway, HttpWorkStateGateway, + HttpScheduledTaskGateway, } from "./requestResponseGateways"; import { HttpAgentGateway, @@ -140,6 +141,7 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway device: new HttpDeviceGateway(http), permission: new HttpPermissionGateway(http), workState: new HttpWorkStateGateway(http), + scheduledTask: new HttpScheduledTaskGateway(http), conversation: new HttpConversationGateway(http), ticket: new HttpTicketGateway(http, ws), window: new WebWindowGateway(), diff --git a/frontend/src/adapters/http/requestResponseGateways.ts b/frontend/src/adapters/http/requestResponseGateways.ts index 27dbdc18..2e4e35f3 100644 --- a/frontend/src/adapters/http/requestResponseGateways.ts +++ b/frontend/src/adapters/http/requestResponseGateways.ts @@ -49,6 +49,7 @@ import type { ProfileModelCatalog, ResolvedAgentPermissions, ResolvedAgentSystemPermissions, + ScheduledProjectTask, SystemPermissionSet, Skill, SkillScope, @@ -78,6 +79,8 @@ import type { SkillGateway, TemplateGateway, WorkStateGateway, + ScheduledTaskDefinitionInput, + ScheduledTaskGateway, BackgroundTaskAttachment, } from "@/ports"; import { normalizeProjectWorkState } from "../workStateNormalization"; @@ -577,6 +580,45 @@ export class HttpWorkStateGateway implements WorkStateGateway { } } +export class HttpScheduledTaskGateway implements ScheduledTaskGateway { + constructor(private readonly http: HttpInvoker) {} + list(projectId: string): Promise { + return this.http.invoke("list_scheduled_project_tasks", { projectId }); + } + create(projectId: string, input: ScheduledTaskDefinitionInput): Promise { + return this.http.invoke("create_scheduled_project_task", { + request: { projectId, ...input }, + }); + } + update( + projectId: string, + scheduledTaskId: string, + auditRevision: number, + input: ScheduledTaskDefinitionInput, + ): Promise { + return this.http.invoke("update_scheduled_project_task", { + request: { projectId, scheduledTaskId, auditRevision, ...input }, + }); + } + async delete(projectId: string, scheduledTaskId: string): Promise { + await this.http.invoke("delete_scheduled_project_task", { projectId, scheduledTaskId }); + } + runNow(projectId: string, scheduledTaskId: string): Promise { + return this.http.invoke("run_scheduled_project_task_now", { projectId, scheduledTaskId }); + } + setEnabled( + projectId: string, + scheduledTaskId: string, + enabled: boolean, + ): Promise { + return this.http.invoke("set_scheduled_project_task_enabled", { + projectId, + scheduledTaskId, + enabled, + }); + } +} + export class HttpConversationGateway implements ConversationGateway { constructor(private readonly http: HttpInvoker) {} async readPage( diff --git a/frontend/src/adapters/http/scheduledTaskGateway.test.ts b/frontend/src/adapters/http/scheduledTaskGateway.test.ts new file mode 100644 index 00000000..a78de618 --- /dev/null +++ b/frontend/src/adapters/http/scheduledTaskGateway.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { HttpInvoker } from "./httpInvoker"; +import { HttpScheduledTaskGateway } from "./requestResponseGateways"; + +describe("HttpScheduledTaskGateway", () => { + it("forwards the shared HTTP command contract", async () => { + const invoke = vi.fn().mockResolvedValue([]); + const gateway = new HttpScheduledTaskGateway({ invoke } as unknown as HttpInvoker); + await gateway.list("project-1"); + await gateway.setEnabled("project-1", "schedule-1", true); + expect(invoke).toHaveBeenNthCalledWith(1, "list_scheduled_project_tasks", { projectId: "project-1" }); + expect(invoke).toHaveBeenNthCalledWith(2, "set_scheduled_project_task_enabled", { + projectId: "project-1", + scheduledTaskId: "schedule-1", + enabled: true, + }); + }); +}); diff --git a/frontend/src/adapters/http/streamGateways.ts b/frontend/src/adapters/http/streamGateways.ts index e445d88f..aed3057c 100644 --- a/frontend/src/adapters/http/streamGateways.ts +++ b/frontend/src/adapters/http/streamGateways.ts @@ -375,6 +375,9 @@ export class HttpAgentGateway implements AgentGateway { ...(payload.assignedConversationId ? { assignedConversationId: payload.assignedConversationId } : {}), + ...(payload.transcriptUnavailableReason + ? { transcriptUnavailableReason: payload.transcriptUnavailableReason } + : {}), }; } @@ -404,6 +407,9 @@ export class HttpAgentGateway implements AgentGateway { } : {}), scrollback: payload.scrollback ?? [], + ...(payload.transcriptUnavailableReason + ? { transcriptUnavailableReason: payload.transcriptUnavailableReason } + : {}), }; } diff --git a/frontend/src/adapters/index.ts b/frontend/src/adapters/index.ts index 774159c6..0f26404a 100644 --- a/frontend/src/adapters/index.ts +++ b/frontend/src/adapters/index.ts @@ -29,6 +29,7 @@ import { TauriDeviceGateway } from "./device"; import { TauriGitGateway } from "./git"; import { TauriPermissionGateway } from "./permission"; import { TauriWorkStateGateway } from "./workState"; +import { TauriScheduledTaskGateway } from "./scheduledTask"; import { TauriConversationGateway } from "./conversation"; import { TauriTicketGateway } from "./ticket"; import { TauriWindowGateway } from "./window"; @@ -78,6 +79,7 @@ export function createTauriGateways(): Gateways { device: new TauriDeviceGateway(), permission: new TauriPermissionGateway(), workState: new TauriWorkStateGateway(), + scheduledTask: new TauriScheduledTaskGateway(), conversation: new TauriConversationGateway(), ticket: new TauriTicketGateway(), window: new TauriWindowGateway(), @@ -112,6 +114,7 @@ export { TauriGitGateway, TauriPermissionGateway, TauriWorkStateGateway, + TauriScheduledTaskGateway, TauriConversationGateway, TauriTicketGateway, TauriWindowGateway, diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index b3b4b0f9..49ba4fcc 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -83,6 +83,7 @@ import type { ResumableAgent, ResolvedAgentPermissions, ResolvedAgentSystemPermissions, + ScheduledProjectTask, ServerExposurePreview, ServerExposureSettings, Skill, @@ -168,6 +169,8 @@ import type { SetProjectPluginEnabledInput, SaveOpenCodeProviderProfileInput, SendAgentChatOptions, + ScheduledTaskDefinitionInput, + ScheduledTaskGateway, SkillGateway, StoppedLiveAgent, SystemGateway, @@ -3247,6 +3250,112 @@ export class MockPermissionGateway implements PermissionGateway { } } +export class MockScheduledTaskGateway implements ScheduledTaskGateway { + private readonly tasks = new Map(); + private nextId = 1; + + /** Test/dev helper for agent-originated pending proposals. */ + _seed(task: ScheduledProjectTask): void { + this.tasks.set(task.projectId, [ + ...(this.tasks.get(task.projectId) ?? []).filter((item) => item.id !== task.id), + structuredClone(task), + ]); + } + + async list(projectId: string): Promise { + return structuredClone(this.tasks.get(projectId) ?? []); + } + + async create( + projectId: string, + input: ScheduledTaskDefinitionInput, + ): Promise { + const now = Date.now(); + const task: ScheduledProjectTask = { + id: `mock-scheduled-${this.nextId++}`, + projectId, + ...structuredClone(input), + approval: "approved", + sandbox: "projectReadWrite", + nextRunAtMs: input.enabled ? now + 3_600_000 : null, + createdBy: { kind: "human" }, + updatedBy: { kind: "human" }, + createdAtMs: now, + updatedAtMs: now, + auditRevision: 1, + }; + this.tasks.set(projectId, [...(this.tasks.get(projectId) ?? []), task]); + return structuredClone(task); + } + + async update( + projectId: string, + scheduledTaskId: string, + auditRevision: number, + input: ScheduledTaskDefinitionInput, + ): Promise { + const rows = this.tasks.get(projectId) ?? []; + const index = rows.findIndex((task) => task.id === scheduledTaskId); + if (index < 0) throw { code: "NOT_FOUND", message: "scheduled task not found" }; + if (rows[index].auditRevision !== auditRevision) { + throw { code: "INVALID", message: "scheduled task revision conflict" }; + } + const updated: ScheduledProjectTask = { + ...rows[index], + ...structuredClone(input), + approval: "approved", + nextRunAtMs: input.enabled ? Date.now() + 3_600_000 : null, + updatedBy: { kind: "human" }, + updatedAtMs: Date.now(), + auditRevision: auditRevision + 1, + }; + rows[index] = updated; + return structuredClone(updated); + } + + async delete(projectId: string, scheduledTaskId: string): Promise { + this.tasks.set( + projectId, + (this.tasks.get(projectId) ?? []).filter((task) => task.id !== scheduledTaskId), + ); + } + + async runNow(projectId: string, scheduledTaskId: string): Promise { + const rows = this.tasks.get(projectId) ?? []; + const task = rows.find((row) => row.id === scheduledTaskId); + if (!task) throw { code: "NOT_FOUND", message: "scheduled task not found" }; + const updated = { + ...task, + lastRunAtMs: Date.now(), + lastTaskId: `mock-background-${scheduledTaskId}`, + auditRevision: task.auditRevision + 1, + }; + rows[rows.indexOf(task)] = updated; + return structuredClone(updated); + } + + async setEnabled( + projectId: string, + scheduledTaskId: string, + enabled: boolean, + ): Promise { + const rows = this.tasks.get(projectId) ?? []; + const task = rows.find((row) => row.id === scheduledTaskId); + if (!task) throw { code: "NOT_FOUND", message: "scheduled task not found" }; + const updated: ScheduledProjectTask = { + ...task, + enabled, + approval: enabled ? "approved" : task.approval === "pending" ? "rejected" : task.approval, + nextRunAtMs: enabled ? Date.now() + 3_600_000 : null, + updatedBy: { kind: "human" }, + updatedAtMs: Date.now(), + auditRevision: task.auditRevision + 1, + }; + rows[rows.indexOf(task)] = updated; + return structuredClone(updated); + } +} + export class MockWorkStateGateway implements WorkStateGateway { private states = new Map(); private backgroundTaskStreams = new Map< @@ -4854,6 +4963,7 @@ export function createMockGateways(): Gateways { device: new MockDeviceGateway(), permission: new MockPermissionGateway(), workState: new MockWorkStateGateway(), + scheduledTask: new MockScheduledTaskGateway(), conversation: new MockConversationGateway(), ticket: new MockTicketGateway(systemGateway), window: new MockWindowGateway(), diff --git a/frontend/src/adapters/mock/mock.test.ts b/frontend/src/adapters/mock/mock.test.ts index bacf04f9..bbfc80e7 100644 --- a/frontend/src/adapters/mock/mock.test.ts +++ b/frontend/src/adapters/mock/mock.test.ts @@ -36,6 +36,7 @@ describe("createMockGateways", () => { "profile", "project", "remote", + "scheduledTask", "skill", "system", "template", diff --git a/frontend/src/adapters/scheduledTask.test.ts b/frontend/src/adapters/scheduledTask.test.ts new file mode 100644 index 00000000..ebcd6885 --- /dev/null +++ b/frontend/src/adapters/scheduledTask.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const invoke = vi.fn(); +vi.mock("@tauri-apps/api/core", () => ({ invoke: (...args: unknown[]) => invoke(...args) })); + +import { TauriScheduledTaskGateway } from "./scheduledTask"; + +const input = { + name: "Nettoyage", + command: { command: "find", args: ["dist", "-delete"], cwd: "." }, + schedule: { kind: "interval" as const, everyMinutes: 60 }, + timezone: "Europe/Paris", + enabled: true, + timeoutMs: 60_000, +}; + +describe("TauriScheduledTaskGateway", () => { + beforeEach(() => invoke.mockReset().mockResolvedValue({})); + + it("uses the backend command envelopes for list/create/update", async () => { + const gateway = new TauriScheduledTaskGateway(); + await gateway.list("p1"); + await gateway.create("p1", input); + await gateway.update("p1", "s1", 4, input); + expect(invoke).toHaveBeenNthCalledWith(1, "list_scheduled_project_tasks", { projectId: "p1" }); + expect(invoke).toHaveBeenNthCalledWith(2, "create_scheduled_project_task", { request: { projectId: "p1", ...input } }); + expect(invoke).toHaveBeenNthCalledWith(3, "update_scheduled_project_task", { request: { projectId: "p1", scheduledTaskId: "s1", auditRevision: 4, ...input } }); + }); + + it("uses flat identity arguments for actions", async () => { + const gateway = new TauriScheduledTaskGateway(); + await gateway.runNow("p1", "s1"); + await gateway.setEnabled("p1", "s1", false); + await gateway.delete("p1", "s1"); + expect(invoke).toHaveBeenNthCalledWith(1, "run_scheduled_project_task_now", { projectId: "p1", scheduledTaskId: "s1" }); + expect(invoke).toHaveBeenNthCalledWith(2, "set_scheduled_project_task_enabled", { projectId: "p1", scheduledTaskId: "s1", enabled: false }); + expect(invoke).toHaveBeenNthCalledWith(3, "delete_scheduled_project_task", { projectId: "p1", scheduledTaskId: "s1" }); + }); +}); diff --git a/frontend/src/adapters/scheduledTask.ts b/frontend/src/adapters/scheduledTask.ts new file mode 100644 index 00000000..1095dd35 --- /dev/null +++ b/frontend/src/adapters/scheduledTask.ts @@ -0,0 +1,45 @@ +import { invoke } from "@tauri-apps/api/core"; + +import type { ScheduledProjectTask } from "@/domain"; +import type { ScheduledTaskDefinitionInput, ScheduledTaskGateway } from "@/ports"; + +export class TauriScheduledTaskGateway implements ScheduledTaskGateway { + list(projectId: string): Promise { + return invoke("list_scheduled_project_tasks", { projectId }); + } + + create(projectId: string, input: ScheduledTaskDefinitionInput): Promise { + return invoke("create_scheduled_project_task", { request: { projectId, ...input } }); + } + + update( + projectId: string, + scheduledTaskId: string, + auditRevision: number, + input: ScheduledTaskDefinitionInput, + ): Promise { + return invoke("update_scheduled_project_task", { + request: { projectId, scheduledTaskId, auditRevision, ...input }, + }); + } + + async delete(projectId: string, scheduledTaskId: string): Promise { + await invoke("delete_scheduled_project_task", { projectId, scheduledTaskId }); + } + + runNow(projectId: string, scheduledTaskId: string): Promise { + return invoke("run_scheduled_project_task_now", { projectId, scheduledTaskId }); + } + + setEnabled( + projectId: string, + scheduledTaskId: string, + enabled: boolean, + ): Promise { + return invoke("set_scheduled_project_task_enabled", { + projectId, + scheduledTaskId, + enabled, + }); + } +} diff --git a/frontend/src/adapters/workStateNormalization.ts b/frontend/src/adapters/workStateNormalization.ts index b10316d9..1afef473 100644 --- a/frontend/src/adapters/workStateNormalization.ts +++ b/frontend/src/adapters/workStateNormalization.ts @@ -114,12 +114,24 @@ function normalizeBackgroundTask( task.conversationId ?? task.conversation_id, ); const parentTaskId = optionalString(task.parentTaskId ?? task.parent_task_id); + const rawOrigin = isRecord(task.origin) ? task.origin : null; + const origin = rawOrigin?.kind === "scheduled" + ? { + kind: "scheduled" as const, + scheduledTaskId: stringValue(rawOrigin.scheduledTaskId ?? rawOrigin.scheduled_task_id), + plannedAtMs: numberValue(rawOrigin.plannedAtMs ?? rawOrigin.planned_at_ms), + } + : rawOrigin?.kind === "manual" + ? { kind: "manual" as const } + : undefined; + const createdAtMs = task.createdAtMs ?? task.created_at_ms; return { taskId: stringValue(task.taskId, `legacy-task-${index}`), ownerAgentId: stringValue(task.ownerAgentId, fallbackAgentId), projectId: stringValue(task.projectId), ...(parentTaskId ? { parentTaskId } : {}), kind: stringValue(task.kind, "background"), + ...(origin ? { origin } : {}), status: normalizeBackgroundStatus(task.status ?? task.state), exitCode: nullableNumber(task.exitCode), summary: nullableString(task.summary), @@ -129,6 +141,7 @@ function normalizeBackgroundTask( ...(targetAgentId ? { targetAgentId } : {}), ...(conversationId ? { conversationId } : {}), updatedAtMs: numberValue(task.updatedAtMs), + ...(typeof createdAtMs === "number" ? { createdAtMs } : {}), }; } diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index f1ab2b2b..17f1945d 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -657,6 +657,57 @@ export interface GatewayError { message: string; } +// --------------------------------------------------------------------------- +// Scheduled project tasks (#305) +// --------------------------------------------------------------------------- + +export type ScheduledTaskWeekday = + | "monday" + | "tuesday" + | "wednesday" + | "thursday" + | "friday" + | "saturday" + | "sunday"; + +export type ScheduledTaskSchedule = + | { kind: "interval"; everyMinutes: number } + | { kind: "daily"; time: string } + | { kind: "weekly"; days: ScheduledTaskWeekday[]; time: string }; + +export interface ScheduledTaskCommand { + command: string; + args: string[]; + cwd?: string; +} + +export type ScheduledTaskActor = + | { kind: "human" } + | { kind: "agent"; agentId: string } + | { kind: "system" }; + +export interface ScheduledProjectTask { + id: string; + projectId: string; + name: string; + description?: string; + enabled: boolean; + approval: "approved" | "pending" | "rejected"; + sandbox: "projectReadWrite"; + command: ScheduledTaskCommand; + schedule: ScheduledTaskSchedule; + timezone: string; + nextRunAtMs: number | null; + lastRunAtMs?: number; + lastTaskId?: string; + createdBy: ScheduledTaskActor; + updatedBy: ScheduledTaskActor; + createdAtMs: number; + updatedAtMs: number; + auditRevision: number; + timeoutMs?: number; +} + // --------------------------------------------------------------------------- // Work state (UX conversations/delegations live read-model) // --------------------------------------------------------------------------- @@ -707,6 +758,9 @@ export interface BackgroundCompletion { projectId: string; parentTaskId?: string; kind: string; + origin?: + | { kind: "manual" } + | { kind: "scheduled"; scheduledTaskId: string; plannedAtMs: number }; status: | "running" | "cancelling" @@ -726,6 +780,7 @@ export interface BackgroundCompletion { conversationId?: string; /** Last-update timestamp (epoch ms); chronological ordering key. */ updatedAtMs: number; + createdAtMs?: number; } /** One item currently pending in an agent's inbox. */ diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index 5a9829e5..9e7f2d64 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -197,7 +197,7 @@ describe("CustomAgentChatView", () => { __resetProjectCliInteractionActivityForTests(); }); - it("settles an isolated conversation id without recovered turns as unavailable", async () => { + it("settles an isolated conversation id without recovered turns as an actionable unavailable state (#307/#314)", async () => { const agent = { launchAgentChat: vi.fn(async () => ({ sessionId: "fresh-session" })), reattachAgentChat: vi.fn(async (sessionId: string) => ({ @@ -209,6 +209,7 @@ describe("CustomAgentChatView", () => { closeAgentChat: vi.fn(async () => {}), }; + const onConversationId = vi.fn(); render( { sessionId={null} conversationId="orphan-conversation" onSessionId={vi.fn()} - onConversationId={vi.fn()} + onConversationId={onConversationId} /> , ); @@ -239,13 +240,59 @@ describe("CustomAgentChatView", () => { ), ); expect( - await screen.findByText("Conversation liée — transcript indisponible."), + await screen.findByText("La conversation liée n’a pas pu être restaurée."), ).toBeTruthy(); + expect(screen.getByText("Son transcript n’est plus disponible après la reprise.")).toBeTruthy(); expect( screen.queryByText("Conversation liée — récupération du transcript en cours."), ).toBeNull(); expect(screen.queryByRole("progressbar")).toBeNull(); - expect(screen.queryByRole("button", { name: "Réessayer" })).toBeNull(); + expect(screen.getByRole("button", { name: "Réessayer" })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Délier la conversation" })); + expect(onConversationId).toHaveBeenCalledWith(null); + expect(await screen.findByText("Envoyez un message pour démarrer la conversation structurée.")).toBeTruthy(); + }); + + it("treats emptyConversation as a normal fresh conversation", async () => { + const agent = { + launchAgentChat: vi.fn(async () => ({ + sessionId: "fresh-session", + transcriptUnavailableReason: "emptyConversation" as const, + })), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ + sessionId, + scrollback: [], + transcriptUnavailableReason: "emptyConversation" as const, + })), + sendAgentChat: vi.fn(async () => {}), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + render( + null) } } as unknown as Gateways}> + + , + ); + expect(await screen.findByText("Envoyez un message pour démarrer la conversation structurée.")).toBeTruthy(); + expect(screen.queryByText("La conversation liée n’a pas pu être restaurée.")).toBeNull(); + }); + + it("maps NOT_FOUND to an actionable missing conversation without automatic looping", async () => { + const agent = { + launchAgentChat: vi.fn(async () => { throw { code: "NOT_FOUND", message: "conversation not found" }; }), + reattachAgentChat: vi.fn(), + sendAgentChat: vi.fn(async () => {}), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + render( + null) } } as unknown as Gateways}> + + , + ); + expect(await screen.findByText("Cette conversation est introuvable dans le stockage disponible.")).toBeTruthy(); + expect(agent.launchAgentChat).toHaveBeenCalledTimes(1); + expect(screen.getByRole("button", { name: "Réessayer" })).toBeTruthy(); }); it("projects self-restart lifecycle separators in the custom CLI only once", async () => { diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index 91a61d79..4c4e36bf 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -43,7 +43,11 @@ import { markProjectCliInteractionStopped, } from "@/features/projects/projectCliInteractionActivity"; import { Button, EntryTimestamp, Spinner, cn, normalizeTimestamp } from "@/shared"; -import type { ChatAttachmentInput, ChatDraftScope } from "@/ports"; +import type { + ChatAttachmentInput, + ChatDraftScope, + ConversationTranscriptUnavailableReason, +} from "@/ports"; export interface CustomAgentChatViewProps { projectId: string; @@ -968,6 +972,9 @@ export function CustomAgentChatView({ useState(null); const [opening, setOpening] = useState(Boolean(sessionId || conversationId)); const [openRetryNonce, setOpenRetryNonce] = useState(0); + const [transcriptUnavailableReason, setTranscriptUnavailableReason] = useState< + ConversationTranscriptUnavailableReason | "conversationNotFound" | null + >(null); const [busy, setBusy] = useState(false); const [stopping, setStopping] = useState(false); const [error, setError] = useState(null); @@ -1373,6 +1380,7 @@ export function CustomAgentChatView({ const receive = useCallback( (chunk: ReplyChunk) => { + setTranscriptUnavailableReason(null); scrollbackChunkCountRef.current += 1; const activeInteractionId = activeInteractionIdRef.current; if ( @@ -1604,6 +1612,11 @@ export function CustomAgentChatView({ applyChatViewState(replayed); setBusy(turnsIndicateBusy(replayed)); } + if (reattached.scrollback.length > 0) { + setTranscriptUnavailableReason(null); + } else if (reattached.transcriptUnavailableReason) { + setTranscriptUnavailableReason(reattached.transcriptUnavailableReason); + } return reattached.sessionId; }, [agent, agentId, applyChatViewState, projectId, publishSessionId, receive], @@ -1700,6 +1713,9 @@ export function CustomAgentChatView({ if (launched.assignedConversationId) { onConversationIdRef.current(launched.assignedConversationId); } + if (launched.transcriptUnavailableReason) { + setTranscriptUnavailableReason(launched.transcriptUnavailableReason); + } try { const recoveredSessionId = await reattachStructuredSession(launched.sessionId, { applyScrollback: options.applyScrollback, @@ -1771,6 +1787,7 @@ export function CustomAgentChatView({ }); setOpening(true); setError(null); + setTranscriptUnavailableReason(null); try { const localSession = sessionRef.current; if (!externalSessionId && localSession) { @@ -1875,7 +1892,11 @@ export function CustomAgentChatView({ sessionId: externalSessionId, error: instrumentationError(e), }); - if (!cancelled) { + if (!cancelled && conversationId && isNotFound(e)) { + setTranscriptUnavailableReason("conversationNotFound"); + setCurrentSession(null); + publishSessionId(null); + } else if (!cancelled) { setError({ message: describeOpenFailure(e), openFailure: true }); } } finally { @@ -1909,10 +1930,37 @@ export function CustomAgentChatView({ ); const hasLinkedConversation = Boolean( conversationId || + transcriptUnavailableReason === "missingTranscript" || + transcriptUnavailableReason === "conversationNotFound" || requesterDelegation.currentAgentBusy || restart || delegationActive, ); + const unavailableLinkedTranscript = + displayTurns.length === 0 && + hasLinkedConversation && + transcriptUnavailableReason !== "emptyConversation" + ? transcriptUnavailableReason ?? "missingTranscript" + : null; + + function unlinkUnavailableConversation() { + onConversationIdRef.current(null); + setTranscriptUnavailableReason("emptyConversation"); + setError(null); + } + + function retryUnavailableTranscript() { + const sid = sessionRef.current; + setTranscriptUnavailableReason(null); + setError(null); + if (sid) { + // Force the next user-triggered pass through the attach path. Keeping the + // local session here would trip the #307/idempotency short-circuit. + setCurrentSession(null); + setExternalSessionId(sid); + } + setOpenRetryNonce((value) => value + 1); + } async function openProfileDialog() { const availableProfiles = (await profileGateway.listProfiles()).filter((candidate) => @@ -2490,10 +2538,30 @@ export function CustomAgentChatView({ {externalSessionId ? "Reprise en cours..." : "Ouverture de la session structurée…"} - ) : displayTurns.length === 0 && hasLinkedConversation ? ( -

- Conversation liée — transcript indisponible. -

+ ) : unavailableLinkedTranscript ? ( +
+

+ La conversation liée n’a pas pu être restaurée. +

+

+ {unavailableLinkedTranscript === "conversationNotFound" + ? "Cette conversation est introuvable dans le stockage disponible." + : "Son transcript n’est plus disponible après la reprise."} +

+
+ + +
+
) : displayTurns.length === 0 ? (

Envoyez un message pour démarrer la conversation structurée. diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index c83a9018..70b593be 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -103,6 +103,7 @@ const PANEL_SIZE: Record = { context: "lg", projectSettings: "lg", work: "lg", + scheduledTasks: "xl", tickets: "lg", agents: "lg", templates: "lg", @@ -649,6 +650,7 @@ export function ProjectsView() { "projectSettings", "context", "work", + "scheduledTasks", "tickets", "agents", "templates", @@ -865,6 +867,7 @@ export function ProjectsView() { projectId={active.id} projectRoot={active.root} onOpenConversation={openConversation} + onOpenWork={() => setPlacement("work", "floating")} /> ); return panel === "agents" ? ( diff --git a/frontend/src/features/projects/ViewPanelBody.tsx b/frontend/src/features/projects/ViewPanelBody.tsx index 5259d39e..4ca86205 100644 --- a/frontend/src/features/projects/ViewPanelBody.tsx +++ b/frontend/src/features/projects/ViewPanelBody.tsx @@ -18,6 +18,7 @@ import { MemoryPanel } from "@/features/memory"; import { EmbedderSettings } from "@/features/embedder"; import { McpPanel, PermissionsPanel } from "@/features/permissions"; import { ProjectWorkStatePanel } from "@/features/workstate"; +import { ScheduledTasksPanel } from "@/features/scheduled-tasks"; import { TicketsView } from "@/features/tickets"; import { GitPanel } from "@/features/git"; import { ProjectContextPanel } from "./ProjectContextPanel"; @@ -34,6 +35,8 @@ export interface ViewPanelBodyProps { projectRoot: string; /** Optional: open a conversation viewer (only wired inside the main window). */ onOpenConversation?: (conversationId: string) => void; + /** Optional main-window navigation to the run details in Work. */ + onOpenWork?: () => void; } /** Renders the requested view panel for the given project. */ @@ -42,6 +45,7 @@ export function ViewPanelBody({ projectId, projectRoot, onOpenConversation, + onOpenWork, }: ViewPanelBodyProps): ReactNode { switch (panel) { case "context": @@ -55,6 +59,8 @@ export function ViewPanelBody({ onOpenConversation={onOpenConversation} /> ); + case "scheduledTasks": + return ; case "tickets": return ; case "agents": diff --git a/frontend/src/features/projects/viewPlacement.ts b/frontend/src/features/projects/viewPlacement.ts index fa8dbc58..621422e2 100644 --- a/frontend/src/features/projects/viewPlacement.ts +++ b/frontend/src/features/projects/viewPlacement.ts @@ -25,6 +25,7 @@ export type PanelId = | "context" | "projectSettings" | "work" + | "scheduledTasks" | "tickets" | "agents" | "templates" @@ -53,6 +54,7 @@ export const PANEL_TITLE: Record = { context: "Project context", projectSettings: "Paramètres du projet", work: "Work state", + scheduledTasks: "Tâches planifiées", tickets: "Tickets", agents: "Agents", templates: "Templates", diff --git a/frontend/src/features/scheduled-tasks/ScheduledTaskForm.tsx b/frontend/src/features/scheduled-tasks/ScheduledTaskForm.tsx new file mode 100644 index 00000000..ef5da36a --- /dev/null +++ b/frontend/src/features/scheduled-tasks/ScheduledTaskForm.tsx @@ -0,0 +1,185 @@ +import { useMemo, useState } from "react"; + +import type { + ScheduledProjectTask, + ScheduledTaskSchedule, + ScheduledTaskWeekday, +} from "@/domain"; +import type { ScheduledTaskDefinitionInput } from "@/ports"; +import { Button, Field, Input } from "@/shared"; + +type Preset = "hourly" | "daily" | "weekly" | "custom"; + +const DAYS: Array<[ScheduledTaskWeekday, string]> = [ + ["monday", "Lun"], ["tuesday", "Mar"], ["wednesday", "Mer"], + ["thursday", "Jeu"], ["friday", "Ven"], ["saturday", "Sam"], ["sunday", "Dim"], +]; + +const control = + "h-9 rounded-md border border-border bg-raised px-3 text-sm text-content outline-none focus:border-primary"; + +function initialPreset(schedule?: ScheduledTaskSchedule): Preset { + if (!schedule) return "daily"; + if (schedule.kind === "interval" && schedule.everyMinutes === 60) return "hourly"; + if (schedule.kind === "interval") return "custom"; + return schedule.kind; +} + +function shellWord(value: string): string { + return /^[A-Za-z0-9_./:@%+=,-]+$/.test(value) + ? value + : `'${value.split("'").join(`'\\''`)}'`; +} + +export function exactCommand(input: ScheduledTaskDefinitionInput): string { + return [input.command.command, ...input.command.args].map(shellWord).join(" "); +} + +export function scheduleLabel(schedule: ScheduledTaskSchedule): string { + if (schedule.kind === "interval") { + return schedule.everyMinutes === 60 + ? "Toutes les heures" + : `Toutes les ${schedule.everyMinutes} minutes`; + } + if (schedule.kind === "daily") return `Chaque jour à ${schedule.time}`; + const labels = schedule.days.map((day) => DAYS.find(([key]) => key === day)?.[1] ?? day); + return `Chaque semaine (${labels.join(", ")}) à ${schedule.time}`; +} + +export function ScheduledTaskForm({ + task, + saving, + onCancel, + onSubmit, +}: { + task?: ScheduledProjectTask; + saving: boolean; + onCancel: () => void; + onSubmit: (input: ScheduledTaskDefinitionInput) => void; +}) { + const [name, setName] = useState(task?.name ?? ""); + const [description, setDescription] = useState(task?.description ?? ""); + const [command, setCommand] = useState(task?.command.command ?? ""); + const [args, setArgs] = useState(task?.command.args.join("\n") ?? ""); + const [cwd, setCwd] = useState(task?.command.cwd ?? "."); + const [preset, setPreset] = useState(initialPreset(task?.schedule)); + const [minutes, setMinutes] = useState( + task?.schedule.kind === "interval" ? String(task.schedule.everyMinutes) : "15", + ); + const [time, setTime] = useState( + task?.schedule.kind === "daily" || task?.schedule.kind === "weekly" + ? task.schedule.time + : "09:00", + ); + const [days, setDays] = useState( + task?.schedule.kind === "weekly" ? task.schedule.days : ["monday"], + ); + const [timezone, setTimezone] = useState( + task?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC", + ); + const [timeoutMinutes, setTimeoutMinutes] = useState( + task?.timeoutMs ? String(Math.round(task.timeoutMs / 60_000)) : "", + ); + const [enabled, setEnabled] = useState(task?.approval === "pending" ? true : task?.enabled ?? true); + const [review, setReview] = useState(false); + + const input = useMemo(() => { + const schedule: ScheduledTaskSchedule = preset === "hourly" + ? { kind: "interval", everyMinutes: 60 } + : preset === "custom" + ? { kind: "interval", everyMinutes: Number(minutes) } + : preset === "weekly" + ? { kind: "weekly", days, time } + : { kind: "daily", time }; + return { + name: name.trim(), + ...(description.trim() ? { description: description.trim() } : {}), + command: { + command: command.trim(), + args: args.split("\n").map((arg) => arg.trim()).filter(Boolean), + ...(cwd.trim() && cwd.trim() !== "." ? { cwd: cwd.trim() } : {}), + }, + schedule, + timezone: timezone.trim(), + enabled, + ...(timeoutMinutes ? { timeoutMs: Number(timeoutMinutes) * 60_000 } : {}), + }; + }, [args, command, cwd, days, description, enabled, minutes, name, preset, time, timeoutMinutes, timezone]); + + const valid = input.name.length > 0 && input.command.command.length > 0 && + input.timezone.length > 0 && + (input.schedule.kind !== "interval" || input.schedule.everyMinutes >= 5) && + (input.schedule.kind !== "weekly" || input.schedule.days.length > 0) && + (!timeoutMinutes || Number(timeoutMinutes) > 0); + + if (review) { + return ( +

+
+ Cette tâche pourra modifier ou supprimer des fichiers automatiquement. +
+
+
Commande exacte
{exactCommand(input)}
+
Dossier
{input.command.cwd ?? "."} (relatif au projet)
+
Fréquence
{scheduleLabel(input.schedule)}
+
Prochaine exécution
Calculée par IdeA lors de l’activation
+
Fuseau horaire
{input.timezone}
+
Permissions
Lecture, modification et suppression dans le projet
+
Sandbox
Projet — lecture/écriture; aucun accès en écriture hors projet
+
+

IdeA doit fonctionner et le projet doit être ouvert pour déclencher cette tâche.

+
+ + +
+
+ ); + } + + return ( +
{ event.preventDefault(); enabled ? setReview(true) : onSubmit(input); }}> +
+ {({ id, describedBy }) => setName(e.target.value)} />} + + {({ id, describedBy }) => setCwd(e.target.value)} />} + +
+ + {({ id, describedBy }) =>