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:
@ -197,7 +197,7 @@ describe("CustomAgentChatView", () => {
|
||||
__resetProjectCliInteractionActivityForTests();
|
||||
});
|
||||
|
||||
it("settles an isolated conversation id without recovered turns as unavailable", async () => {
|
||||
it("settles an isolated conversation id without recovered turns as an actionable unavailable state (#307/#314)", async () => {
|
||||
const agent = {
|
||||
launchAgentChat: vi.fn(async () => ({ sessionId: "fresh-session" })),
|
||||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||||
@ -209,6 +209,7 @@ describe("CustomAgentChatView", () => {
|
||||
closeAgentChat: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
const onConversationId = vi.fn();
|
||||
render(
|
||||
<DIProvider
|
||||
gateways={{
|
||||
@ -226,7 +227,7 @@ describe("CustomAgentChatView", () => {
|
||||
sessionId={null}
|
||||
conversationId="orphan-conversation"
|
||||
onSessionId={vi.fn()}
|
||||
onConversationId={vi.fn()}
|
||||
onConversationId={onConversationId}
|
||||
/>
|
||||
</DIProvider>,
|
||||
);
|
||||
@ -239,13 +240,59 @@ describe("CustomAgentChatView", () => {
|
||||
),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText("Conversation liée — transcript indisponible."),
|
||||
await screen.findByText("La conversation liée n’a pas pu être restaurée."),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("Son transcript n’est plus disponible après la reprise.")).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByText("Conversation liée — récupération du transcript en cours."),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole("progressbar")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Réessayer" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Réessayer" })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Délier la conversation" }));
|
||||
expect(onConversationId).toHaveBeenCalledWith(null);
|
||||
expect(await screen.findByText("Envoyez un message pour démarrer la conversation structurée.")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("treats emptyConversation as a normal fresh conversation", async () => {
|
||||
const agent = {
|
||||
launchAgentChat: vi.fn(async () => ({
|
||||
sessionId: "fresh-session",
|
||||
transcriptUnavailableReason: "emptyConversation" as const,
|
||||
})),
|
||||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||||
sessionId,
|
||||
scrollback: [],
|
||||
transcriptUnavailableReason: "emptyConversation" as const,
|
||||
})),
|
||||
sendAgentChat: vi.fn(async () => {}),
|
||||
cancelAgentChat: vi.fn(async () => {}),
|
||||
closeAgentChat: vi.fn(async () => {}),
|
||||
};
|
||||
render(
|
||||
<DIProvider gateways={{ agent, system: { pickFile: vi.fn(async () => null) } } as unknown as Gateways}>
|
||||
<CustomAgentChatView projectId="project-1" agentId="agent-1" agentName="Worker" profile={profile} cwd="/repo" nodeId="node-1" sessionId={null} conversationId="conversation-1" onSessionId={vi.fn()} onConversationId={vi.fn()} />
|
||||
</DIProvider>,
|
||||
);
|
||||
expect(await screen.findByText("Envoyez un message pour démarrer la conversation structurée.")).toBeTruthy();
|
||||
expect(screen.queryByText("La conversation liée n’a pas pu être restaurée.")).toBeNull();
|
||||
});
|
||||
|
||||
it("maps NOT_FOUND to an actionable missing conversation without automatic looping", async () => {
|
||||
const agent = {
|
||||
launchAgentChat: vi.fn(async () => { throw { code: "NOT_FOUND", message: "conversation not found" }; }),
|
||||
reattachAgentChat: vi.fn(),
|
||||
sendAgentChat: vi.fn(async () => {}),
|
||||
cancelAgentChat: vi.fn(async () => {}),
|
||||
closeAgentChat: vi.fn(async () => {}),
|
||||
};
|
||||
render(
|
||||
<DIProvider gateways={{ agent, system: { pickFile: vi.fn(async () => null) } } as unknown as Gateways}>
|
||||
<CustomAgentChatView projectId="project-1" agentId="agent-1" agentName="Worker" profile={profile} cwd="/repo" nodeId="node-1" sessionId={null} conversationId="missing-conversation" onSessionId={vi.fn()} onConversationId={vi.fn()} />
|
||||
</DIProvider>,
|
||||
);
|
||||
expect(await screen.findByText("Cette conversation est introuvable dans le stockage disponible.")).toBeTruthy();
|
||||
expect(agent.launchAgentChat).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByRole("button", { name: "Réessayer" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("projects self-restart lifecycle separators in the custom CLI only once", async () => {
|
||||
|
||||
@ -43,7 +43,11 @@ import {
|
||||
markProjectCliInteractionStopped,
|
||||
} from "@/features/projects/projectCliInteractionActivity";
|
||||
import { Button, EntryTimestamp, Spinner, cn, normalizeTimestamp } from "@/shared";
|
||||
import type { ChatAttachmentInput, ChatDraftScope } from "@/ports";
|
||||
import type {
|
||||
ChatAttachmentInput,
|
||||
ChatDraftScope,
|
||||
ConversationTranscriptUnavailableReason,
|
||||
} from "@/ports";
|
||||
|
||||
export interface CustomAgentChatViewProps {
|
||||
projectId: string;
|
||||
@ -968,6 +972,9 @@ export function CustomAgentChatView({
|
||||
useState<ProfileCommandDialogState | null>(null);
|
||||
const [opening, setOpening] = useState(Boolean(sessionId || conversationId));
|
||||
const [openRetryNonce, setOpenRetryNonce] = useState(0);
|
||||
const [transcriptUnavailableReason, setTranscriptUnavailableReason] = useState<
|
||||
ConversationTranscriptUnavailableReason | "conversationNotFound" | null
|
||||
>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [stopping, setStopping] = useState(false);
|
||||
const [error, setError] = useState<ChatErrorState | null>(null);
|
||||
@ -1373,6 +1380,7 @@ export function CustomAgentChatView({
|
||||
|
||||
const receive = useCallback(
|
||||
(chunk: ReplyChunk) => {
|
||||
setTranscriptUnavailableReason(null);
|
||||
scrollbackChunkCountRef.current += 1;
|
||||
const activeInteractionId = activeInteractionIdRef.current;
|
||||
if (
|
||||
@ -1604,6 +1612,11 @@ export function CustomAgentChatView({
|
||||
applyChatViewState(replayed);
|
||||
setBusy(turnsIndicateBusy(replayed));
|
||||
}
|
||||
if (reattached.scrollback.length > 0) {
|
||||
setTranscriptUnavailableReason(null);
|
||||
} else if (reattached.transcriptUnavailableReason) {
|
||||
setTranscriptUnavailableReason(reattached.transcriptUnavailableReason);
|
||||
}
|
||||
return reattached.sessionId;
|
||||
},
|
||||
[agent, agentId, applyChatViewState, projectId, publishSessionId, receive],
|
||||
@ -1700,6 +1713,9 @@ export function CustomAgentChatView({
|
||||
if (launched.assignedConversationId) {
|
||||
onConversationIdRef.current(launched.assignedConversationId);
|
||||
}
|
||||
if (launched.transcriptUnavailableReason) {
|
||||
setTranscriptUnavailableReason(launched.transcriptUnavailableReason);
|
||||
}
|
||||
try {
|
||||
const recoveredSessionId = await reattachStructuredSession(launched.sessionId, {
|
||||
applyScrollback: options.applyScrollback,
|
||||
@ -1771,6 +1787,7 @@ export function CustomAgentChatView({
|
||||
});
|
||||
setOpening(true);
|
||||
setError(null);
|
||||
setTranscriptUnavailableReason(null);
|
||||
try {
|
||||
const localSession = sessionRef.current;
|
||||
if (!externalSessionId && localSession) {
|
||||
@ -1875,7 +1892,11 @@ export function CustomAgentChatView({
|
||||
sessionId: externalSessionId,
|
||||
error: instrumentationError(e),
|
||||
});
|
||||
if (!cancelled) {
|
||||
if (!cancelled && conversationId && isNotFound(e)) {
|
||||
setTranscriptUnavailableReason("conversationNotFound");
|
||||
setCurrentSession(null);
|
||||
publishSessionId(null);
|
||||
} else if (!cancelled) {
|
||||
setError({ message: describeOpenFailure(e), openFailure: true });
|
||||
}
|
||||
} finally {
|
||||
@ -1909,10 +1930,37 @@ export function CustomAgentChatView({
|
||||
);
|
||||
const hasLinkedConversation = Boolean(
|
||||
conversationId ||
|
||||
transcriptUnavailableReason === "missingTranscript" ||
|
||||
transcriptUnavailableReason === "conversationNotFound" ||
|
||||
requesterDelegation.currentAgentBusy ||
|
||||
restart ||
|
||||
delegationActive,
|
||||
);
|
||||
const unavailableLinkedTranscript =
|
||||
displayTurns.length === 0 &&
|
||||
hasLinkedConversation &&
|
||||
transcriptUnavailableReason !== "emptyConversation"
|
||||
? transcriptUnavailableReason ?? "missingTranscript"
|
||||
: null;
|
||||
|
||||
function unlinkUnavailableConversation() {
|
||||
onConversationIdRef.current(null);
|
||||
setTranscriptUnavailableReason("emptyConversation");
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function retryUnavailableTranscript() {
|
||||
const sid = sessionRef.current;
|
||||
setTranscriptUnavailableReason(null);
|
||||
setError(null);
|
||||
if (sid) {
|
||||
// Force the next user-triggered pass through the attach path. Keeping the
|
||||
// local session here would trip the #307/idempotency short-circuit.
|
||||
setCurrentSession(null);
|
||||
setExternalSessionId(sid);
|
||||
}
|
||||
setOpenRetryNonce((value) => value + 1);
|
||||
}
|
||||
|
||||
async function openProfileDialog() {
|
||||
const availableProfiles = (await profileGateway.listProfiles()).filter((candidate) =>
|
||||
@ -2490,10 +2538,30 @@ export function CustomAgentChatView({
|
||||
{externalSessionId ? "Reprise en cours..." : "Ouverture de la session structurée…"}
|
||||
</span>
|
||||
</div>
|
||||
) : displayTurns.length === 0 && hasLinkedConversation ? (
|
||||
<p className="m-auto text-xs text-muted">
|
||||
Conversation liée — transcript indisponible.
|
||||
</p>
|
||||
) : unavailableLinkedTranscript ? (
|
||||
<div className="m-auto flex max-w-md flex-col items-center gap-3 rounded-md border border-warning/40 bg-warning/10 p-4 text-center">
|
||||
<p className="text-sm font-medium text-content">
|
||||
La conversation liée n’a pas pu être restaurée.
|
||||
</p>
|
||||
<p className="text-xs text-muted">
|
||||
{unavailableLinkedTranscript === "conversationNotFound"
|
||||
? "Cette conversation est introuvable dans le stockage disponible."
|
||||
: "Son transcript n’est plus disponible après la reprise."}
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
disabled={opening}
|
||||
onClick={retryUnavailableTranscript}
|
||||
>
|
||||
Réessayer
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={unlinkUnavailableConversation}>
|
||||
Délier la conversation
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : displayTurns.length === 0 ? (
|
||||
<p className="m-auto text-xs text-muted">
|
||||
Envoyez un message pour démarrer la conversation structurée.
|
||||
|
||||
@ -103,6 +103,7 @@ const PANEL_SIZE: Record<PanelId, FloatingWindowSize> = {
|
||||
context: "lg",
|
||||
projectSettings: "lg",
|
||||
work: "lg",
|
||||
scheduledTasks: "xl",
|
||||
tickets: "lg",
|
||||
agents: "lg",
|
||||
templates: "lg",
|
||||
@ -649,6 +650,7 @@ export function ProjectsView() {
|
||||
"projectSettings",
|
||||
"context",
|
||||
"work",
|
||||
"scheduledTasks",
|
||||
"tickets",
|
||||
"agents",
|
||||
"templates",
|
||||
@ -865,6 +867,7 @@ export function ProjectsView() {
|
||||
projectId={active.id}
|
||||
projectRoot={active.root}
|
||||
onOpenConversation={openConversation}
|
||||
onOpenWork={() => setPlacement("work", "floating")}
|
||||
/>
|
||||
);
|
||||
return panel === "agents" ? (
|
||||
|
||||
@ -18,6 +18,7 @@ import { MemoryPanel } from "@/features/memory";
|
||||
import { EmbedderSettings } from "@/features/embedder";
|
||||
import { McpPanel, PermissionsPanel } from "@/features/permissions";
|
||||
import { ProjectWorkStatePanel } from "@/features/workstate";
|
||||
import { ScheduledTasksPanel } from "@/features/scheduled-tasks";
|
||||
import { TicketsView } from "@/features/tickets";
|
||||
import { GitPanel } from "@/features/git";
|
||||
import { ProjectContextPanel } from "./ProjectContextPanel";
|
||||
@ -34,6 +35,8 @@ export interface ViewPanelBodyProps {
|
||||
projectRoot: string;
|
||||
/** Optional: open a conversation viewer (only wired inside the main window). */
|
||||
onOpenConversation?: (conversationId: string) => void;
|
||||
/** Optional main-window navigation to the run details in Work. */
|
||||
onOpenWork?: () => void;
|
||||
}
|
||||
|
||||
/** Renders the requested view panel for the given project. */
|
||||
@ -42,6 +45,7 @@ export function ViewPanelBody({
|
||||
projectId,
|
||||
projectRoot,
|
||||
onOpenConversation,
|
||||
onOpenWork,
|
||||
}: ViewPanelBodyProps): ReactNode {
|
||||
switch (panel) {
|
||||
case "context":
|
||||
@ -55,6 +59,8 @@ export function ViewPanelBody({
|
||||
onOpenConversation={onOpenConversation}
|
||||
/>
|
||||
);
|
||||
case "scheduledTasks":
|
||||
return <ScheduledTasksPanel projectId={projectId} onOpenWork={onOpenWork} />;
|
||||
case "tickets":
|
||||
return <TicketsView projectId={projectId} />;
|
||||
case "agents":
|
||||
|
||||
@ -25,6 +25,7 @@ export type PanelId =
|
||||
| "context"
|
||||
| "projectSettings"
|
||||
| "work"
|
||||
| "scheduledTasks"
|
||||
| "tickets"
|
||||
| "agents"
|
||||
| "templates"
|
||||
@ -53,6 +54,7 @@ export const PANEL_TITLE: Record<PanelId, string> = {
|
||||
context: "Project context",
|
||||
projectSettings: "Paramètres du projet",
|
||||
work: "Work state",
|
||||
scheduledTasks: "Tâches planifiées",
|
||||
tickets: "Tickets",
|
||||
agents: "Agents",
|
||||
templates: "Templates",
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
146
frontend/src/features/scheduled-tasks/ScheduledTasksPanel.tsx
Normal file
146
frontend/src/features/scheduled-tasks/ScheduledTasksPanel.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import type { BackgroundCompletion, ScheduledProjectTask } from "@/domain";
|
||||
import { Button, FloatingWindow, Panel, Spinner, cn } from "@/shared";
|
||||
import { exactCommand, scheduleLabel, ScheduledTaskForm } from "./ScheduledTaskForm";
|
||||
import { useScheduledTasks } from "./useScheduledTasks";
|
||||
|
||||
type Editor = { kind: "create" } | { kind: "edit"; task: ScheduledProjectTask };
|
||||
|
||||
function dateTime(value?: number | null): string {
|
||||
if (value == null) return "Indisponible";
|
||||
return new Intl.DateTimeFormat("fr-FR", { dateStyle: "medium", timeStyle: "short" }).format(value);
|
||||
}
|
||||
|
||||
function duration(run: BackgroundCompletion): string {
|
||||
if (run.createdAtMs && run.updatedAtMs >= run.createdAtMs && !["running", "pending", "cancelling"].includes(run.status)) {
|
||||
const seconds = Math.round((run.updatedAtMs - run.createdAtMs) / 1000);
|
||||
return seconds < 60 ? `${seconds} s` : `${Math.round(seconds / 60)} min`;
|
||||
}
|
||||
return run.status === "running" || run.status === "pending" || run.status === "cancelling"
|
||||
? "En cours"
|
||||
: "Terminée";
|
||||
}
|
||||
|
||||
function displayStatus(task: ScheduledProjectTask, run?: BackgroundCompletion) {
|
||||
if (task.approval === "pending") return { label: "À approuver", tone: "warning" } as const;
|
||||
if (run && ["running", "pending", "cancelling"].includes(run.status)) return { label: "En cours", tone: "primary" } as const;
|
||||
if (run?.status === "failed" || run?.status === "cancelFailed") {
|
||||
const inaccessible = `${run.summary ?? ""} ${run.stderrTail ?? ""}`.toLowerCase().includes("inaccessible");
|
||||
return { label: inaccessible ? "Projet inaccessible" : "Échec", tone: "danger" } as const;
|
||||
}
|
||||
if (!task.enabled) return { label: "Suspendue", tone: "muted" } as const;
|
||||
return { label: "Active", tone: "success" } as const;
|
||||
}
|
||||
|
||||
function actorLabel(task: ScheduledProjectTask, names: ReadonlyMap<string, string>): string {
|
||||
if (task.createdBy.kind === "agent") {
|
||||
return `Créée par ${names.get(task.createdBy.agentId) ?? task.createdBy.agentId}`;
|
||||
}
|
||||
return task.createdBy.kind === "human" ? "Créée par vous" : "Créée par IdeA";
|
||||
}
|
||||
|
||||
export function ScheduledTasksPanel({
|
||||
projectId,
|
||||
onOpenWork,
|
||||
}: {
|
||||
projectId: string;
|
||||
onOpenWork?: () => void;
|
||||
}) {
|
||||
const vm = useScheduledTasks(projectId);
|
||||
const [editor, setEditor] = useState<Editor | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ScheduledProjectTask | null>(null);
|
||||
const [approveTarget, setApproveTarget] = useState<ScheduledProjectTask | null>(null);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
async function remove(task: ScheduledProjectTask, stop = false) {
|
||||
if (stop) await vm.cancelRun(task);
|
||||
const removed = await vm.remove(task);
|
||||
if (removed) setDeleteTarget(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-content">Tâches planifiées</h2>
|
||||
<p className="mt-1 text-xs text-muted">IdeA doit fonctionner et le projet rester ouvert pour déclencher les tâches. Les occurrences manquées ne sont pas rattrapées en rafale.</p>
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => setEditor({ kind: "create" })}>Nouvelle tâche planifiée</Button>
|
||||
</div>
|
||||
|
||||
{vm.error && <div role="alert" className="flex items-center justify-between gap-3 rounded-md border border-danger/40 bg-danger/10 p-3 text-sm text-danger"><span><strong>{vm.error.toLowerCase().includes("inaccessible") ? "Projet inaccessible" : "Erreur"}</strong> — Impossible de charger ou modifier les tâches planifiées. {vm.error}</span><Button size="sm" onClick={() => void vm.refresh()}>Réessayer</Button></div>}
|
||||
{vm.tasks.some((task) => {
|
||||
const run = task.lastTaskId ? vm.runsById.get(task.lastTaskId) : undefined;
|
||||
return run?.status === "failed" || run?.status === "cancelFailed";
|
||||
}) && <div role="status" className="rounded-md border border-danger/30 bg-danger/5 px-3 py-2 text-sm text-danger">Une tâche planifiée a échoué. Utilisez « Voir les détails » sur la tâche concernée.</div>}
|
||||
{vm.loading && vm.tasks.length === 0 ? <div className="flex justify-center p-8"><Spinner /></div> : vm.tasks.length === 0 ? (
|
||||
<Panel><p className="text-sm font-medium text-content">Aucune tâche planifiée pour ce projet</p><p className="mt-1 text-sm text-muted">Créez une tâche pour automatiser une commande récurrente sans utiliser le cron du système.</p></Panel>
|
||||
) : (
|
||||
<ul className="space-y-3" aria-label="Tâches planifiées du projet">
|
||||
{vm.tasks.map((task) => {
|
||||
const run = task.lastTaskId ? vm.runsById.get(task.lastTaskId) : undefined;
|
||||
const status = displayStatus(task, run);
|
||||
const requestingAgentId = task.createdBy.kind === "agent" ? task.createdBy.agentId : null;
|
||||
const pendingAgent = task.approval === "pending" && requestingAgentId !== null;
|
||||
const running = run && ["running", "pending", "cancelling"].includes(run.status);
|
||||
return <li key={task.id} className="rounded-lg border border-border bg-surface p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2"><span className="font-medium text-content">{task.name}</span><span className={cn("rounded-full px-2 py-0.5 text-xs font-medium", status.tone === "danger" && "bg-danger/10 text-danger", status.tone === "warning" && "bg-warning/10 text-warning", status.tone === "success" && "bg-success/10 text-success", status.tone === "primary" && "bg-primary/10 text-primary", status.tone === "muted" && "bg-raised text-muted")}>{status.label}</span></div>
|
||||
<p className="mt-1 text-xs text-muted">{actorLabel(task, vm.agentNames)} · {scheduleLabel(task.schedule)} · {task.timezone}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{pendingAgent ? <><Button size="sm" variant="primary" onClick={() => setApproveTarget(task)}>Approuver et activer</Button><Button size="sm" onClick={() => setEditor({ kind: "edit", task })}>Modifier avant d’activer</Button><Button size="sm" variant="danger" loading={vm.busyId === task.id} onClick={() => void vm.setEnabled(task, false)}>Refuser</Button></> : <><Button size="sm" onClick={() => setEditor({ kind: "edit", task })}>Modifier</Button><Button size="sm" disabled={task.approval !== "approved" || Boolean(running)} loading={vm.busyId === task.id} onClick={() => void vm.runNow(task)}>Exécuter maintenant</Button><Button size="sm" loading={vm.busyId === task.id} onClick={() => void vm.setEnabled(task, !task.enabled)}>{task.enabled ? "Suspendre" : "Reprendre"}</Button><Button size="sm" variant="danger" onClick={() => setDeleteTarget(task)}>Supprimer</Button></>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pendingAgent && <div className="mt-3 rounded-md border border-warning/50 bg-warning/10 p-3 text-sm"><p className="font-medium">Demandée par {vm.agentNames.get(requestingAgentId) ?? requestingAgentId}</p><p className="mt-1 text-muted">{dateTime(task.createdAtMs)}{task.description ? ` · Motif : ${task.description}` : " · Aucun motif fourni"}</p><p className="mt-2 text-xs">Cette tâche ne s’exécutera pas avant votre approbation.</p></div>}
|
||||
|
||||
<dl className="mt-3 grid gap-x-4 gap-y-1 text-xs sm:grid-cols-[8rem_1fr_8rem_1fr]">
|
||||
<dt className="text-muted">Commande</dt><dd className="break-all font-mono text-content">{exactCommand({ name: task.name, command: task.command, schedule: task.schedule, timezone: task.timezone, enabled: task.enabled })}</dd>
|
||||
<dt className="text-muted">Dossier</dt><dd className="font-mono">{task.command.cwd ?? "."} (projet)</dd>
|
||||
<dt className="text-muted">Prochaine exécution</dt><dd>{dateTime(task.nextRunAtMs)}</dd>
|
||||
<dt className="text-muted">Dernière exécution</dt><dd>{dateTime(task.lastRunAtMs)}</dd>
|
||||
<dt className="text-muted">Permissions</dt><dd>Modifier/supprimer dans le projet</dd>
|
||||
<dt className="text-muted">Sandbox</dt><dd>Projet — lecture/écriture; réseau non isolé en V1</dd>
|
||||
<dt className="text-muted">Limite de durée</dt><dd>{task.timeoutMs ? `${Math.round(task.timeoutMs / 60_000)} min` : "Aucune"}</dd>
|
||||
<dt className="text-muted">Déclenchement</dt><dd>{run?.origin?.kind === "scheduled" ? "Planifié" : run ? "Manuel" : "Aucune exécution"}</dd>
|
||||
</dl>
|
||||
{task.lastTaskId && <div className="mt-3 flex flex-wrap items-center gap-2"><Button size="sm" variant="ghost" onClick={() => setExpanded(expanded === task.id ? null : task.id)}>{expanded === task.id ? "Masquer les détails" : "Voir les détails"}</Button>{onOpenWork && <Button size="sm" variant="ghost" onClick={onOpenWork}>Ouvrir la tâche de fond {task.lastTaskId.slice(0, 8)}</Button>}</div>}
|
||||
{expanded === task.id && <div className="mt-2 rounded-md bg-raised p-3 text-xs"><p><span className="text-muted">BackgroundTask :</span> {task.lastTaskId}</p>{run ? <><p className="mt-1"><span className="text-muted">Statut :</span> {run.status} · {duration(run)} · code de sortie {run.exitCode ?? "indisponible"}</p><p className="mt-1"><span className="text-muted">Permissions réellement utilisées :</span> sandbox projet — lecture/écriture</p>{run.summary && <p className="mt-2 whitespace-pre-wrap">{run.summary}</p>}{run.stdoutTail && <pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded bg-canvas p-2">{run.stdoutTail}</pre>}{run.stderrTail && <pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded bg-danger/5 p-2 text-danger">{run.stderrTail}</pre>}</> : <p className="mt-1 text-muted">Les détails de cette exécution ne sont plus présents dans le WorkState courant.</p>}</div>}
|
||||
</li>;
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<FloatingWindow open={editor !== null} title={editor?.kind === "edit" ? (editor.task.approval === "pending" ? "Modifier avant d’activer" : "Modifier la tâche planifiée") : "Nouvelle tâche planifiée"} size="lg" onClose={() => setEditor(null)}>
|
||||
{editor && <ScheduledTaskForm task={editor.kind === "edit" ? editor.task : undefined} saving={vm.busyId === (editor.kind === "edit" ? editor.task.id : "new")} onCancel={() => setEditor(null)} onSubmit={(input) => void (editor.kind === "edit" ? vm.update(editor.task, input) : vm.create(input)).then((saved) => { if (saved) setEditor(null); })} />}
|
||||
</FloatingWindow>
|
||||
|
||||
<FloatingWindow open={deleteTarget !== null} title="Supprimer la tâche planifiée" size="sm" onClose={() => setDeleteTarget(null)}>
|
||||
{deleteTarget && (() => {
|
||||
const run = deleteTarget.lastTaskId ? vm.runsById.get(deleteTarget.lastTaskId) : undefined;
|
||||
const running = run && ["running", "pending", "cancelling"].includes(run.status);
|
||||
return <div className="space-y-4"><p className="text-sm">Supprimer définitivement cette tâche planifiée ?</p>{running ? <><p className="text-xs text-muted">Une exécution est en cours. La première action retire la planification mais laisse cette exécution se terminer.</p><div className="flex flex-col gap-2"><Button variant="danger" loading={vm.busyId === deleteTarget.id} onClick={() => void remove(deleteTarget)}>Supprimer après l’exécution</Button><Button variant="danger" loading={vm.busyId === deleteTarget.id} onClick={() => void remove(deleteTarget, true)}>Arrêter puis supprimer</Button><Button onClick={() => setDeleteTarget(null)}>Annuler</Button></div></> : <div className="flex justify-end gap-2"><Button onClick={() => setDeleteTarget(null)}>Annuler</Button><Button variant="danger" loading={vm.busyId === deleteTarget.id} onClick={() => void remove(deleteTarget)}>Supprimer définitivement</Button></div>}</div>;
|
||||
})()}
|
||||
</FloatingWindow>
|
||||
|
||||
<FloatingWindow open={approveTarget !== null} title="Approuver la tâche planifiée" size="md" onClose={() => setApproveTarget(null)}>
|
||||
{approveTarget && <div className="space-y-4">
|
||||
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-sm">Cette tâche pourra modifier ou supprimer des fichiers automatiquement.</div>
|
||||
<dl className="grid gap-2 text-sm sm:grid-cols-[8rem_1fr]">
|
||||
<dt className="text-muted">Commande exacte</dt><dd className="break-all font-mono">{exactCommand({ name: approveTarget.name, command: approveTarget.command, schedule: approveTarget.schedule, timezone: approveTarget.timezone, enabled: true })}</dd>
|
||||
<dt className="text-muted">Dossier</dt><dd>{approveTarget.command.cwd ?? "."} (relatif au projet)</dd>
|
||||
<dt className="text-muted">Fréquence</dt><dd>{scheduleLabel(approveTarget.schedule)}</dd>
|
||||
<dt className="text-muted">Fuseau</dt><dd>{approveTarget.timezone}</dd>
|
||||
<dt className="text-muted">Prochaine exécution</dt><dd>Calculée par IdeA lors de l’activation</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</dd>
|
||||
</dl>
|
||||
<div className="flex justify-end gap-2"><Button onClick={() => setApproveTarget(null)}>Annuler</Button><Button variant="primary" loading={vm.busyId === approveTarget.id} onClick={() => void vm.setEnabled(approveTarget, true).then((ok) => { if (ok) setApproveTarget(null); })}>Approuver et activer</Button></div>
|
||||
</div>}
|
||||
</FloatingWindow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
frontend/src/features/scheduled-tasks/index.ts
Normal file
2
frontend/src/features/scheduled-tasks/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { ScheduledTasksPanel } from "./ScheduledTasksPanel";
|
||||
export { exactCommand, scheduleLabel } from "./ScheduledTaskForm";
|
||||
@ -0,0 +1,64 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { DIProvider } from "@/app/di";
|
||||
import {
|
||||
MockAgentGateway,
|
||||
MockScheduledTaskGateway,
|
||||
MockSystemGateway,
|
||||
MockWorkStateGateway,
|
||||
} from "@/adapters/mock";
|
||||
import type { ScheduledProjectTask } from "@/domain";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { ScheduledTasksPanel } from "./ScheduledTasksPanel";
|
||||
|
||||
const PROJECT = "project-schedules";
|
||||
|
||||
function renderPanel(scheduledTask = new MockScheduledTaskGateway()) {
|
||||
const gateways = {
|
||||
scheduledTask,
|
||||
agent: new MockAgentGateway(),
|
||||
system: new MockSystemGateway(),
|
||||
workState: new MockWorkStateGateway(),
|
||||
} as unknown as Gateways;
|
||||
return { scheduledTask, ...render(<DIProvider gateways={gateways}><ScheduledTasksPanel projectId={PROJECT} /></DIProvider>) };
|
||||
}
|
||||
|
||||
describe("ScheduledTasksPanel", () => {
|
||||
it("shows the prescribed empty state and creates only after security review", async () => {
|
||||
const { scheduledTask } = renderPanel();
|
||||
expect(await screen.findByText("Aucune tâche planifiée pour ce projet")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Nouvelle tâche planifiée" }));
|
||||
const dialog = screen.getByRole("dialog", { name: "Nouvelle tâche planifiée" });
|
||||
fireEvent.change(within(dialog).getByLabelText("Nom"), { target: { value: "Nettoyage" } });
|
||||
fireEvent.change(within(dialog).getByLabelText("Commande"), { target: { value: "npm" } });
|
||||
fireEvent.change(within(dialog).getByLabelText("Arguments"), { target: { value: "run\nclean" } });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Vérifier avant activation" }));
|
||||
expect(within(dialog).getByText("Cette tâche pourra modifier ou supprimer des fichiers automatiquement.")).toBeTruthy();
|
||||
expect(within(dialog).getByText("npm run clean")).toBeTruthy();
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Créer la tâche" }));
|
||||
await waitFor(async () => expect(await scheduledTask.list(PROJECT)).toHaveLength(1));
|
||||
expect(await screen.findByText("Nettoyage")).toBeTruthy();
|
||||
expect(screen.getByText("Active")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("surfaces an agent proposal and supports refusal", async () => {
|
||||
const scheduledTask = new MockScheduledTaskGateway();
|
||||
const now = Date.now();
|
||||
const pending: ScheduledProjectTask = {
|
||||
id: "agent-request", projectId: PROJECT, name: "Purge", description: "Limiter la taille du build",
|
||||
enabled: false, approval: "pending", sandbox: "projectReadWrite",
|
||||
command: { command: "find", args: ["dist", "-delete"] },
|
||||
schedule: { kind: "daily", time: "02:00" }, timezone: "Europe/Paris",
|
||||
nextRunAtMs: null, createdBy: { kind: "agent", agentId: "agent-1" },
|
||||
updatedBy: { kind: "agent", agentId: "agent-1" }, createdAtMs: now,
|
||||
updatedAtMs: now, auditRevision: 1,
|
||||
};
|
||||
scheduledTask._seed(pending);
|
||||
renderPanel(scheduledTask);
|
||||
expect(await screen.findByText("À approuver")).toBeTruthy();
|
||||
expect(screen.getByText(/Cette tâche ne s’exécutera pas avant votre approbation/)).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refuser" }));
|
||||
await waitFor(() => expect(screen.getByText("Suspendue")).toBeTruthy());
|
||||
});
|
||||
});
|
||||
109
frontend/src/features/scheduled-tasks/useScheduledTasks.ts
Normal file
109
frontend/src/features/scheduled-tasks/useScheduledTasks.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { useGateways } from "@/app/di";
|
||||
import type { Agent, BackgroundCompletion, GatewayError, ScheduledProjectTask } from "@/domain";
|
||||
import type { ScheduledTaskDefinitionInput } from "@/ports";
|
||||
|
||||
function message(error: unknown): string {
|
||||
return error && typeof error === "object" && "message" in error
|
||||
? String((error as GatewayError).message)
|
||||
: String(error);
|
||||
}
|
||||
|
||||
export function useScheduledTasks(projectId: string) {
|
||||
const { scheduledTask, workState, agent, system } = useGateways();
|
||||
const [tasks, setTasks] = useState<ScheduledProjectTask[]>([]);
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [runs, setRuns] = useState<BackgroundCompletion[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [definitions, roster, state] = await Promise.all([
|
||||
scheduledTask.list(projectId),
|
||||
agent.listAgents(projectId).catch(() => []),
|
||||
workState.getProjectWorkState(projectId).catch(() => null),
|
||||
]);
|
||||
setTasks(definitions);
|
||||
setAgents(roster);
|
||||
setRuns(state?.agents.flatMap((item) => item.backgroundTasks ?? []) ?? []);
|
||||
setError(null);
|
||||
} catch (cause) {
|
||||
setError(message(cause));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [agent, projectId, scheduledTask, workState]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
void system.onDomainEvent((event) => {
|
||||
if (event.type === "backgroundTaskChanged" && event.projectId === projectId) {
|
||||
void refresh();
|
||||
}
|
||||
}).then((stop) => {
|
||||
if (cancelled) stop();
|
||||
else unsubscribe = stop;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [projectId, refresh, system]);
|
||||
|
||||
const runsById = useMemo(
|
||||
() => new Map(runs.map((run) => [run.taskId, run])),
|
||||
[runs],
|
||||
);
|
||||
const agentNames = useMemo(
|
||||
() => new Map(agents.map((item) => [item.id, item.name])),
|
||||
[agents],
|
||||
);
|
||||
|
||||
async function mutate(id: string, action: () => Promise<unknown>): Promise<boolean> {
|
||||
setBusyId(id);
|
||||
setError(null);
|
||||
try {
|
||||
await action();
|
||||
await refresh();
|
||||
return true;
|
||||
} catch (cause) {
|
||||
setError(message(cause));
|
||||
return false;
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tasks,
|
||||
loading,
|
||||
busyId,
|
||||
error,
|
||||
runsById,
|
||||
agentNames,
|
||||
refresh,
|
||||
create: (input: ScheduledTaskDefinitionInput) =>
|
||||
mutate("new", () => scheduledTask.create(projectId, input)),
|
||||
update: (task: ScheduledProjectTask, input: ScheduledTaskDefinitionInput) =>
|
||||
mutate(task.id, () => scheduledTask.update(projectId, task.id, task.auditRevision, input)),
|
||||
remove: (task: ScheduledProjectTask) =>
|
||||
mutate(task.id, () => scheduledTask.delete(projectId, task.id)),
|
||||
runNow: (task: ScheduledProjectTask) =>
|
||||
mutate(task.id, () => scheduledTask.runNow(projectId, task.id)),
|
||||
setEnabled: (task: ScheduledProjectTask, enabled: boolean) =>
|
||||
mutate(task.id, () => scheduledTask.setEnabled(projectId, task.id, enabled)),
|
||||
cancelRun: (task: ScheduledProjectTask) =>
|
||||
task.lastTaskId
|
||||
? mutate(task.id, () => workState.cancelBackgroundTask(task.lastTaskId!))
|
||||
: Promise.resolve(undefined),
|
||||
};
|
||||
}
|
||||
@ -29,6 +29,7 @@ import type {
|
||||
} from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { useProjectWorkState } from "@/features/workstate/useProjectWorkState";
|
||||
import { ScheduledTasksPanel } from "@/features/scheduled-tasks";
|
||||
import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
||||
import { ProjectContextPanel } from "@/features/projects/ProjectContextPanel";
|
||||
import { isDockedTo, type ViewPlacement } from "@/features/projects/viewPlacement";
|
||||
@ -204,6 +205,7 @@ const WEB_PANEL_SIZE: Record<WebDesktopPanelId, FloatingWindowSize> = {
|
||||
projects: "md",
|
||||
context: "lg",
|
||||
work: "lg",
|
||||
scheduledTasks: "xl",
|
||||
tickets: "lg",
|
||||
sprints: "lg",
|
||||
agents: "lg",
|
||||
@ -332,6 +334,7 @@ function WebDesktopWorkspace({
|
||||
const panelOrder: WebDesktopPanelId[] = [
|
||||
"context",
|
||||
"work",
|
||||
"scheduledTasks",
|
||||
"tickets",
|
||||
"sprints",
|
||||
"agents",
|
||||
@ -867,6 +870,8 @@ export function WebProjectPanelBody({
|
||||
return <ProjectContextPanel projectId={projectId} />;
|
||||
case "work":
|
||||
return <LiveProjectPanel projectId={projectId} root={root} mobile={mobile} />;
|
||||
case "scheduledTasks":
|
||||
return <ScheduledTasksPanel projectId={projectId} />;
|
||||
case "tickets":
|
||||
return <WebTicketsView projectId={projectId} focusSprintId={focusSprintId} />;
|
||||
case "sprints":
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
export type WebProjectPanelId =
|
||||
| "context"
|
||||
| "work"
|
||||
| "scheduledTasks"
|
||||
| "tickets"
|
||||
| "sprints"
|
||||
| "agents"
|
||||
@ -31,6 +32,7 @@ export interface WebPanelMeta {
|
||||
export const WEB_PANEL_ORDER: WebProjectPanelId[] = [
|
||||
"context",
|
||||
"work",
|
||||
"scheduledTasks",
|
||||
"tickets",
|
||||
"sprints",
|
||||
"agents",
|
||||
@ -59,6 +61,13 @@ export const WEB_PANEL_META: Record<WebProjectPanelId, WebPanelMeta> = {
|
||||
description: "État live des agents et tâches",
|
||||
placement: "primary",
|
||||
},
|
||||
scheduledTasks: {
|
||||
id: "scheduledTasks",
|
||||
label: "Tâches planifiées",
|
||||
shortLabel: "Planifiées",
|
||||
description: "Automatisations récurrentes du projet",
|
||||
placement: "primary",
|
||||
},
|
||||
tickets: {
|
||||
id: "tickets",
|
||||
label: "Tickets",
|
||||
|
||||
Reference in New Issue
Block a user