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

@ -77,17 +77,19 @@ use domain::{
use backend::dto::{
create_input, paginate, parse_agent_id, parse_agent_id_dto, parse_attachment_id_dto,
parse_layout_id, parse_link_kind_dto, parse_node_id, parse_profile_id, parse_project_id,
parse_ref_dto, parse_session_id, parse_skill_id, parse_sprint_id_dto, parse_sprint_status_dto,
parse_task_id, parse_template_id, sort_ticket_rows, sprint_version_dto, update_input,
version_dto, AgentDriftListDto, AgentDto, AgentListDto, AssignSkillRequestDto,
AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, BackgroundTaskDto,
ChangeAgentProfileDto, ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto,
ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto,
parse_ref_dto, parse_scheduled_task_id, parse_session_id, parse_skill_id, parse_sprint_id_dto,
parse_sprint_status_dto, parse_task_id, parse_template_id, sort_ticket_rows,
sprint_version_dto, update_input, version_dto, AgentDriftListDto, AgentDto, AgentListDto,
AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto,
BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto,
CloneOpenCodeProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
ConversationTranscriptUnavailableReasonDto, CreateAgentFromTemplateRequestDto,
CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto,
CreateSkillRequestDto, CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto,
EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto,
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
CreateScheduledProjectTaskRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
DeleteLayoutRequestDto, DeleteLayoutResultDto, DetectProfilesRequestDto,
DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto,
ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto,
GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto,
@ -117,7 +119,8 @@ use backend::dto::{
UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
UpdateProjectSettingsRequestDto, UpdateProjectSystemPermissionsRequestDto,
UpdateSkillRequestDto, UpdateTemplateRequestDto, UserSettingsDto,
UpdateScheduledProjectTaskRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
UserSettingsDto,
};
use backend::events::DomainEventDto;
type PtyChunk = Vec<u8>;
@ -1587,11 +1590,21 @@ async fn send_launch_agent_attached(
output: application::LaunchAgentOutput,
) -> Result<(), ErrorDto> {
if let Some(structured) = output.structured.as_ref() {
let transcript_unavailable_reason =
ConversationTranscriptUnavailableReasonDto::for_scrollback(
true,
state
.app
.structured_sessions
.transcript_restore_expected(&structured.session_id),
);
tx.send(ServerFrame::chat_attached(
&frame.id,
structured,
output.assigned_conversation_id.as_deref(),
output.engine_session_id.as_deref(),
Vec::new(),
transcript_unavailable_reason,
))
.await
.map_err(|_| ErrorDto {
@ -1868,6 +1881,13 @@ async fn ws_attach_chat(
})?;
(sid, session)
};
let transcript_unavailable_reason = ConversationTranscriptUnavailableReasonDto::for_scrollback(
true,
state
.app
.structured_sessions
.transcript_restore_expected(&sid),
);
tx.send(ServerFrame::chat_attached(
&frame.id,
&application::StructuredSessionDescriptor {
@ -1878,6 +1898,8 @@ async fn ws_attach_chat(
},
None,
session.conversation_id().as_deref(),
Vec::new(),
transcript_unavailable_reason,
))
.await
.map_err(|_| ErrorDto {
@ -3513,6 +3535,24 @@ async fn invoke(
"list_background_tasks" => invoke_list_background_tasks(&request.args, &state.app).await,
"cancel_background_task" => invoke_cancel_background_task(&request.args, &state.app).await,
"retry_background_task" => invoke_retry_background_task(&request.args, &state.app).await,
"list_scheduled_project_tasks" => {
invoke_list_scheduled_project_tasks(&request.args, &state.app).await
}
"create_scheduled_project_task" => {
invoke_create_scheduled_project_task(&request.args, &state.app).await
}
"update_scheduled_project_task" => {
invoke_update_scheduled_project_task(&request.args, &state.app).await
}
"delete_scheduled_project_task" => {
invoke_delete_scheduled_project_task(&request.args, &state.app).await
}
"run_scheduled_project_task_now" => {
invoke_run_scheduled_project_task_now(&request.args, &state.app).await
}
"set_scheduled_project_task_enabled" => {
invoke_set_scheduled_project_task_enabled(&request.args, &state.app).await
}
"ticket_create" => invoke_ticket_create(&request.args, &state.app).await,
"ticket_read" => invoke_ticket_read(&request.args, &state.app).await,
"ticket_list" => invoke_ticket_list(&request.args, &state.app).await,
@ -4280,6 +4320,142 @@ async fn invoke_retry_background_task(
serde_json::to_value(BackgroundTaskDto::from(output.task)).map_err(serialization_error)
}
fn required_string_arg<'a>(
args: &'a Value,
field: &str,
command: &str,
) -> Result<&'a str, ErrorDto> {
args.get(field)
.and_then(Value::as_str)
.ok_or_else(|| ErrorDto {
code: "INVALID".to_owned(),
message: format!("{command} requires args.{field}"),
})
}
async fn invoke_list_scheduled_project_tasks(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let project_id = required_string_arg(args, "projectId", "list_scheduled_project_tasks")?;
let tasks = state
.scheduled_project_tasks
.list(parse_project_id(project_id)?)
.await
.map_err(ErrorDto::from)?;
serde_json::to_value(tasks).map_err(serialization_error)
}
async fn invoke_create_scheduled_project_task(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request = required_request::<CreateScheduledProjectTaskRequestDto>(
"create_scheduled_project_task",
args,
)?;
let task = state
.scheduled_project_tasks
.create(application::CreateScheduledProjectTaskInput {
project_id: parse_project_id(&request.project_id)?,
name: request.name,
description: request.description,
command: request.command,
schedule: request.schedule,
timezone: request.timezone,
enabled: request.enabled,
timeout_ms: request.timeout_ms,
actor: domain::ScheduledTaskActor::Human,
})
.await
.map_err(ErrorDto::from)?;
serde_json::to_value(task).map_err(serialization_error)
}
async fn invoke_update_scheduled_project_task(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request = required_request::<UpdateScheduledProjectTaskRequestDto>(
"update_scheduled_project_task",
args,
)?;
let task = state
.scheduled_project_tasks
.update(application::UpdateScheduledProjectTaskInput {
project_id: parse_project_id(&request.project_id)?,
scheduled_task_id: parse_scheduled_task_id(&request.scheduled_task_id)?,
name: request.name,
description: request.description,
command: request.command,
schedule: request.schedule,
timezone: request.timezone,
enabled: request.enabled,
timeout_ms: request.timeout_ms,
audit_revision: request.audit_revision,
actor: domain::ScheduledTaskActor::Human,
})
.await
.map_err(ErrorDto::from)?;
serde_json::to_value(task).map_err(serialization_error)
}
fn scheduled_identity(
args: &Value,
command: &str,
) -> Result<(domain::ProjectId, domain::ScheduledTaskId), ErrorDto> {
Ok((
parse_project_id(required_string_arg(args, "projectId", command)?)?,
parse_scheduled_task_id(required_string_arg(args, "scheduledTaskId", command)?)?,
))
}
async fn invoke_delete_scheduled_project_task(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let (project_id, id) = scheduled_identity(args, "delete_scheduled_project_task")?;
state
.scheduled_project_tasks
.delete(project_id, id, domain::ScheduledTaskActor::Human)
.await
.map_err(ErrorDto::from)?;
Ok(Value::Null)
}
async fn invoke_run_scheduled_project_task_now(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let (project_id, id) = scheduled_identity(args, "run_scheduled_project_task_now")?;
let task = state
.scheduled_project_tasks
.run_now(project_id, id, domain::ScheduledTaskActor::Human)
.await
.map_err(ErrorDto::from)?;
serde_json::to_value(task).map_err(serialization_error)
}
async fn invoke_set_scheduled_project_task_enabled(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let (project_id, id) = scheduled_identity(args, "set_scheduled_project_task_enabled")?;
let enabled = args
.get("enabled")
.and_then(Value::as_bool)
.ok_or_else(|| ErrorDto {
code: "INVALID".to_owned(),
message: "set_scheduled_project_task_enabled requires args.enabled".to_owned(),
})?;
let task = state
.scheduled_project_tasks
.set_enabled(project_id, id, enabled, domain::ScheduledTaskActor::Human)
.await
.map_err(ErrorDto::from)?;
serde_json::to_value(task).map_err(serialization_error)
}
async fn invoke_ticket_create(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<TicketCreateRequestDto>("ticket_create", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
@ -6068,6 +6244,8 @@ impl ServerFrame {
session: &application::StructuredSessionDescriptor,
assigned_conversation_id: Option<&str>,
engine_session_id: Option<&str>,
scrollback: Vec<ChatChunk>,
transcript_unavailable_reason: Option<ConversationTranscriptUnavailableReasonDto>,
) -> Self {
Self {
kind: "chat.attached".to_owned(),
@ -6081,6 +6259,8 @@ impl ServerFrame {
"cellKind": "chat",
"assignedConversationId": assigned_conversation_id,
"engineSessionId": engine_session_id,
"scrollback": scrollback,
"transcriptUnavailableReason": transcript_unavailable_reason,
}),
}
}
@ -7924,6 +8104,11 @@ mod tests {
"pair-conversation"
);
assert_eq!(attached.payload["engineSessionId"], "engine-session");
assert_eq!(attached.payload["scrollback"], json!([]));
assert_eq!(
attached.payload["transcriptUnavailableReason"],
"emptyConversation"
);
assert_eq!(state.ws_pty_bridge.active_sessions(), 0);
}
@ -7973,6 +8158,11 @@ mod tests {
);
assert_eq!(attached.payload["cellKind"], "chat");
assert_eq!(attached.payload["engineSessionId"], "engine-chat-session");
assert_eq!(attached.payload["scrollback"], json!([]));
assert_eq!(
attached.payload["transcriptUnavailableReason"],
"emptyConversation"
);
assert_eq!(state.ws_chat_bridge.active_sessions(), 1);
}
@ -7996,6 +8186,10 @@ mod tests {
agent_id,
node_id,
);
state
.app
.structured_sessions
.set_transcript_restore_expected(live_session_id, true);
let (tx, mut rx) = mpsc::channel(8);
let owned = Arc::new(Mutex::new(Vec::new()));
@ -8022,6 +8216,11 @@ mod tests {
attached.payload["session"]["sessionId"],
live_session_id.to_string()
);
assert_eq!(attached.payload["scrollback"], json!([]));
assert_eq!(
attached.payload["transcriptUnavailableReason"],
"missingTranscript"
);
assert!(state.ws_chat_bridge.send_output(
&live_session_id,
json!({ "kind": "textDelta", "text": "after restart" })
@ -10347,6 +10546,46 @@ mod tests {
assert_eq!(tasks[0]["state"], "queued");
}
#[tokio::test]
async fn authorized_scheduled_task_invokes_use_camel_case_contract() {
let state = state();
let project_id = create_project_for_test(&state, "Scheduled HTTP").await;
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let (status, created) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"create_scheduled_project_task",
json!({
"request": {
"projectId": project_id,
"name": "cleanup",
"command": { "command": "true", "args": [], "cwd": "." },
"schedule": { "kind": "interval", "everyMinutes": 15 },
"timezone": "UTC",
"enabled": false
}
}),
)
.await;
assert_eq!(status, StatusCode::OK, "create failed: {created}");
assert_eq!(created["projectId"], project_id);
assert_eq!(created["approval"], "approved");
assert_eq!(created["schedule"]["everyMinutes"], 15);
assert!(created.get("scheduled_task_id").is_none());
let (status, listed) = invoke_request_for_test(
state,
&cookie,
"list_scheduled_project_tasks",
json!({ "projectId": project_id }),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(listed.as_array().map(Vec::len), Some(1));
assert_eq!(listed[0]["id"], created["id"]);
}
#[tokio::test]
async fn authorized_settings_invoke_round_trips_user_and_project_contracts() {
let state = state();