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

@ -0,0 +1,185 @@
import { useMemo, useState } from "react";
import type {
ScheduledProjectTask,
ScheduledTaskSchedule,
ScheduledTaskWeekday,
} from "@/domain";
import type { ScheduledTaskDefinitionInput } from "@/ports";
import { Button, Field, Input } from "@/shared";
type Preset = "hourly" | "daily" | "weekly" | "custom";
const DAYS: Array<[ScheduledTaskWeekday, string]> = [
["monday", "Lun"], ["tuesday", "Mar"], ["wednesday", "Mer"],
["thursday", "Jeu"], ["friday", "Ven"], ["saturday", "Sam"], ["sunday", "Dim"],
];
const control =
"h-9 rounded-md border border-border bg-raised px-3 text-sm text-content outline-none focus:border-primary";
function initialPreset(schedule?: ScheduledTaskSchedule): Preset {
if (!schedule) return "daily";
if (schedule.kind === "interval" && schedule.everyMinutes === 60) return "hourly";
if (schedule.kind === "interval") return "custom";
return schedule.kind;
}
function shellWord(value: string): string {
return /^[A-Za-z0-9_./:@%+=,-]+$/.test(value)
? value
: `'${value.split("'").join(`'\\''`)}'`;
}
export function exactCommand(input: ScheduledTaskDefinitionInput): string {
return [input.command.command, ...input.command.args].map(shellWord).join(" ");
}
export function scheduleLabel(schedule: ScheduledTaskSchedule): string {
if (schedule.kind === "interval") {
return schedule.everyMinutes === 60
? "Toutes les heures"
: `Toutes les ${schedule.everyMinutes} minutes`;
}
if (schedule.kind === "daily") return `Chaque jour à ${schedule.time}`;
const labels = schedule.days.map((day) => DAYS.find(([key]) => key === day)?.[1] ?? day);
return `Chaque semaine (${labels.join(", ")}) à ${schedule.time}`;
}
export function ScheduledTaskForm({
task,
saving,
onCancel,
onSubmit,
}: {
task?: ScheduledProjectTask;
saving: boolean;
onCancel: () => void;
onSubmit: (input: ScheduledTaskDefinitionInput) => void;
}) {
const [name, setName] = useState(task?.name ?? "");
const [description, setDescription] = useState(task?.description ?? "");
const [command, setCommand] = useState(task?.command.command ?? "");
const [args, setArgs] = useState(task?.command.args.join("\n") ?? "");
const [cwd, setCwd] = useState(task?.command.cwd ?? ".");
const [preset, setPreset] = useState<Preset>(initialPreset(task?.schedule));
const [minutes, setMinutes] = useState(
task?.schedule.kind === "interval" ? String(task.schedule.everyMinutes) : "15",
);
const [time, setTime] = useState(
task?.schedule.kind === "daily" || task?.schedule.kind === "weekly"
? task.schedule.time
: "09:00",
);
const [days, setDays] = useState<ScheduledTaskWeekday[]>(
task?.schedule.kind === "weekly" ? task.schedule.days : ["monday"],
);
const [timezone, setTimezone] = useState(
task?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC",
);
const [timeoutMinutes, setTimeoutMinutes] = useState(
task?.timeoutMs ? String(Math.round(task.timeoutMs / 60_000)) : "",
);
const [enabled, setEnabled] = useState(task?.approval === "pending" ? true : task?.enabled ?? true);
const [review, setReview] = useState(false);
const input = useMemo<ScheduledTaskDefinitionInput>(() => {
const schedule: ScheduledTaskSchedule = preset === "hourly"
? { kind: "interval", everyMinutes: 60 }
: preset === "custom"
? { kind: "interval", everyMinutes: Number(minutes) }
: preset === "weekly"
? { kind: "weekly", days, time }
: { kind: "daily", time };
return {
name: name.trim(),
...(description.trim() ? { description: description.trim() } : {}),
command: {
command: command.trim(),
args: args.split("\n").map((arg) => arg.trim()).filter(Boolean),
...(cwd.trim() && cwd.trim() !== "." ? { cwd: cwd.trim() } : {}),
},
schedule,
timezone: timezone.trim(),
enabled,
...(timeoutMinutes ? { timeoutMs: Number(timeoutMinutes) * 60_000 } : {}),
};
}, [args, command, cwd, days, description, enabled, minutes, name, preset, time, timeoutMinutes, timezone]);
const valid = input.name.length > 0 && input.command.command.length > 0 &&
input.timezone.length > 0 &&
(input.schedule.kind !== "interval" || input.schedule.everyMinutes >= 5) &&
(input.schedule.kind !== "weekly" || input.schedule.days.length > 0) &&
(!timeoutMinutes || Number(timeoutMinutes) > 0);
if (review) {
return (
<div className="space-y-4">
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-sm text-content">
Cette tâche pourra modifier ou supprimer des fichiers automatiquement.
</div>
<dl className="grid gap-2 text-sm sm:grid-cols-[9rem_1fr]">
<dt className="text-muted">Commande exacte</dt><dd><code className="break-all">{exactCommand(input)}</code></dd>
<dt className="text-muted">Dossier</dt><dd><code>{input.command.cwd ?? "."}</code> (relatif au projet)</dd>
<dt className="text-muted">Fréquence</dt><dd>{scheduleLabel(input.schedule)}</dd>
<dt className="text-muted">Prochaine exécution</dt><dd>Calculée par IdeA lors de l’activation</dd>
<dt className="text-muted">Fuseau horaire</dt><dd>{input.timezone}</dd>
<dt className="text-muted">Permissions</dt><dd>Lecture, modification et suppression dans le projet</dd>
<dt className="text-muted">Sandbox</dt><dd>Projet — lecture/écriture; aucun accès en écriture hors projet</dd>
</dl>
<p className="text-xs text-muted">IdeA doit fonctionner et le projet doit être ouvert pour déclencher cette tâche.</p>
<div className="flex justify-end gap-2">
<Button onClick={() => setReview(false)}>Retour</Button>
<Button variant="primary" loading={saving} onClick={() => onSubmit(input)}>
{task?.approval === "pending" ? "Approuver et activer" : task ? "Enregistrer" : "Créer la tâche"}
</Button>
</div>
</div>
);
}
return (
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); enabled ? setReview(true) : onSubmit(input); }}>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Nom">{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} value={name} onChange={(e) => setName(e.target.value)} />}</Field>
<Field label="Dossier de travail" hint="Chemin relatif au projet; « . » utilise sa racine.">
{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} value={cwd} onChange={(e) => setCwd(e.target.value)} />}
</Field>
</div>
<Field label={task?.approval === "pending" ? "Motif de la demande" : "Description (facultative)"}>
{({ id, describedBy }) => <textarea id={id} aria-describedby={describedBy} rows={2} className={`${control} h-auto py-2`} value={description} onChange={(e) => setDescription(e.target.value)} />}
</Field>
<Field label="Commande" hint="Exécutable exact, sans interpolation shell.">
{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} value={command} onChange={(e) => setCommand(e.target.value)} placeholder="npm" />}
</Field>
<Field label="Arguments" hint="Un argument littéral par ligne.">
{({ id, describedBy }) => <textarea id={id} aria-describedby={describedBy} rows={3} className={`${control} h-auto py-2 font-mono`} value={args} onChange={(e) => setArgs(e.target.value)} placeholder={'run\nbuild'} />}
</Field>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Planification">
{({ id }) => <select id={id} className={control} value={preset} onChange={(e) => setPreset(e.target.value as Preset)}>
<option value="hourly">Toutes les heures</option><option value="daily">Chaque jour</option>
<option value="weekly">Chaque semaine</option><option value="custom">Personnalisée</option>
</select>}
</Field>
{preset === "custom" ? (
<Field label="Intervalle (minutes)" error={Number(minutes) < 5 ? "Minimum : 5 minutes." : undefined}>
{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} type="number" min={5} value={minutes} onChange={(e) => setMinutes(e.target.value)} />}
</Field>
) : preset === "daily" || preset === "weekly" ? (
<Field label="Heure locale">{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} type="time" value={time} onChange={(e) => setTime(e.target.value)} />}</Field>
) : <div />}
</div>
{preset === "weekly" && <fieldset><legend className="mb-1 text-xs font-medium text-muted">Jours</legend><div className="flex flex-wrap gap-2">
{DAYS.map(([key, label]) => <label key={key} className="flex items-center gap-1 rounded border border-border px-2 py-1 text-xs"><input type="checkbox" checked={days.includes(key)} onChange={() => setDays((current) => current.includes(key) ? current.filter((day) => day !== key) : [...current, key])} />{label}</label>)}
</div></fieldset>}
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Fuseau horaire" hint="Identifiant IANA, par exemple Europe/Paris.">{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} value={timezone} onChange={(e) => setTimezone(e.target.value)} />}</Field>
<Field label="Limite de durée (minutes)" hint="Vide : aucune limite.">{({ id, describedBy }) => <Input id={id} aria-describedby={describedBy} type="number" min={1} value={timeoutMinutes} onChange={(e) => setTimeoutMinutes(e.target.value)} />}</Field>
</div>
<div className="rounded-md border border-border bg-raised p-3 text-sm"><div className="font-medium">Permissions et sandbox</div><p className="mt-1 text-muted">Projet — lecture/écriture. La commande peut modifier ou supprimer des fichiers dans le projet; l’écriture hors projet est refusée. Le réseau n’est pas isolé par ce profil V1.</p></div>
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />Activée</label>
<div className="flex justify-end gap-2"><Button onClick={onCancel}>Annuler</Button><Button type="submit" variant="primary" disabled={!valid} loading={saving}>{enabled ? "Vérifier avant activation" : "Enregistrer"}</Button></div>
</form>
);
}