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

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

View File

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