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

@ -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(),