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

@ -3,8 +3,9 @@
//! The sink is the durable boundary between a runner completion and later
//! mailbox/wake work: it writes the terminal task state to the
//! [`BackgroundTaskStore`](domain::ports::BackgroundTaskStore) first, then emits
//! a lightweight "ready to deliver" signal. It never wakes an agent and never
//! enqueues mailbox items; B4/B5 consume the ready signal.
//! a lightweight "ready to deliver" signal only for `WakeOwner` tasks.
//! `RecordOnly` completions are durably marked delivered without a signal. The
//! sink never wakes an agent or enqueues mailbox items; B4/B5 consume signals.
use std::collections::HashSet;
use std::sync::Arc;
@ -36,6 +37,11 @@ pub struct BackgroundTaskReadyToDeliver {
pub enum BackgroundCompletionSinkOutcome {
/// Completion was persisted and signalled as ready to deliver.
PersistedAndSignalled(BackgroundTaskReadyToDeliver),
/// Completion was persisted silently according to the `RecordOnly` policy.
PersistedRecordOnly {
/// Persisted task id.
task_id: TaskId,
},
/// Task was already terminal, so the duplicate completion was ignored.
IgnoredAlreadyTerminal {
/// Ignored task id.
@ -64,7 +70,7 @@ pub enum BackgroundCompletionSinkError {
ReadySignalClosed,
}
/// Consumes runner completions, persists terminal state, then signals delivery.
/// Consumes runner completions, persists terminal state, then applies wake policy.
pub struct BackgroundCompletionSink {
store: Arc<dyn BackgroundTaskStore>,
ready: UnboundedSender<BackgroundTaskReadyToDeliver>,
@ -154,9 +160,20 @@ impl BackgroundCompletionSink {
});
}
let terminal = task.complete(completion.result).map_err(|e| {
let mut terminal = task.complete(completion.result).map_err(|e| {
BackgroundCompletionSinkError::Store(BackgroundTaskPortError::Invalid(e.to_string()))
})?;
if terminal.wake_policy == domain::BackgroundTaskWakePolicy::RecordOnly {
terminal = terminal.mark_completion_delivered().map_err(|e| {
BackgroundCompletionSinkError::Store(BackgroundTaskPortError::Invalid(
e.to_string(),
))
})?;
self.store.save(&terminal).await?;
return Ok(BackgroundCompletionSinkOutcome::PersistedRecordOnly {
task_id: terminal.id,
});
}
self.store.save(&terminal).await?;
let ready = BackgroundTaskReadyToDeliver {

View File

@ -120,9 +120,9 @@ pub use store::{
AdaptiveMemoryRecall, BackgroundTaskReconcileReport, EmbedderEnvProbe, FsBackgroundTaskStore,
FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore,
FsMcpToolPermissionStore, FsMemoryStore, FsOpenProjectRestoreStore, FsPermissionStore,
FsProfileStore, FsProjectSettingsStore, FsProjectStore, FsSecretStore, FsSkillStore,
FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore, FsWindowStateStore,
HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo, StubEmbedder,
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
FsProfileStore, FsProjectSettingsStore, FsProjectStore, FsScheduledProjectTaskStore,
FsSecretStore, FsSkillStore, FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore,
FsWindowStateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo,
StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
};

View File

@ -46,7 +46,7 @@ const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
/// Compact MCP instructions advertised at initialization. The first 512 chars are
/// intentionally self-contained because some clients truncate this field.
const MCP_INSTRUCTIONS: &str = "Use IdeA tools by intent: ticket work -> idea_ticket_*; ask/delegate to one agent -> idea_ask_agent, several -> idea_ask_agents; durable project facts -> idea_memory_*; project/agent context -> idea_context_*. Then use idea_skill_* for assigned workflows, idea_template_* for agent templates, idea_workstate_* for live status, idea_run_in_background/background tools for long commands, agent lifecycle tools to launch/stop/resume/swap agents, and sprint tools for planning.";
const MCP_INSTRUCTIONS: &str = "Use IdeA tools by intent: ticket work -> idea_ticket_*; ask/delegate to one agent -> idea_ask_agent, several -> idea_ask_agents; durable project facts -> idea_memory_*; project/agent context -> idea_context_*; recurring project maintenance -> idea_scheduled_task_* (never system cron). Then use idea_skill_* for assigned workflows, idea_template_* for agent templates, idea_workstate_* for live status, idea_run_in_background/background tools for long commands, agent lifecycle tools to launch/stop/resume/swap agents, and sprint tools for planning.";
/// The IdeA MCP server: an entry adapter over [`OrchestratorService::dispatch`].
///

View File

@ -61,6 +61,8 @@ pub const READ_ONLY_TOOLS: &[&str] = &[
"idea_template_list",
"idea_template_read",
"idea_managed_temp_resolve",
"idea_scheduled_task_list",
"idea_scheduled_task_read",
];
/// Canonical write/action MCP tools denied by default.
@ -99,6 +101,11 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[
"idea_managed_temp_gc",
"idea_plugin_install_from_directory",
"idea_plugin_reload",
"idea_scheduled_task_create",
"idea_scheduled_task_update",
"idea_scheduled_task_delete",
"idea_scheduled_task_run_now",
"idea_scheduled_task_set_enabled",
];
/// All MCP tool names that have an explicit access classification.
@ -158,6 +165,13 @@ pub fn tool_returns_reply(tool: &str) -> bool {
| "idea_run_in_background"
| "idea_restart_self"
| "idea_workstate_read"
| "idea_scheduled_task_list"
| "idea_scheduled_task_read"
| "idea_scheduled_task_create"
| "idea_scheduled_task_update"
| "idea_scheduled_task_delete"
| "idea_scheduled_task_run_now"
| "idea_scheduled_task_set_enabled"
) || is_ticket_tool(tool)
|| is_template_tool(tool)
|| is_managed_temp_tool(tool)
@ -342,6 +356,41 @@ pub fn catalogue() -> Vec<ToolDef> {
"additionalProperties": false
}),
},
ToolDef {
name: "idea_scheduled_task_list",
description: "List durable scheduled tasks for the current project, including approval and next/last run state.",
input_schema: json!({"type":"object","properties":{},"additionalProperties":false}),
},
ToolDef {
name: "idea_scheduled_task_read",
description: "Read one durable scheduled task from the current project.",
input_schema: json!({"type":"object","properties":{"scheduledTaskId":{"type":"string","format":"uuid"}},"required":["scheduledTaskId"],"additionalProperties":false}),
},
ToolDef {
name: "idea_scheduled_task_create",
description: "Request a recurring project command. Agent-created tasks are saved disabled and pending human approval; this tool never activates them silently.",
input_schema: scheduled_mutation_schema(false),
},
ToolDef {
name: "idea_scheduled_task_update",
description: "Replace a scheduled task proposal. Agent updates disable it and require fresh human approval.",
input_schema: scheduled_mutation_schema(true),
},
ToolDef {
name: "idea_scheduled_task_delete",
description: "Delete a scheduled definition. An already-running BackgroundTask is not interrupted.",
input_schema: json!({"type":"object","properties":{"scheduledTaskId":{"type":"string","format":"uuid"}},"required":["scheduledTaskId"],"additionalProperties":false}),
},
ToolDef {
name: "idea_scheduled_task_run_now",
description: "Run an approved scheduled task now using its exact confined command and sandbox.",
input_schema: json!({"type":"object","properties":{"scheduledTaskId":{"type":"string","format":"uuid"}},"required":["scheduledTaskId"],"additionalProperties":false}),
},
ToolDef {
name: "idea_scheduled_task_set_enabled",
description: "Suspend or resume an already-approved scheduled task. This cannot approve an agent proposal.",
input_schema: json!({"type":"object","properties":{"scheduledTaskId":{"type":"string","format":"uuid"},"enabled":{"type":"boolean"}},"required":["scheduledTaskId","enabled"],"additionalProperties":false}),
},
ToolDef {
name: "idea_launch_agent",
description: "Launch (or attach) an IdeA agent. Fire-and-forget: returns once IdeA \
@ -595,6 +644,61 @@ pub fn map_tool_call(
deadline_ms: optional_u64(args.get("deadline_ms"), name)?,
..base()
},
"idea_scheduled_task_list" => OrchestratorRequest {
request_type: Some("scheduled_task.list".to_owned()),
..base()
},
"idea_scheduled_task_read" => OrchestratorRequest {
request_type: Some("scheduled_task.read".to_owned()),
scheduled_task_id: s("scheduledTaskId"),
..base()
},
"idea_scheduled_task_create" | "idea_scheduled_task_update" => {
let is_update = name.ends_with("update");
OrchestratorRequest {
request_type: Some(
if is_update {
"scheduled_task.update"
} else {
"scheduled_task.create"
}
.to_owned(),
),
requested_by: Some(requester.to_owned()),
scheduled_task_id: s("scheduledTaskId"),
name: s("name"),
description: s("description"),
command: s("command"),
args: string_array(args.get("args"), name)?,
cwd: s("cwd"),
timezone: s("timezone"),
schedule: args
.get("schedule")
.cloned()
.map(serde_json::from_value)
.transpose()
.map_err(|_| ToolMapError::BadArguments(name.to_owned()))?,
timeout_ms: optional_u64(args.get("timeoutMs"), name)?,
audit_revision: optional_u64(args.get("auditRevision"), name)?,
..base()
}
}
"idea_scheduled_task_delete"
| "idea_scheduled_task_run_now"
| "idea_scheduled_task_set_enabled" => OrchestratorRequest {
request_type: Some(
match name {
"idea_scheduled_task_delete" => "scheduled_task.delete",
"idea_scheduled_task_run_now" => "scheduled_task.run_now",
_ => "scheduled_task.set_enabled",
}
.to_owned(),
),
requested_by: Some(requester.to_owned()),
scheduled_task_id: s("scheduledTaskId"),
enabled: args.get("enabled").and_then(Value::as_bool),
..base()
},
"idea_stop_agent" => OrchestratorRequest {
request_type: Some("agent.stop".to_owned()),
requested_by: Some(requester.to_owned()),
@ -713,9 +817,44 @@ fn base() -> OrchestratorRequest {
args: Vec::new(),
cwd: None,
deadline_ms: None,
scheduled_task_id: None,
description: None,
schedule: None,
timezone: None,
enabled: None,
timeout_ms: None,
audit_revision: None,
}
}
fn scheduled_mutation_schema(update: bool) -> Value {
let mut required = vec![
json!("name"),
json!("command"),
json!("schedule"),
json!("timezone"),
];
if update {
required.insert(0, json!("scheduledTaskId"));
required.push(json!("auditRevision"));
}
json!({
"type":"object",
"properties":{
"scheduledTaskId":{"type":"string","format":"uuid"}, "name":{"type":"string"}, "description":{"type":"string"},
"command":{"type":"string"}, "args":{"type":"array","items":{"type":"string"}},
"cwd":{"type":"string","description":"Project-relative working directory; absolute paths and .. are rejected."},
"schedule":{"oneOf":[
{"type":"object","properties":{"kind":{"const":"interval"},"everyMinutes":{"type":"integer","minimum":5}},"required":["kind","everyMinutes"],"additionalProperties":false},
{"type":"object","properties":{"kind":{"const":"daily"},"time":{"type":"string","pattern":"^[0-2][0-9]:[0-5][0-9]$"}},"required":["kind","time"],"additionalProperties":false},
{"type":"object","properties":{"kind":{"const":"weekly"},"days":{"type":"array","minItems":1,"items":{"type":"string","enum":["monday","tuesday","wednesday","thursday","friday","saturday","sunday"]}},"time":{"type":"string","pattern":"^[0-2][0-9]:[0-5][0-9]$"}},"required":["kind","days","time"],"additionalProperties":false}
]},
"timezone":{"type":"string","description":"IANA timezone, for example Europe/Paris."},
"timeoutMs":{"type":"integer","minimum":1}, "auditRevision":{"type":"integer","minimum":1}
}, "required":required, "additionalProperties":false
})
}
fn reject_unknown_keys(
tool: &str,
args: &serde_json::Map<String, Value>,
@ -781,6 +920,38 @@ mod tests {
map_tool_call(name, args, "")
}
#[test]
fn scheduled_create_is_write_action_and_carries_requester_identity() {
assert_eq!(
tool_access("idea_scheduled_task_create"),
Some(McpToolAccess::WriteAction)
);
let command = map_tool_call(
"idea_scheduled_task_create",
&json!({
"name":"cleanup", "command":"find", "args":["build"], "cwd":".",
"schedule":{"kind":"interval","everyMinutes":15}, "timezone":"Europe/Paris"
}),
REQ,
)
.unwrap();
assert!(matches!(command, OrchestratorCommand::CreateScheduledTask {
requester, schedule: domain::ScheduledTaskSchedule::Interval { every_minutes: 15 }, ..
} if requester.to_string() == REQ));
}
#[test]
fn scheduled_read_tools_are_read_only() {
assert_eq!(
tool_access("idea_scheduled_task_list"),
Some(McpToolAccess::Read)
);
assert_eq!(
tool_access("idea_scheduled_task_read"),
Some(McpToolAccess::Read)
);
}
#[test]
fn catalogue_tools_have_explicit_read_or_write_access() {
let catalogue_names = catalogue()

View File

@ -258,7 +258,10 @@ mod tests {
// `ScheduledTask` est mono-variante : déstructuration directe (pas de `if let`).
let ScheduledTask::ResumeAgent {
conversation_id, ..
} = task;
} = task
else {
panic!("expected resume task")
};
got.push(conversation_id.expect("conv présent"));
}
got.sort();
@ -288,7 +291,10 @@ mod tests {
.expect("canal ouvert");
let ScheduledTask::ResumeAgent {
conversation_id, ..
} = task;
} = task
else {
panic!("expected resume task")
};
got.push(conversation_id.expect("conv présent"));
}
got.sort();

View File

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

View 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;
}
}