Files
IdeA/crates/domain/src/scheduled_task.rs
Blomios 7007b2cc7e feat(backend,frontend): tâches planifiées projet — tool MCP idea_scheduled_task_* et menu utilisateur #305, fix transcript lié indisponible après reboot #314 (QA verte)
#305: nouvel agrégat durable ScheduledProjectTask dans domain (scheduled_task.rs, ids, ports ScheduledProjectTaskStore), source de vérité <app_data_dir>/scheduled-project-tasks/<projectId>.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.
2026-09-11 10:32:01 +02:00

289 lines
10 KiB
Rust

//! 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<Weekday>,
/// 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::<u8>().map_or(true, |h| h > 23)
|| minute.parse::<u8>().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<String>,
/// Project-relative working directory (`.` by default).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
}
/// 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<String>,
/// 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<u64>,
/// Time the most recent concrete run was created.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_run_at_ms: Option<u64>,
/// Most recently created background task, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_task_id: Option<TaskId>,
/// 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<u64>,
}
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,
}