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

@ -328,6 +328,7 @@ describe("TauriAgentGateway invoke payloads", () => {
cols: 80,
cellKind: "chat",
assignedConversationId: "conversation-1",
transcriptUnavailableReason: "missingTranscript",
});
const out = await new TauriAgentGateway().launchAgentChat("proj-1", "agent-2", {
@ -355,6 +356,7 @@ describe("TauriAgentGateway invoke payloads", () => {
sessionId: "chat-session-1",
cellKind: "chat",
assignedConversationId: "conversation-1",
transcriptUnavailableReason: "missingTranscript",
});
});

View File

@ -52,6 +52,7 @@ interface LaunchAgentResponse {
cellKind?: "chat" | "pty";
/** Conversation id minted by this launch (omitted when nothing was assigned). */
assignedConversationId?: string;
transcriptUnavailableReason?: AgentChatHandle["transcriptUnavailableReason"];
}
function structuredLaunchRoutedToPtyError(
@ -269,6 +270,9 @@ export class TauriAgentGateway implements AgentGateway {
...(res.assignedConversationId
? { assignedConversationId: res.assignedConversationId }
: {}),
...(res.transcriptUnavailableReason
? { transcriptUnavailableReason: res.transcriptUnavailableReason }
: {}),
};
}

View File

@ -294,12 +294,14 @@ describe("HttpAgentGateway WS round-trip (B6 frames)", () => {
sessionId: "chat-1",
cellKind: "chat",
assignedConversationId: "conv-1",
transcriptUnavailableReason: "emptyConversation",
},
}));
await expect(launched).resolves.toEqual({
sessionId: "chat-1",
cellKind: "chat",
assignedConversationId: "conv-1",
transcriptUnavailableReason: "emptyConversation",
});
expect(launch.kind).toBe("agent.launch");
expect(launch.payload).toEqual({
@ -324,12 +326,14 @@ describe("HttpAgentGateway WS round-trip (B6 frames)", () => {
sessionId: "chat-1",
conversationId: "conv-replacement",
scrollback: [{ kind: "final", content: "Recovered reply" }],
transcriptUnavailableReason: "missingTranscript",
},
}));
await expect(reattached).resolves.toEqual({
sessionId: "chat-1",
conversationId: "conv-replacement",
scrollback: [{ kind: "final", content: "Recovered reply" }],
transcriptUnavailableReason: "missingTranscript",
});
expect(attach.kind).toBe("chat.attach");
expect(attach.payload).toEqual({

View File

@ -155,4 +155,5 @@ export interface ChatAttachedPayload {
conversationId?: string;
engineSessionId?: string;
scrollback?: ReplyChunk[];
transcriptUnavailableReason?: "missingTranscript" | "emptyConversation";
}

View File

@ -35,6 +35,7 @@ import {
HttpSkillGateway,
HttpTemplateGateway,
HttpWorkStateGateway,
HttpScheduledTaskGateway,
} from "./requestResponseGateways";
import {
HttpAgentGateway,
@ -140,6 +141,7 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway
device: new HttpDeviceGateway(http),
permission: new HttpPermissionGateway(http),
workState: new HttpWorkStateGateway(http),
scheduledTask: new HttpScheduledTaskGateway(http),
conversation: new HttpConversationGateway(http),
ticket: new HttpTicketGateway(http, ws),
window: new WebWindowGateway(),

View File

@ -49,6 +49,7 @@ import type {
ProfileModelCatalog,
ResolvedAgentPermissions,
ResolvedAgentSystemPermissions,
ScheduledProjectTask,
SystemPermissionSet,
Skill,
SkillScope,
@ -78,6 +79,8 @@ import type {
SkillGateway,
TemplateGateway,
WorkStateGateway,
ScheduledTaskDefinitionInput,
ScheduledTaskGateway,
BackgroundTaskAttachment,
} from "@/ports";
import { normalizeProjectWorkState } from "../workStateNormalization";
@ -577,6 +580,45 @@ export class HttpWorkStateGateway implements WorkStateGateway {
}
}
export class HttpScheduledTaskGateway implements ScheduledTaskGateway {
constructor(private readonly http: HttpInvoker) {}
list(projectId: string): Promise<ScheduledProjectTask[]> {
return this.http.invoke("list_scheduled_project_tasks", { projectId });
}
create(projectId: string, input: ScheduledTaskDefinitionInput): Promise<ScheduledProjectTask> {
return this.http.invoke("create_scheduled_project_task", {
request: { projectId, ...input },
});
}
update(
projectId: string,
scheduledTaskId: string,
auditRevision: number,
input: ScheduledTaskDefinitionInput,
): Promise<ScheduledProjectTask> {
return this.http.invoke("update_scheduled_project_task", {
request: { projectId, scheduledTaskId, auditRevision, ...input },
});
}
async delete(projectId: string, scheduledTaskId: string): Promise<void> {
await this.http.invoke("delete_scheduled_project_task", { projectId, scheduledTaskId });
}
runNow(projectId: string, scheduledTaskId: string): Promise<ScheduledProjectTask> {
return this.http.invoke("run_scheduled_project_task_now", { projectId, scheduledTaskId });
}
setEnabled(
projectId: string,
scheduledTaskId: string,
enabled: boolean,
): Promise<ScheduledProjectTask> {
return this.http.invoke("set_scheduled_project_task_enabled", {
projectId,
scheduledTaskId,
enabled,
});
}
}
export class HttpConversationGateway implements ConversationGateway {
constructor(private readonly http: HttpInvoker) {}
async readPage(

View File

@ -0,0 +1,19 @@
import { describe, expect, it, vi } from "vitest";
import type { HttpInvoker } from "./httpInvoker";
import { HttpScheduledTaskGateway } from "./requestResponseGateways";
describe("HttpScheduledTaskGateway", () => {
it("forwards the shared HTTP command contract", async () => {
const invoke = vi.fn().mockResolvedValue([]);
const gateway = new HttpScheduledTaskGateway({ invoke } as unknown as HttpInvoker);
await gateway.list("project-1");
await gateway.setEnabled("project-1", "schedule-1", true);
expect(invoke).toHaveBeenNthCalledWith(1, "list_scheduled_project_tasks", { projectId: "project-1" });
expect(invoke).toHaveBeenNthCalledWith(2, "set_scheduled_project_task_enabled", {
projectId: "project-1",
scheduledTaskId: "schedule-1",
enabled: true,
});
});
});

View File

@ -375,6 +375,9 @@ export class HttpAgentGateway implements AgentGateway {
...(payload.assignedConversationId
? { assignedConversationId: payload.assignedConversationId }
: {}),
...(payload.transcriptUnavailableReason
? { transcriptUnavailableReason: payload.transcriptUnavailableReason }
: {}),
};
}
@ -404,6 +407,9 @@ export class HttpAgentGateway implements AgentGateway {
}
: {}),
scrollback: payload.scrollback ?? [],
...(payload.transcriptUnavailableReason
? { transcriptUnavailableReason: payload.transcriptUnavailableReason }
: {}),
};
}

View File

@ -29,6 +29,7 @@ import { TauriDeviceGateway } from "./device";
import { TauriGitGateway } from "./git";
import { TauriPermissionGateway } from "./permission";
import { TauriWorkStateGateway } from "./workState";
import { TauriScheduledTaskGateway } from "./scheduledTask";
import { TauriConversationGateway } from "./conversation";
import { TauriTicketGateway } from "./ticket";
import { TauriWindowGateway } from "./window";
@ -78,6 +79,7 @@ export function createTauriGateways(): Gateways {
device: new TauriDeviceGateway(),
permission: new TauriPermissionGateway(),
workState: new TauriWorkStateGateway(),
scheduledTask: new TauriScheduledTaskGateway(),
conversation: new TauriConversationGateway(),
ticket: new TauriTicketGateway(),
window: new TauriWindowGateway(),
@ -112,6 +114,7 @@ export {
TauriGitGateway,
TauriPermissionGateway,
TauriWorkStateGateway,
TauriScheduledTaskGateway,
TauriConversationGateway,
TauriTicketGateway,
TauriWindowGateway,

View File

@ -83,6 +83,7 @@ import type {
ResumableAgent,
ResolvedAgentPermissions,
ResolvedAgentSystemPermissions,
ScheduledProjectTask,
ServerExposurePreview,
ServerExposureSettings,
Skill,
@ -168,6 +169,8 @@ import type {
SetProjectPluginEnabledInput,
SaveOpenCodeProviderProfileInput,
SendAgentChatOptions,
ScheduledTaskDefinitionInput,
ScheduledTaskGateway,
SkillGateway,
StoppedLiveAgent,
SystemGateway,
@ -3247,6 +3250,112 @@ export class MockPermissionGateway implements PermissionGateway {
}
}
export class MockScheduledTaskGateway implements ScheduledTaskGateway {
private readonly tasks = new Map<string, ScheduledProjectTask[]>();
private nextId = 1;
/** Test/dev helper for agent-originated pending proposals. */
_seed(task: ScheduledProjectTask): void {
this.tasks.set(task.projectId, [
...(this.tasks.get(task.projectId) ?? []).filter((item) => item.id !== task.id),
structuredClone(task),
]);
}
async list(projectId: string): Promise<ScheduledProjectTask[]> {
return structuredClone(this.tasks.get(projectId) ?? []);
}
async create(
projectId: string,
input: ScheduledTaskDefinitionInput,
): Promise<ScheduledProjectTask> {
const now = Date.now();
const task: ScheduledProjectTask = {
id: `mock-scheduled-${this.nextId++}`,
projectId,
...structuredClone(input),
approval: "approved",
sandbox: "projectReadWrite",
nextRunAtMs: input.enabled ? now + 3_600_000 : null,
createdBy: { kind: "human" },
updatedBy: { kind: "human" },
createdAtMs: now,
updatedAtMs: now,
auditRevision: 1,
};
this.tasks.set(projectId, [...(this.tasks.get(projectId) ?? []), task]);
return structuredClone(task);
}
async update(
projectId: string,
scheduledTaskId: string,
auditRevision: number,
input: ScheduledTaskDefinitionInput,
): Promise<ScheduledProjectTask> {
const rows = this.tasks.get(projectId) ?? [];
const index = rows.findIndex((task) => task.id === scheduledTaskId);
if (index < 0) throw { code: "NOT_FOUND", message: "scheduled task not found" };
if (rows[index].auditRevision !== auditRevision) {
throw { code: "INVALID", message: "scheduled task revision conflict" };
}
const updated: ScheduledProjectTask = {
...rows[index],
...structuredClone(input),
approval: "approved",
nextRunAtMs: input.enabled ? Date.now() + 3_600_000 : null,
updatedBy: { kind: "human" },
updatedAtMs: Date.now(),
auditRevision: auditRevision + 1,
};
rows[index] = updated;
return structuredClone(updated);
}
async delete(projectId: string, scheduledTaskId: string): Promise<void> {
this.tasks.set(
projectId,
(this.tasks.get(projectId) ?? []).filter((task) => task.id !== scheduledTaskId),
);
}
async runNow(projectId: string, scheduledTaskId: string): Promise<ScheduledProjectTask> {
const rows = this.tasks.get(projectId) ?? [];
const task = rows.find((row) => row.id === scheduledTaskId);
if (!task) throw { code: "NOT_FOUND", message: "scheduled task not found" };
const updated = {
...task,
lastRunAtMs: Date.now(),
lastTaskId: `mock-background-${scheduledTaskId}`,
auditRevision: task.auditRevision + 1,
};
rows[rows.indexOf(task)] = updated;
return structuredClone(updated);
}
async setEnabled(
projectId: string,
scheduledTaskId: string,
enabled: boolean,
): Promise<ScheduledProjectTask> {
const rows = this.tasks.get(projectId) ?? [];
const task = rows.find((row) => row.id === scheduledTaskId);
if (!task) throw { code: "NOT_FOUND", message: "scheduled task not found" };
const updated: ScheduledProjectTask = {
...task,
enabled,
approval: enabled ? "approved" : task.approval === "pending" ? "rejected" : task.approval,
nextRunAtMs: enabled ? Date.now() + 3_600_000 : null,
updatedBy: { kind: "human" },
updatedAtMs: Date.now(),
auditRevision: task.auditRevision + 1,
};
rows[rows.indexOf(task)] = updated;
return structuredClone(updated);
}
}
export class MockWorkStateGateway implements WorkStateGateway {
private states = new Map<string, ProjectWorkState>();
private backgroundTaskStreams = new Map<
@ -4854,6 +4963,7 @@ export function createMockGateways(): Gateways {
device: new MockDeviceGateway(),
permission: new MockPermissionGateway(),
workState: new MockWorkStateGateway(),
scheduledTask: new MockScheduledTaskGateway(),
conversation: new MockConversationGateway(),
ticket: new MockTicketGateway(systemGateway),
window: new MockWindowGateway(),

View File

@ -36,6 +36,7 @@ describe("createMockGateways", () => {
"profile",
"project",
"remote",
"scheduledTask",
"skill",
"system",
"template",

View File

@ -0,0 +1,39 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const invoke = vi.fn();
vi.mock("@tauri-apps/api/core", () => ({ invoke: (...args: unknown[]) => invoke(...args) }));
import { TauriScheduledTaskGateway } from "./scheduledTask";
const input = {
name: "Nettoyage",
command: { command: "find", args: ["dist", "-delete"], cwd: "." },
schedule: { kind: "interval" as const, everyMinutes: 60 },
timezone: "Europe/Paris",
enabled: true,
timeoutMs: 60_000,
};
describe("TauriScheduledTaskGateway", () => {
beforeEach(() => invoke.mockReset().mockResolvedValue({}));
it("uses the backend command envelopes for list/create/update", async () => {
const gateway = new TauriScheduledTaskGateway();
await gateway.list("p1");
await gateway.create("p1", input);
await gateway.update("p1", "s1", 4, input);
expect(invoke).toHaveBeenNthCalledWith(1, "list_scheduled_project_tasks", { projectId: "p1" });
expect(invoke).toHaveBeenNthCalledWith(2, "create_scheduled_project_task", { request: { projectId: "p1", ...input } });
expect(invoke).toHaveBeenNthCalledWith(3, "update_scheduled_project_task", { request: { projectId: "p1", scheduledTaskId: "s1", auditRevision: 4, ...input } });
});
it("uses flat identity arguments for actions", async () => {
const gateway = new TauriScheduledTaskGateway();
await gateway.runNow("p1", "s1");
await gateway.setEnabled("p1", "s1", false);
await gateway.delete("p1", "s1");
expect(invoke).toHaveBeenNthCalledWith(1, "run_scheduled_project_task_now", { projectId: "p1", scheduledTaskId: "s1" });
expect(invoke).toHaveBeenNthCalledWith(2, "set_scheduled_project_task_enabled", { projectId: "p1", scheduledTaskId: "s1", enabled: false });
expect(invoke).toHaveBeenNthCalledWith(3, "delete_scheduled_project_task", { projectId: "p1", scheduledTaskId: "s1" });
});
});

View File

@ -0,0 +1,45 @@
import { invoke } from "@tauri-apps/api/core";
import type { ScheduledProjectTask } from "@/domain";
import type { ScheduledTaskDefinitionInput, ScheduledTaskGateway } from "@/ports";
export class TauriScheduledTaskGateway implements ScheduledTaskGateway {
list(projectId: string): Promise<ScheduledProjectTask[]> {
return invoke("list_scheduled_project_tasks", { projectId });
}
create(projectId: string, input: ScheduledTaskDefinitionInput): Promise<ScheduledProjectTask> {
return invoke("create_scheduled_project_task", { request: { projectId, ...input } });
}
update(
projectId: string,
scheduledTaskId: string,
auditRevision: number,
input: ScheduledTaskDefinitionInput,
): Promise<ScheduledProjectTask> {
return invoke("update_scheduled_project_task", {
request: { projectId, scheduledTaskId, auditRevision, ...input },
});
}
async delete(projectId: string, scheduledTaskId: string): Promise<void> {
await invoke("delete_scheduled_project_task", { projectId, scheduledTaskId });
}
runNow(projectId: string, scheduledTaskId: string): Promise<ScheduledProjectTask> {
return invoke("run_scheduled_project_task_now", { projectId, scheduledTaskId });
}
setEnabled(
projectId: string,
scheduledTaskId: string,
enabled: boolean,
): Promise<ScheduledProjectTask> {
return invoke("set_scheduled_project_task_enabled", {
projectId,
scheduledTaskId,
enabled,
});
}
}

View File

@ -114,12 +114,24 @@ function normalizeBackgroundTask(
task.conversationId ?? task.conversation_id,
);
const parentTaskId = optionalString(task.parentTaskId ?? task.parent_task_id);
const rawOrigin = isRecord(task.origin) ? task.origin : null;
const origin = rawOrigin?.kind === "scheduled"
? {
kind: "scheduled" as const,
scheduledTaskId: stringValue(rawOrigin.scheduledTaskId ?? rawOrigin.scheduled_task_id),
plannedAtMs: numberValue(rawOrigin.plannedAtMs ?? rawOrigin.planned_at_ms),
}
: rawOrigin?.kind === "manual"
? { kind: "manual" as const }
: undefined;
const createdAtMs = task.createdAtMs ?? task.created_at_ms;
return {
taskId: stringValue(task.taskId, `legacy-task-${index}`),
ownerAgentId: stringValue(task.ownerAgentId, fallbackAgentId),
projectId: stringValue(task.projectId),
...(parentTaskId ? { parentTaskId } : {}),
kind: stringValue(task.kind, "background"),
...(origin ? { origin } : {}),
status: normalizeBackgroundStatus(task.status ?? task.state),
exitCode: nullableNumber(task.exitCode),
summary: nullableString(task.summary),
@ -129,6 +141,7 @@ function normalizeBackgroundTask(
...(targetAgentId ? { targetAgentId } : {}),
...(conversationId ? { conversationId } : {}),
updatedAtMs: numberValue(task.updatedAtMs),
...(typeof createdAtMs === "number" ? { createdAtMs } : {}),
};
}

View File

@ -657,6 +657,57 @@ export interface GatewayError {
message: string;
}
// ---------------------------------------------------------------------------
// Scheduled project tasks (#305)
// ---------------------------------------------------------------------------
export type ScheduledTaskWeekday =
| "monday"
| "tuesday"
| "wednesday"
| "thursday"
| "friday"
| "saturday"
| "sunday";
export type ScheduledTaskSchedule =
| { kind: "interval"; everyMinutes: number }
| { kind: "daily"; time: string }
| { kind: "weekly"; days: ScheduledTaskWeekday[]; time: string };
export interface ScheduledTaskCommand {
command: string;
args: string[];
cwd?: string;
}
export type ScheduledTaskActor =
| { kind: "human" }
| { kind: "agent"; agentId: string }
| { kind: "system" };
export interface ScheduledProjectTask {
id: string;
projectId: string;
name: string;
description?: string;
enabled: boolean;
approval: "approved" | "pending" | "rejected";
sandbox: "projectReadWrite";
command: ScheduledTaskCommand;
schedule: ScheduledTaskSchedule;
timezone: string;
nextRunAtMs: number | null;
lastRunAtMs?: number;
lastTaskId?: string;
createdBy: ScheduledTaskActor;
updatedBy: ScheduledTaskActor;
createdAtMs: number;
updatedAtMs: number;
auditRevision: number;
timeoutMs?: number;
}
// ---------------------------------------------------------------------------
// Work state (UX conversations/delegations live read-model)
// ---------------------------------------------------------------------------
@ -707,6 +758,9 @@ export interface BackgroundCompletion {
projectId: string;
parentTaskId?: string;
kind: string;
origin?:
| { kind: "manual" }
| { kind: "scheduled"; scheduledTaskId: string; plannedAtMs: number };
status:
| "running"
| "cancelling"
@ -726,6 +780,7 @@ export interface BackgroundCompletion {
conversationId?: string;
/** Last-update timestamp (epoch ms); chronological ordering key. */
updatedAtMs: number;
createdAtMs?: number;
}
/** One item currently pending in an agent's inbox. */

View File

@ -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 () => {

View File

@ -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.

View File

@ -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" ? (

View File

@ -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":

View File

@ -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",

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>
);
}

View 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>
);
}

View File

@ -0,0 +1,2 @@
export { ScheduledTasksPanel } from "./ScheduledTasksPanel";
export { exactCommand, scheduleLabel } from "./ScheduledTaskForm";

View File

@ -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());
});
});

View 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),
};
}

