#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.
289 lines
10 KiB
Rust
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,
|
|
}
|