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

@ -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 {

View File

@ -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

View File

@ -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,
};

View File

@ -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<u64>,
/// Durable scheduled-task id for scheduled-task read/update/action tools.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scheduled_task_id: Option<String>,
/// Optional description for scheduled-task create/update.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Bounded recurring schedule for scheduled-task create/update.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schedule: Option<ScheduledTaskSchedule>,
/// IANA timezone for scheduled-task create/update.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timezone: Option<String>,
/// Desired enabled state.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
/// Per-run timeout in milliseconds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u64>,
/// Optimistic audit revision for scheduled-task updates.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub audit_revision: Option<u64>,
}
/// 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<u64>,
},
/// 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<String>,
/// 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<u64>,
},
/// 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<String>,
/// 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<u64>,
/// 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<ScheduledTaskId, OrchestratorError> {
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.
///

View File

@ -1037,6 +1037,16 @@ pub enum ScheduledTask {
/// id, comme le reste du chemin de reprise §15).
conversation_id: Option<String>,
},
/// 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<Vec<crate::ScheduledProjectTask>, ScheduledTaskStoreError>;
/// Reads one definition.
async fn get(
&self,
project_id: ProjectId,
scheduled_task_id: crate::ScheduledTaskId,
) -> Result<Option<crate::ScheduledProjectTask>, 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<Vec<crate::ScheduledProjectTask>, ScheduledTaskStoreError>;
}
/// Execution port for first-class background tasks.
#[async_trait]
pub trait BackgroundTaskRunner: Send + Sync {

View File

@ -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<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,
}