View File

@ -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":

View File

@ -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",

View File

@ -81,6 +81,9 @@ import type {
ResumableAgent,
ResolvedAgentPermissions,
ResolvedAgentSystemPermissions,
ScheduledProjectTask,
ScheduledTaskCommand,
ScheduledTaskSchedule,
ReplyChunk,
SlashCommand,
ExecuteSlashCommandResult,
@ -203,8 +206,14 @@ export interface AgentChatHandle {
readonly cellKind: "chat";
/** Conversation id assigned by launch when the backend minted one. */
readonly assignedConversationId?: string;
/** Why a successful structured open has no replayable transcript. */
readonly transcriptUnavailableReason?: ConversationTranscriptUnavailableReason;
}
export type ConversationTranscriptUnavailableReason =
| "missingTranscript"
| "emptyConversation";
/** Result of re-attaching a custom chat view to a live structured session. */
export interface ReattachAgentChatResult {
/** Effective live structured session id (may replace the requested id after restart). */
@ -213,6 +222,8 @@ export interface ReattachAgentChatResult {
conversationId?: string;
/** Retained chunks already streamed for the session. */
scrollback: ReplyChunk[];
/** Additive reason when `scrollback` is empty. */
transcriptUnavailableReason?: ConversationTranscriptUnavailableReason;
}
/** Stable owner identity used to reconcile a stale transport id after restart. */
@ -1207,6 +1218,35 @@ export interface WorkStateGateway {
retryBackgroundTask(taskId: string): Promise<void>;
}
export interface ScheduledTaskDefinitionInput {
name: string;
description?: string;
command: ScheduledTaskCommand;
schedule: ScheduledTaskSchedule;
timezone: string;
enabled: boolean;
timeoutMs?: number;
}
/** Durable project-scoped schedules; concrete executions remain in WorkState. */
export interface ScheduledTaskGateway {
list(projectId: string): Promise<ScheduledProjectTask[]>;
create(projectId: string, input: ScheduledTaskDefinitionInput): Promise<ScheduledProjectTask>;
update(
projectId: string,
scheduledTaskId: string,
auditRevision: number,
input: ScheduledTaskDefinitionInput,
): Promise<ScheduledProjectTask>;
delete(projectId: string, scheduledTaskId: string): Promise<void>;
runNow(projectId: string, scheduledTaskId: string): Promise<ScheduledProjectTask>;
setEnabled(
projectId: string,
scheduledTaskId: string,
enabled: boolean,
): Promise<ScheduledProjectTask>;
}
/** One paginated page request over a conversation transcript (LS7). */
export interface ConversationPageRequest {
/** Turn id to paginate around; omit to start from a thread end. */
@ -1804,6 +1844,7 @@ export interface Gateways {
device: DeviceGateway;
permission: PermissionGateway;
workState: WorkStateGateway;
scheduledTask: ScheduledTaskGateway;
conversation: ConversationGateway;
ticket: TicketGateway;
window: WindowGateway;