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:
@ -3917,11 +3917,32 @@ pub enum ReplyChunk {
|
||||
},
|
||||
}
|
||||
|
||||
/// Reason why a successful structured attach has no replayable turns.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ConversationTranscriptUnavailableReasonDto {
|
||||
/// A linked conversation was resumed, but the volatile live transcript was absent.
|
||||
MissingTranscript,
|
||||
/// The session is fresh and has not produced a conversation turn yet.
|
||||
EmptyConversation,
|
||||
}
|
||||
|
||||
impl ConversationTranscriptUnavailableReasonDto {
|
||||
/// Returns an additive reason only when no replayable chunk exists.
|
||||
#[must_use]
|
||||
pub fn for_scrollback(is_empty: bool, transcript_restore_expected: bool) -> Option<Self> {
|
||||
is_empty.then_some(if transcript_restore_expected {
|
||||
Self::MissingTranscript
|
||||
} else {
|
||||
Self::EmptyConversation
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Response DTO for `reattach_agent_chat`: the retained **conversation
|
||||
/// scrollback** of a still-live structured session, replayed into the
|
||||
/// re-mounting `AgentChatView` before the new reply stream is wired (§17.6). The
|
||||
/// typed twin of [`ReattachResultDto`] (PTY scrollback bytes) — here the
|
||||
/// scrollback is the ordered list of [`ReplyChunk`]s already streamed.
|
||||
/// scrollback** of a still-live structured session, replayed before the new
|
||||
/// reply stream is wired. Unlike a transport error, an empty successful result
|
||||
/// carries a typed reason so the UI can offer an appropriate recovery action.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReattachChatDto {
|
||||
@ -3936,6 +3957,32 @@ pub struct ReattachChatDto {
|
||||
/// replays them to rebuild the visible turns, then receives subsequent chunks
|
||||
/// over the freshly-registered channel.
|
||||
pub scrollback: Vec<ReplyChunk>,
|
||||
/// Structured reason for an empty scrollback. Absent when replayable chunks exist.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub transcript_unavailable_reason: Option<ConversationTranscriptUnavailableReasonDto>,
|
||||
}
|
||||
|
||||
impl ReattachChatDto {
|
||||
/// Builds the additive read/open contract from runtime transcript metadata.
|
||||
#[must_use]
|
||||
pub fn from_scrollback(
|
||||
session_id: String,
|
||||
conversation_id: Option<String>,
|
||||
scrollback: Vec<ReplyChunk>,
|
||||
transcript_restore_expected: bool,
|
||||
) -> Self {
|
||||
let transcript_unavailable_reason =
|
||||
ConversationTranscriptUnavailableReasonDto::for_scrollback(
|
||||
scrollback.is_empty(),
|
||||
transcript_restore_expected,
|
||||
);
|
||||
Self {
|
||||
session_id,
|
||||
conversation_id,
|
||||
scrollback,
|
||||
transcript_unavailable_reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Request DTO for `inspect_conversation` (T7).
|
||||
@ -5751,7 +5798,7 @@ mod ls6_pagination_tests {
|
||||
turns: vec![
|
||||
TurnView {
|
||||
id: tid(1),
|
||||
at_ms: 1_700_000_000_000,
|
||||
at_ms: Some(1_700_000_000_000),
|
||||
role: TurnRole::Prompt,
|
||||
source: TurnSource::Human,
|
||||
text: big.clone(),
|
||||
@ -5759,7 +5806,7 @@ mod ls6_pagination_tests {
|
||||
},
|
||||
TurnView {
|
||||
id: tid(2),
|
||||
at_ms: 1_700_000_000_001,
|
||||
at_ms: Some(1_700_000_000_001),
|
||||
role: TurnRole::Response,
|
||||
source: TurnSource::Agent {
|
||||
agent_id: domain::AgentId::from_uuid(uuid::Uuid::from_u128(7)),
|
||||
@ -5822,7 +5869,8 @@ mod ls6_pagination_tests {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use domain::{
|
||||
BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskState, TaskId,
|
||||
BackgroundTask, BackgroundTaskKind, BackgroundTaskOrigin, BackgroundTaskResult,
|
||||
BackgroundTaskState, TaskId,
|
||||
};
|
||||
|
||||
/// One first-class background task as seen by the frontend (camelCase wire shape).
|
||||
@ -5846,6 +5894,8 @@ pub struct BackgroundTaskDto {
|
||||
/// Kind discriminant (`"command"`, `"headlessRendezvous"`, `"sessionResume"`,
|
||||
/// `"maintenance"`).
|
||||
pub kind: String,
|
||||
/// Manual launch or scheduled occurrence provenance.
|
||||
pub origin: BackgroundTaskOrigin,
|
||||
/// Lifecycle state (`"queued"`, `"running"`, `"waiting"`, `"completed"`,
|
||||
/// `"failed"`, `"cancelled"`, `"expired"`).
|
||||
pub state: String,
|
||||
@ -5955,6 +6005,7 @@ impl From<BackgroundTask> for BackgroundTaskDto {
|
||||
project_id: task.project_id.to_string(),
|
||||
parent_task_id: task.parent_task_id.map(|id| id.to_string()),
|
||||
kind: background_kind_label(&task.kind).to_owned(),
|
||||
origin: task.origin.clone(),
|
||||
state: background_state_label(task.state).to_owned(),
|
||||
exit_code,
|
||||
summary,
|
||||
@ -6000,6 +6051,55 @@ pub fn parse_task_id(raw: &str) -> Result<TaskId, ErrorDto> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Parses a durable scheduled-task id.
|
||||
pub fn parse_scheduled_task_id(raw: &str) -> Result<domain::ScheduledTaskId, ErrorDto> {
|
||||
uuid::Uuid::parse_str(raw)
|
||||
.map(domain::ScheduledTaskId::from_uuid)
|
||||
.map_err(|_| ErrorDto {
|
||||
code: "INVALID".to_owned(),
|
||||
message: format!("invalid scheduled task id: {raw}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Stable scheduled task response; domain serialization already uses camelCase.
|
||||
pub type ScheduledProjectTaskDto = domain::ScheduledProjectTask;
|
||||
|
||||
/// Create request shared by Tauri and HTTP.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateScheduledProjectTaskRequestDto {
|
||||
pub project_id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
pub command: domain::ScheduledTaskCommand,
|
||||
pub schedule: domain::ScheduledTaskSchedule,
|
||||
pub timezone: String,
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// Full replacement request with optimistic audit revision.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateScheduledProjectTaskRequestDto {
|
||||
pub project_id: String,
|
||||
pub scheduled_task_id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
pub command: domain::ScheduledTaskCommand,
|
||||
pub schedule: domain::ScheduledTaskSchedule,
|
||||
pub timezone: String,
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub timeout_ms: Option<u64>,
|
||||
pub audit_revision: u64,
|
||||
}
|
||||
|
||||
/// Request DTO for `spawn_background_command`.
|
||||
///
|
||||
/// Builds a command-backed [`BackgroundTask`]: the command line runs under the
|
||||
|
||||
@ -55,17 +55,18 @@ use application::{
|
||||
RestoreOpenProjectsOutput, RestoreOpenWindows, RetryBackgroundTask, RetryOpenProjectRestore,
|
||||
ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveChatDraft,
|
||||
SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveOpenProjectRestoreOrder,
|
||||
SaveProfile, SessionLimitService, SetActiveLayout, SetAgentMcpToolAllowed, SetPluginEnabled,
|
||||
SetProjectPluginEnabled, SlashCommandRegistry, SnapshotOpenWindows, SnapshotRunningAgents,
|
||||
SnapshotRunningAgentsInput, SpawnBackgroundCommand, StopAgentRuntime, StructuredRoutingMode,
|
||||
StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
|
||||
UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues,
|
||||
UpdateAgentContactable, UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions,
|
||||
UpdateAgentPermissions, UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet,
|
||||
UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectMcpToolPermissions,
|
||||
UpdateProjectOrchestrator, UpdateProjectPermissions, UpdateProjectSettings,
|
||||
UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, UpdateUserSettings,
|
||||
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||
SaveProfile, ScheduledProjectTaskService, SessionLimitService, SetActiveLayout,
|
||||
SetAgentMcpToolAllowed, SetPluginEnabled, SetProjectPluginEnabled, SlashCommandRegistry,
|
||||
SnapshotOpenWindows, SnapshotRunningAgents, SnapshotRunningAgentsInput, SpawnBackgroundCommand,
|
||||
StopAgentRuntime, StructuredRoutingMode, StructuredSessions, SuggestedThisSession,
|
||||
SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent,
|
||||
UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContactable,
|
||||
UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, UpdateAgentPermissions,
|
||||
UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory,
|
||||
UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectOrchestrator,
|
||||
UpdateProjectPermissions, UpdateProjectSettings, UpdateProjectSystemPermissions, UpdateSkill,
|
||||
UpdateTemplate, UpdateUserSettings, WakeSessionProvider, WriteMemory, WriteToTerminal,
|
||||
AGENT_MEMORY_RECALL_BUDGET,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use domain::events::AgentRestartPhase;
|
||||
@ -107,9 +108,9 @@ use infrastructure::{
|
||||
FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, FsOpenProjectRestoreStore,
|
||||
FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore,
|
||||
FsPluginStorageStore, FsProfileStore, FsProjectPluginSettingsStore, FsProjectSettingsStore,
|
||||
FsProjectStore, FsProviderSessionStore, FsSecretStore, FsSkillStore, FsSprintStore,
|
||||
FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore, FsWindowStateStore,
|
||||
Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
|
||||
FsProjectStore, FsProviderSessionStore, FsScheduledProjectTaskStore, FsSecretStore,
|
||||
FsSkillStore, FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore,
|
||||
FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
|
||||
HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, IdeaiContextStore,
|
||||
InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime,
|
||||
LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner,
|
||||
@ -2165,6 +2166,8 @@ pub struct BackendCore {
|
||||
pub background_command_runner: Arc<CommandBackgroundRunner>,
|
||||
/// Store handle used by `list_background_tasks` to read the task read-model.
|
||||
pub background_task_store: Arc<dyn BackgroundTaskStore>,
|
||||
/// Durable definitions plus their runtime one-shot reconciliation.
|
||||
pub scheduled_project_tasks: Arc<ScheduledProjectTaskService>,
|
||||
// --- Plugins (#43) ---
|
||||
/// Review a local plugin package before install.
|
||||
pub review_plugin_package: Arc<ReviewPluginPackage>,
|
||||
@ -2470,6 +2473,9 @@ impl BackendCore {
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
app_data_dir.to_string_lossy().into_owned(),
|
||||
));
|
||||
let scheduled_task_store = Arc::new(FsScheduledProjectTaskStore::new(&app_data_dir));
|
||||
let scheduled_task_store_port =
|
||||
Arc::clone(&scheduled_task_store) as Arc<dyn domain::ScheduledProjectTaskStore>;
|
||||
let pair_attempt_limiter = Arc::new(InMemoryPairAttemptLimiter::new(
|
||||
Arc::clone(&clock) as Arc<dyn Clock>
|
||||
));
|
||||
@ -3945,10 +3951,19 @@ impl BackendCore {
|
||||
}) as Arc<dyn AgentResumer>;
|
||||
let session_limit_service = Arc::new(SessionLimitService::new(
|
||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||
scheduler,
|
||||
Arc::clone(&scheduler),
|
||||
Arc::clone(&events_port),
|
||||
resumer,
|
||||
));
|
||||
let scheduled_project_tasks = Arc::new(ScheduledProjectTaskService::new(
|
||||
Arc::clone(&scheduled_task_store_port),
|
||||
Arc::clone(&store_port),
|
||||
Arc::clone(&fs_port),
|
||||
Arc::clone(&scheduler),
|
||||
Arc::clone(&spawn_background_command),
|
||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||
));
|
||||
let restart_rendezvous = Arc::new(application::RestartRendezvousCoordinator::new());
|
||||
let self_restart = Arc::new(AppAgentSelfRestarter {
|
||||
launch_agent: Arc::clone(&launch_agent),
|
||||
@ -3967,9 +3982,24 @@ impl BackendCore {
|
||||
// peut tourner sans runtime ambiant). Detache ⇒ vit autant que l'app.
|
||||
{
|
||||
let service = Arc::clone(&session_limit_service);
|
||||
let scheduled = Arc::clone(&scheduled_project_tasks);
|
||||
spawn_detached(async move {
|
||||
while let Some(task) = resume_rx.recv().await {
|
||||
if let Err(e) = service.execute_resume(task).await {
|
||||
let result = match task {
|
||||
task @ ScheduledTask::ResumeAgent { .. } => {
|
||||
service.execute_resume(task).await
|
||||
}
|
||||
ScheduledTask::FireProjectTask {
|
||||
project_id,
|
||||
scheduled_task_id,
|
||||
planned_at_ms,
|
||||
} => {
|
||||
scheduled
|
||||
.fire(project_id, scheduled_task_id, planned_at_ms)
|
||||
.await
|
||||
}
|
||||
};
|
||||
if let Err(e) = result {
|
||||
// Best-effort : une relance qui échoue ne fige pas le drain.
|
||||
eprintln!("[session-limit] reprise auto échouée : {e}");
|
||||
}
|
||||
@ -4129,7 +4159,8 @@ impl BackendCore {
|
||||
.with_session_limits(Arc::clone(&session_limit_service))
|
||||
// Producteur B8 côté agent MCP : `idea_run_in_background` passe par le
|
||||
// même use case que la commande desktop, sans aller-retour frontend.
|
||||
.with_spawn_background_command(Arc::clone(&spawn_background_command)),
|
||||
.with_spawn_background_command(Arc::clone(&spawn_background_command))
|
||||
.with_scheduled_project_tasks(Arc::clone(&scheduled_project_tasks)),
|
||||
);
|
||||
openai_tool_invoker.bind(Arc::new(AppOpenAiToolInvoker::new(
|
||||
Arc::clone(&orchestrator_service),
|
||||
@ -4375,6 +4406,7 @@ impl BackendCore {
|
||||
retry_background_task,
|
||||
background_command_runner: Arc::clone(&background_runner),
|
||||
background_task_store: Arc::clone(&background_tasks_port),
|
||||
scheduled_project_tasks,
|
||||
review_plugin_package,
|
||||
install_plugin_from_archive,
|
||||
install_plugin_from_directory,
|
||||
@ -4435,6 +4467,10 @@ impl BackendCore {
|
||||
.execute(application::ReconcileLiveStateInput { project_id })
|
||||
.await;
|
||||
let _ = self.reconcile_background_tasks.execute(project_id).await;
|
||||
let _ = self
|
||||
.scheduled_project_tasks
|
||||
.reconcile_project(project_id)
|
||||
.await;
|
||||
self.ensure_orchestrator_watch(project);
|
||||
self.reconcile_claude_run_dirs(project).await;
|
||||
}
|
||||
@ -4708,6 +4744,7 @@ impl BackendCore {
|
||||
/// Symmetrically stops the project's MCP server (its twin) so both entry doors
|
||||
/// are torn down together.
|
||||
pub fn stop_orchestrator_watch(&self, project_id: &ProjectId) {
|
||||
self.scheduled_project_tasks.cancel_project(*project_id);
|
||||
if let Some(handle) = self
|
||||
.orchestrator_watchers
|
||||
.lock()
|
||||
@ -8797,3 +8834,107 @@ mod open_project_restore_tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ticket305_scheduled_task_tests {
|
||||
use super::BackendCore;
|
||||
use application::CreateProjectInput;
|
||||
use domain::{
|
||||
AgentId, OrchestratorCommand, ScheduledTaskApproval, ScheduledTaskCommand,
|
||||
ScheduledTaskSchedule,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_request_is_machine_local_pending_and_cannot_be_enabled_by_agent() {
|
||||
let app_data = std::env::temp_dir().join(format!("idea-ticket305-app-{}", Uuid::new_v4()));
|
||||
let root = std::env::temp_dir().join(format!("idea-ticket305-project-{}", Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let core = BackendCore::build(app_data.clone());
|
||||
let project = core
|
||||
.create_project
|
||||
.execute(CreateProjectInput {
|
||||
name: "scheduled".to_owned(),
|
||||
root: root.to_string_lossy().into_owned(),
|
||||
remote: None,
|
||||
default_profile_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.project;
|
||||
let requester = AgentId::new_random();
|
||||
let outcome = core
|
||||
.orchestrator_service
|
||||
.dispatch(
|
||||
&project,
|
||||
OrchestratorCommand::CreateScheduledTask {
|
||||
requester,
|
||||
name: "cleanup".to_owned(),
|
||||
description: Some("remove generated files".to_owned()),
|
||||
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(),
|
||||
timeout_ms: Some(60_000),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let task: domain::ScheduledProjectTask =
|
||||
serde_json::from_str(outcome.reply.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(task.approval, ScheduledTaskApproval::Pending);
|
||||
assert!(!task.enabled);
|
||||
assert!(task.next_run_at_ms.is_none());
|
||||
assert!(app_data
|
||||
.join("scheduled-project-tasks")
|
||||
.join(format!("{}.json", project.id))
|
||||
.is_file());
|
||||
let error = core
|
||||
.scheduled_project_tasks
|
||||
.set_enabled(
|
||||
project.id,
|
||||
task.id,
|
||||
true,
|
||||
domain::ScheduledTaskActor::Agent {
|
||||
agent_id: requester,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("only a human can approve"));
|
||||
|
||||
let approved = core
|
||||
.scheduled_project_tasks
|
||||
.set_enabled(project.id, task.id, true, domain::ScheduledTaskActor::Human)
|
||||
.await
|
||||
.unwrap();
|
||||
let planned_at_ms = approved.next_run_at_ms.unwrap();
|
||||
core.scheduled_project_tasks
|
||||
.fire(project.id, task.id, planned_at_ms)
|
||||
.await
|
||||
.unwrap();
|
||||
let fired = core
|
||||
.scheduled_project_tasks
|
||||
.get(project.id, task.id)
|
||||
.await
|
||||
.unwrap();
|
||||
let concrete_task = core
|
||||
.background_task_store
|
||||
.get(fired.last_task_id.unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
concrete_task.origin,
|
||||
domain::BackgroundTaskOrigin::Scheduled {
|
||||
scheduled_task_id,
|
||||
planned_at_ms: occurrence,
|
||||
} if scheduled_task_id == task.id && occurrence == planned_at_ms
|
||||
));
|
||||
let _ = tokio::fs::remove_dir_all(app_data).await;
|
||||
let _ = tokio::fs::remove_dir_all(root).await;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user