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:
@ -16,6 +16,7 @@ mod permission;
|
||||
mod profile;
|
||||
mod project;
|
||||
mod project_plugin;
|
||||
mod scheduled_task;
|
||||
mod secrets;
|
||||
mod settings;
|
||||
mod skill;
|
||||
@ -44,6 +45,7 @@ pub use permission::FsPermissionStore;
|
||||
pub use profile::{FsEmbedderProfileStore, FsProfileStore};
|
||||
pub use project::FsProjectStore;
|
||||
pub use project_plugin::FsProjectPluginSettingsStore;
|
||||
pub use scheduled_task::FsScheduledProjectTaskStore;
|
||||
pub use secrets::FsSecretStore;
|
||||
pub use settings::{FsProjectSettingsStore, FsUserSettingsStore};
|
||||
pub use skill::FsSkillStore;
|
||||
|
||||
282
crates/infrastructure/src/store/scheduled_task.rs
Normal file
282
crates/infrastructure/src/store/scheduled_task.rs
Normal file
@ -0,0 +1,282 @@
|
||||
//! 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user