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.
This commit is contained in:
2026-09-11 10:32:01 +02:00
parent 72792bb02b
commit 7007b2cc7e
63 changed files with 3887 additions and 100 deletions

View File

@ -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<String>,
/// 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<u64>,
/// 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<String>,
/// 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<u64>,
/// 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<dyn ScheduledProjectTaskStore>,
projects: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
scheduler: Arc<dyn Scheduler>,
spawn: Arc<SpawnBackgroundCommand>,
clock: Arc<dyn Clock>,
ids: Arc<dyn IdGenerator>,
arms: Mutex<HashMap<ScheduledTaskId, (ProjectId, ScheduleId)>>,
mutation: AsyncMutex<()>,
}
impl ScheduledProjectTaskService {
/// Builds the scheduled-task application service from inward-facing ports.
#[must_use]
pub fn new(
store: Arc<dyn ScheduledProjectTaskStore>,
projects: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
scheduler: Arc<dyn Scheduler>,
spawn: Arc<SpawnBackgroundCommand>,
clock: Arc<dyn Clock>,
ids: Arc<dyn IdGenerator>,
) -> 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<Vec<ScheduledProjectTask>, 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<ScheduledProjectTask, AppError> {
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<ScheduledProjectTask, AppError> {
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<ScheduledProjectTask, AppError> {
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<ScheduledProjectTask, AppError> {
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<ScheduledProjectTask, 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}")))?;
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::<Vec<_>>();
let handles = ids
.into_iter()
.filter_map(|id| arms.remove(&id).map(|(_, handle)| handle))
.collect::<Vec<_>>();
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<ScheduledProjectTask, AppError> {
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<Tz, AppError> {
value
.parse::<Tz>()
.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<u64, AppError> {
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<ProjectPath, AppError> {
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());
}
}