Files
IdeA/crates/infrastructure/src/store/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

283 lines
9.7 KiB
Rust

//! 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<ScheduledProjectTask>,
#[serde(default)]
audit: Vec<ScheduledTaskAuditEntry>,
}
/// JSON store rooted at `<app_data_dir>/scheduled-project-tasks`.
pub struct FsScheduledProjectTaskStore {
dir: PathBuf,
lock: Mutex<()>,
}
impl FsScheduledProjectTaskStore {
/// Creates a store rooted at `<app_data_dir>/scheduled-project-tasks`.
#[must_use]
pub fn new(app_data_dir: impl AsRef<Path>) -> 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<Document, ScheduledTaskStoreError> {
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<Vec<ScheduledProjectTask>, 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<Option<ScheduledProjectTask>, 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<Vec<ScheduledProjectTask>, 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;
}
}