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:
185
frontend/src/features/scheduled-tasks/ScheduledTaskForm.tsx
Normal file
185
frontend/src/features/scheduled-tasks/ScheduledTaskForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user