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:
@ -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();
|
||||
|
||||
Reference in New Issue
Block a user