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