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:
@ -46,7 +46,7 @@ const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
|
||||
|
||||
/// Compact MCP instructions advertised at initialization. The first 512 chars are
|
||||
/// intentionally self-contained because some clients truncate this field.
|
||||
const MCP_INSTRUCTIONS: &str = "Use IdeA tools by intent: ticket work -> idea_ticket_*; ask/delegate to one agent -> idea_ask_agent, several -> idea_ask_agents; durable project facts -> idea_memory_*; project/agent context -> idea_context_*. Then use idea_skill_* for assigned workflows, idea_template_* for agent templates, idea_workstate_* for live status, idea_run_in_background/background tools for long commands, agent lifecycle tools to launch/stop/resume/swap agents, and sprint tools for planning.";
|
||||
const MCP_INSTRUCTIONS: &str = "Use IdeA tools by intent: ticket work -> idea_ticket_*; ask/delegate to one agent -> idea_ask_agent, several -> idea_ask_agents; durable project facts -> idea_memory_*; project/agent context -> idea_context_*; recurring project maintenance -> idea_scheduled_task_* (never system cron). Then use idea_skill_* for assigned workflows, idea_template_* for agent templates, idea_workstate_* for live status, idea_run_in_background/background tools for long commands, agent lifecycle tools to launch/stop/resume/swap agents, and sprint tools for planning.";
|
||||
|
||||
/// The IdeA MCP server: an entry adapter over [`OrchestratorService::dispatch`].
|
||||
///
|
||||
|
||||
@ -61,6 +61,8 @@ pub const READ_ONLY_TOOLS: &[&str] = &[
|
||||
"idea_template_list",
|
||||
"idea_template_read",
|
||||
"idea_managed_temp_resolve",
|
||||
"idea_scheduled_task_list",
|
||||
"idea_scheduled_task_read",
|
||||
];
|
||||
|
||||
/// Canonical write/action MCP tools denied by default.
|
||||
@ -99,6 +101,11 @@ pub const WRITE_ACTION_TOOLS: &[&str] = &[
|
||||
"idea_managed_temp_gc",
|
||||
"idea_plugin_install_from_directory",
|
||||
"idea_plugin_reload",
|
||||
"idea_scheduled_task_create",
|
||||
"idea_scheduled_task_update",
|
||||
"idea_scheduled_task_delete",
|
||||
"idea_scheduled_task_run_now",
|
||||
"idea_scheduled_task_set_enabled",
|
||||
];
|
||||
|
||||
/// All MCP tool names that have an explicit access classification.
|
||||
@ -158,6 +165,13 @@ pub fn tool_returns_reply(tool: &str) -> bool {
|
||||
| "idea_run_in_background"
|
||||
| "idea_restart_self"
|
||||
| "idea_workstate_read"
|
||||
| "idea_scheduled_task_list"
|
||||
| "idea_scheduled_task_read"
|
||||
| "idea_scheduled_task_create"
|
||||
| "idea_scheduled_task_update"
|
||||
| "idea_scheduled_task_delete"
|
||||
| "idea_scheduled_task_run_now"
|
||||
| "idea_scheduled_task_set_enabled"
|
||||
) || is_ticket_tool(tool)
|
||||
|| is_template_tool(tool)
|
||||
|| is_managed_temp_tool(tool)
|
||||
@ -342,6 +356,41 @@ pub fn catalogue() -> Vec<ToolDef> {
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_scheduled_task_list",
|
||||
description: "List durable scheduled tasks for the current project, including approval and next/last run state.",
|
||||
input_schema: json!({"type":"object","properties":{},"additionalProperties":false}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_scheduled_task_read",
|
||||
description: "Read one durable scheduled task from the current project.",
|
||||
input_schema: json!({"type":"object","properties":{"scheduledTaskId":{"type":"string","format":"uuid"}},"required":["scheduledTaskId"],"additionalProperties":false}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_scheduled_task_create",
|
||||
description: "Request a recurring project command. Agent-created tasks are saved disabled and pending human approval; this tool never activates them silently.",
|
||||
input_schema: scheduled_mutation_schema(false),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_scheduled_task_update",
|
||||
description: "Replace a scheduled task proposal. Agent updates disable it and require fresh human approval.",
|
||||
input_schema: scheduled_mutation_schema(true),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_scheduled_task_delete",
|
||||
description: "Delete a scheduled definition. An already-running BackgroundTask is not interrupted.",
|
||||
input_schema: json!({"type":"object","properties":{"scheduledTaskId":{"type":"string","format":"uuid"}},"required":["scheduledTaskId"],"additionalProperties":false}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_scheduled_task_run_now",
|
||||
description: "Run an approved scheduled task now using its exact confined command and sandbox.",
|
||||
input_schema: json!({"type":"object","properties":{"scheduledTaskId":{"type":"string","format":"uuid"}},"required":["scheduledTaskId"],"additionalProperties":false}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_scheduled_task_set_enabled",
|
||||
description: "Suspend or resume an already-approved scheduled task. This cannot approve an agent proposal.",
|
||||
input_schema: json!({"type":"object","properties":{"scheduledTaskId":{"type":"string","format":"uuid"},"enabled":{"type":"boolean"}},"required":["scheduledTaskId","enabled"],"additionalProperties":false}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_launch_agent",
|
||||
description: "Launch (or attach) an IdeA agent. Fire-and-forget: returns once IdeA \
|
||||
@ -595,6 +644,61 @@ pub fn map_tool_call(
|
||||
deadline_ms: optional_u64(args.get("deadline_ms"), name)?,
|
||||
..base()
|
||||
},
|
||||
"idea_scheduled_task_list" => OrchestratorRequest {
|
||||
request_type: Some("scheduled_task.list".to_owned()),
|
||||
..base()
|
||||
},
|
||||
"idea_scheduled_task_read" => OrchestratorRequest {
|
||||
request_type: Some("scheduled_task.read".to_owned()),
|
||||
scheduled_task_id: s("scheduledTaskId"),
|
||||
..base()
|
||||
},
|
||||
"idea_scheduled_task_create" | "idea_scheduled_task_update" => {
|
||||
let is_update = name.ends_with("update");
|
||||
OrchestratorRequest {
|
||||
request_type: Some(
|
||||
if is_update {
|
||||
"scheduled_task.update"
|
||||
} else {
|
||||
"scheduled_task.create"
|
||||
}
|
||||
.to_owned(),
|
||||
),
|
||||
requested_by: Some(requester.to_owned()),
|
||||
scheduled_task_id: s("scheduledTaskId"),
|
||||
name: s("name"),
|
||||
description: s("description"),
|
||||
command: s("command"),
|
||||
args: string_array(args.get("args"), name)?,
|
||||
cwd: s("cwd"),
|
||||
timezone: s("timezone"),
|
||||
schedule: args
|
||||
.get("schedule")
|
||||
.cloned()
|
||||
.map(serde_json::from_value)
|
||||
.transpose()
|
||||
.map_err(|_| ToolMapError::BadArguments(name.to_owned()))?,
|
||||
timeout_ms: optional_u64(args.get("timeoutMs"), name)?,
|
||||
audit_revision: optional_u64(args.get("auditRevision"), name)?,
|
||||
..base()
|
||||
}
|
||||
}
|
||||
"idea_scheduled_task_delete"
|
||||
| "idea_scheduled_task_run_now"
|
||||
| "idea_scheduled_task_set_enabled" => OrchestratorRequest {
|
||||
request_type: Some(
|
||||
match name {
|
||||
"idea_scheduled_task_delete" => "scheduled_task.delete",
|
||||
"idea_scheduled_task_run_now" => "scheduled_task.run_now",
|
||||
_ => "scheduled_task.set_enabled",
|
||||
}
|
||||
.to_owned(),
|
||||
),
|
||||
requested_by: Some(requester.to_owned()),
|
||||
scheduled_task_id: s("scheduledTaskId"),
|
||||
enabled: args.get("enabled").and_then(Value::as_bool),
|
||||
..base()
|
||||
},
|
||||
"idea_stop_agent" => OrchestratorRequest {
|
||||
request_type: Some("agent.stop".to_owned()),
|
||||
requested_by: Some(requester.to_owned()),
|
||||
@ -713,9 +817,44 @@ fn base() -> OrchestratorRequest {
|
||||
args: Vec::new(),
|
||||
cwd: None,
|
||||
deadline_ms: None,
|
||||
scheduled_task_id: None,
|
||||
description: None,
|
||||
schedule: None,
|
||||
timezone: None,
|
||||
enabled: None,
|
||||
timeout_ms: None,
|
||||
audit_revision: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn scheduled_mutation_schema(update: bool) -> Value {
|
||||
let mut required = vec![
|
||||
json!("name"),
|
||||
json!("command"),
|
||||
json!("schedule"),
|
||||
json!("timezone"),
|
||||
];
|
||||
if update {
|
||||
required.insert(0, json!("scheduledTaskId"));
|
||||
required.push(json!("auditRevision"));
|
||||
}
|
||||
json!({
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"scheduledTaskId":{"type":"string","format":"uuid"}, "name":{"type":"string"}, "description":{"type":"string"},
|
||||
"command":{"type":"string"}, "args":{"type":"array","items":{"type":"string"}},
|
||||
"cwd":{"type":"string","description":"Project-relative working directory; absolute paths and .. are rejected."},
|
||||
"schedule":{"oneOf":[
|
||||
{"type":"object","properties":{"kind":{"const":"interval"},"everyMinutes":{"type":"integer","minimum":5}},"required":["kind","everyMinutes"],"additionalProperties":false},
|
||||
{"type":"object","properties":{"kind":{"const":"daily"},"time":{"type":"string","pattern":"^[0-2][0-9]:[0-5][0-9]$"}},"required":["kind","time"],"additionalProperties":false},
|
||||
{"type":"object","properties":{"kind":{"const":"weekly"},"days":{"type":"array","minItems":1,"items":{"type":"string","enum":["monday","tuesday","wednesday","thursday","friday","saturday","sunday"]}},"time":{"type":"string","pattern":"^[0-2][0-9]:[0-5][0-9]$"}},"required":["kind","days","time"],"additionalProperties":false}
|
||||
]},
|
||||
"timezone":{"type":"string","description":"IANA timezone, for example Europe/Paris."},
|
||||
"timeoutMs":{"type":"integer","minimum":1}, "auditRevision":{"type":"integer","minimum":1}
|
||||
}, "required":required, "additionalProperties":false
|
||||
})
|
||||
}
|
||||
|
||||
fn reject_unknown_keys(
|
||||
tool: &str,
|
||||
args: &serde_json::Map<String, Value>,
|
||||
@ -781,6 +920,38 @@ mod tests {
|
||||
map_tool_call(name, args, "")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheduled_create_is_write_action_and_carries_requester_identity() {
|
||||
assert_eq!(
|
||||
tool_access("idea_scheduled_task_create"),
|
||||
Some(McpToolAccess::WriteAction)
|
||||
);
|
||||
let command = map_tool_call(
|
||||
"idea_scheduled_task_create",
|
||||
&json!({
|
||||
"name":"cleanup", "command":"find", "args":["build"], "cwd":".",
|
||||
"schedule":{"kind":"interval","everyMinutes":15}, "timezone":"Europe/Paris"
|
||||
}),
|
||||
REQ,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(command, OrchestratorCommand::CreateScheduledTask {
|
||||
requester, schedule: domain::ScheduledTaskSchedule::Interval { every_minutes: 15 }, ..
|
||||
} if requester.to_string() == REQ));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheduled_read_tools_are_read_only() {
|
||||
assert_eq!(
|
||||
tool_access("idea_scheduled_task_list"),
|
||||
Some(McpToolAccess::Read)
|
||||
);
|
||||
assert_eq!(
|
||||
tool_access("idea_scheduled_task_read"),
|
||||
Some(McpToolAccess::Read)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalogue_tools_have_explicit_read_or_write_access() {
|
||||
let catalogue_names = catalogue()
|
||||
|
||||
Reference in New Issue
Block a user