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:
@ -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 {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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`].
|
||||
///
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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