From 8509653e3c8fa18bd1cd235fb189c09ff4af9fe6 Mon Sep 17 00:00:00 2001 From: Blomios Date: Mon, 20 Jul 2026 19:16:29 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(frontend):=20popup=20de=20confirmation?= =?UTF-8?q?=20=C3=A0=20la=20fermeture=20avec=20travail=20en=20cours=20(#83?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Écoute l'event Tauri `app-exit-work-guard` (émis par le backend quand la fermeture de la fenêtre main est interceptée) et affiche une popup modale "Du travail est encore en cours" avec le résumé pluralisé exact du carnet #83, le détail agents/tâches capé à 5 lignes, et les deux actions Annuler (focus par défaut, no-op local) / Quitter quand même (danger, appelle confirm_app_exit). Pas d'option "ne plus demander". - domain/ports/adapters (Tauri listen+invoke, HTTP desktop-only stub, mock avec helpers de test) : onAppExitWorkGuard/confirmAppExit sur SystemGateway, suivant le patron déjà utilisé pour focused-project et les domain events. - AppExitConfirmDialog : mounted une fois près de la racine (App.tsx), à côté d'AnnouncementsProvider — role="alertdialog", focus trap, Échap = Annuler, pas de fermeture au clic extérieur, ne se referme jamais automatiquement (un event pendant l'ouverture rafraîchit juste le résumé). Co-Authored-By: Claude Sonnet 5 --- frontend/src/adapters/http/streamGateways.ts | 14 ++ frontend/src/adapters/mock/index.ts | 23 ++ frontend/src/adapters/system.ts | 18 +- frontend/src/app/App.tsx | 2 + frontend/src/domain/index.ts | 37 +++ .../appExit/AppExitConfirmDialog.test.tsx | 229 ++++++++++++++++++ .../features/appExit/AppExitConfirmDialog.tsx | 222 +++++++++++++++++ frontend/src/features/appExit/index.ts | 6 + frontend/src/ports/index.ts | 15 ++ 9 files changed, 565 insertions(+), 1 deletion(-) create mode 100644 frontend/src/features/appExit/AppExitConfirmDialog.test.tsx create mode 100644 frontend/src/features/appExit/AppExitConfirmDialog.tsx create mode 100644 frontend/src/features/appExit/index.ts diff --git a/frontend/src/adapters/http/streamGateways.ts b/frontend/src/adapters/http/streamGateways.ts index e3474bf..cb9d670 100644 --- a/frontend/src/adapters/http/streamGateways.ts +++ b/frontend/src/adapters/http/streamGateways.ts @@ -21,6 +21,7 @@ import type { Agent, + AppExitWorkGuardState, DomainEvent, HealthReport, ReplyChunk, @@ -114,6 +115,19 @@ export class HttpSystemGateway implements SystemGateway { // folder browser would be its own lot (flagged in the F1 report). return unsupportedOnWeb("Native folder picker"); } + + onAppExitWorkGuard( + _handler: (state: AppExitWorkGuardState) => void, + ): Promise { + // Desktop-only (ticket #83): there is no interceptable native window to + // guard on the web client, so this never fires — an inert unsubscribe, + // not a rejection, so callers can subscribe unconditionally. + return Promise.resolve(() => {}); + } + + confirmAppExit(): Promise { + return unsupportedOnWeb("Confirming an app exit"); + } } export class HttpTerminalGateway implements TerminalGateway { diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index b03a692..9af7c96 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -7,6 +7,7 @@ import type { Agent, AgentDrift, + AppExitWorkGuardState, AgentProfile, DiagnosticWarning, DomainEvent, @@ -182,6 +183,28 @@ export class MockSystemGateway implements SystemGateway { async pickFolder(): Promise { return "/home/user/mock-project"; } + + private exitGuardListeners = new Set<(state: AppExitWorkGuardState) => void>(); + /** Count of `confirmAppExit()` calls, for test assertions. */ + confirmAppExitCallCount = 0; + + async onAppExitWorkGuard( + handler: (state: AppExitWorkGuardState) => void, + ): Promise { + this.exitGuardListeners.add(handler); + return () => { + this.exitGuardListeners.delete(handler); + }; + } + + /** Test/dev helper to push an app-exit work guard state to all subscribers. */ + emitAppExitWorkGuard(state: AppExitWorkGuardState): void { + for (const l of this.exitGuardListeners) l(state); + } + + async confirmAppExit(): Promise { + this.confirmAppExitCallCount += 1; + } } /** diff --git a/frontend/src/adapters/system.ts b/frontend/src/adapters/system.ts index 53de458..df9da29 100644 --- a/frontend/src/adapters/system.ts +++ b/frontend/src/adapters/system.ts @@ -8,12 +8,15 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { open } from "@tauri-apps/plugin-dialog"; -import type { DomainEvent, HealthReport, Unsubscribe } from "@/domain"; +import type { AppExitWorkGuardState, DomainEvent, HealthReport, Unsubscribe } from "@/domain"; import type { SystemGateway } from "@/ports"; /** Tauri event name carrying relayed domain events (mirror of `DOMAIN_EVENT`). */ const DOMAIN_EVENT = "domain://event"; +/** Tauri event name carrying the app-exit work guard state (ticket #83). */ +const APP_EXIT_WORK_GUARD = "app-exit-work-guard"; + export class TauriSystemGateway implements SystemGateway { async health(note?: string): Promise { // The backend command takes an optional `request: { note }` (camelCase). @@ -37,4 +40,17 @@ export class TauriSystemGateway implements SystemGateway { // `open` with `multiple: false` returns a string when a path is chosen. return typeof result === "string" ? result : null; } + + async onAppExitWorkGuard( + handler: (state: AppExitWorkGuardState) => void, + ): Promise { + const unlisten = await listen(APP_EXIT_WORK_GUARD, (e) => { + handler(e.payload); + }); + return unlisten; + } + + async confirmAppExit(): Promise { + await invoke("confirm_app_exit"); + } } diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 1dfc335..4b42458 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -10,6 +10,7 @@ import type { DomainEvent, HealthReport } from "@/domain"; import { ProjectsView } from "@/features/projects"; import { FirstRunWizard } from "@/features/first-run"; import { AnnouncementsProvider } from "@/features/announcements"; +import { AppExitConfirmDialog } from "@/features/appExit"; import { Panel, Spinner, Toolbar } from "@/shared"; import { useGateways, shouldUseMock } from "./di"; @@ -114,6 +115,7 @@ export function App() { )} + ); } diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index cb2c15d..5660abd 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -13,6 +13,43 @@ export interface HealthReport { note: string | null; } +// --------------------------------------------------------------------------- +// App-exit work guard (ticket #83) — confirmation before quitting IdeA while +// agents/background tasks are active. Mirrors the backend +// `AppExitWorkGuardStateDto` (main window only; detached windows don't carry +// this guard). +// --------------------------------------------------------------------------- + +/** One active work item contributing to {@link AppExitWorkGuardState}. */ +export type AppExitWorkGuardDetail = + | { + kind: "busyAgent"; + projectId: string; + projectName: string; + agentId: string; + agentName: string; + ticketId: string | null; + } + | { + kind: "activeBackgroundTask"; + projectId: string; + projectName: string; + agentId: string; + agentName: string; + taskId: string; + state: string; + taskKind: string; + }; + +/** App-wide shutdown guard read model, carried by the `app-exit-work-guard` event. */ +export interface AppExitWorkGuardState { + hasWorkInProgress: boolean; + busyAgentCount: number; + activeBackgroundTaskCount: number; + totalWorkCount: number; + details: AppExitWorkGuardDetail[]; +} + /** * Lifecycle status of a local model server during an agent launch (F35, mirror * of the backend `ModelServerStatusDto`, tagged on `state`, camelCase wire). diff --git a/frontend/src/features/appExit/AppExitConfirmDialog.test.tsx b/frontend/src/features/appExit/AppExitConfirmDialog.test.tsx new file mode 100644 index 0000000..88e228f --- /dev/null +++ b/frontend/src/features/appExit/AppExitConfirmDialog.test.tsx @@ -0,0 +1,229 @@ +/** + * Ticket #83 — the app-exit "work in progress" confirmation popup. + * + * Pins the carnet #83 contract: the popup only appears on the backend's + * `app-exit-work-guard` event (never derived locally), with the exact title + * and pluralized body copy, a compact capped detail list, `Annuler` as a pure + * local no-op, and `Quitter quand même` calling `confirmAppExit()`. + */ +import { describe, it, expect } from "vitest"; +import { render, screen, act, fireEvent } from "@testing-library/react"; + +import type { AppExitWorkGuardState } from "@/domain"; +import type { Gateways } from "@/ports"; +import { MockSystemGateway } from "@/adapters/mock"; +import { DIProvider } from "@/app/di"; +import { AppExitConfirmDialog } from "./AppExitConfirmDialog"; + +function setup() { + const system = new MockSystemGateway(); + const gateways = { system } as unknown as Gateways; + render( + + + , + ); + return { system }; +} + +/** The gateway subscribes asynchronously; flush the microtask before emitting. */ +async function flush() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +function state(over: Partial = {}): AppExitWorkGuardState { + return { + hasWorkInProgress: true, + busyAgentCount: 0, + activeBackgroundTaskCount: 0, + totalWorkCount: 0, + details: [], + ...over, + }; +} + +const dialog = () => screen.queryByRole("alertdialog"); + +describe("AppExitConfirmDialog", () => { + it("renders nothing until the app-exit-work-guard event fires", async () => { + await setup(); + await flush(); + expect(dialog()).toBeNull(); + }); + + it("shows the exact title and singular body for exactly one active work item", async () => { + const { system } = await setup(); + await flush(); + + act(() => { + system.emitAppExitWorkGuard( + state({ busyAgentCount: 1, totalWorkCount: 1 }), + ); + }); + + expect(dialog()).not.toBeNull(); + expect(screen.getByText("Du travail est encore en cours")).toBeTruthy(); + expect( + screen.getByText( + "1 travail actif sera interrompu si vous quittez IdeA maintenant.", + ), + ).toBeTruthy(); + expect( + screen.getByText( + "Annulez la fermeture pour laisser les agents et les tâches se terminer.", + ), + ).toBeTruthy(); + }); + + it("pluralizes and distinguishes agent/background-task counts when both are present", async () => { + const { system } = await setup(); + await flush(); + + act(() => { + system.emitAppExitWorkGuard( + state({ busyAgentCount: 2, activeBackgroundTaskCount: 1, totalWorkCount: 3 }), + ); + }); + + expect( + screen.getByText( + "2 agents travaillent encore et 1 tâche de fond est encore active. Ces travaux seront interrompus si vous quittez IdeA maintenant.", + ), + ).toBeTruthy(); + }); + + it("shows up to 5 detail lines then a '+N autres' summary", async () => { + const { system } = await setup(); + await flush(); + + const details: AppExitWorkGuardState["details"] = Array.from({ length: 7 }, (_, i) => ({ + kind: "busyAgent", + projectId: "p1", + projectName: "IdeA", + agentId: `a${i}`, + agentName: `Agent${i}`, + ticketId: null, + })); + + act(() => { + system.emitAppExitWorkGuard( + state({ busyAgentCount: 7, totalWorkCount: 7, details }), + ); + }); + + expect(screen.getByText("• Agent Agent0 — IdeA")).toBeTruthy(); + expect(screen.getByText("• Agent Agent4 — IdeA")).toBeTruthy(); + expect(screen.queryByText("• Agent Agent5 — IdeA")).toBeNull(); + expect(screen.getByText("+ 2 autres")).toBeTruthy(); + }); + + it("formats a background-task detail line using the short task id fallback", async () => { + const { system } = await setup(); + await flush(); + + act(() => { + system.emitAppExitWorkGuard( + state({ + activeBackgroundTaskCount: 1, + totalWorkCount: 1, + details: [ + { + kind: "activeBackgroundTask", + projectId: "p1", + projectName: "IdeA", + agentId: "a1", + agentName: "DevBackend", + taskId: "0123456789abcdef", + state: "running", + taskKind: "command", + }, + ], + }), + ); + }); + + expect(screen.getByText("• Tâche de fond 01234567 — IdeA")).toBeTruthy(); + }); + + it("'Annuler' closes the popup locally without calling confirmAppExit", async () => { + const { system } = await setup(); + await flush(); + + act(() => { + system.emitAppExitWorkGuard(state({ busyAgentCount: 1, totalWorkCount: 1 })); + }); + expect(dialog()).not.toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Annuler" })); + + expect(dialog()).toBeNull(); + expect(system.confirmAppExitCallCount).toBe(0); + }); + + it("Escape is equivalent to Annuler", async () => { + const { system } = await setup(); + await flush(); + + act(() => { + system.emitAppExitWorkGuard(state({ busyAgentCount: 1, totalWorkCount: 1 })); + }); + expect(dialog()).not.toBeNull(); + + fireEvent.keyDown(window, { key: "Escape" }); + + expect(dialog()).toBeNull(); + expect(system.confirmAppExitCallCount).toBe(0); + }); + + it("'Quitter quand même' calls confirmAppExit", async () => { + const { system } = await setup(); + await flush(); + + act(() => { + system.emitAppExitWorkGuard(state({ busyAgentCount: 1, totalWorkCount: 1 })); + }); + + fireEvent.click(screen.getByRole("button", { name: /Quitter quand même/ })); + + await flush(); + expect(system.confirmAppExitCallCount).toBe(1); + }); + + it("focuses 'Annuler' by default when the popup opens", async () => { + const { system } = await setup(); + await flush(); + + act(() => { + system.emitAppExitWorkGuard(state({ busyAgentCount: 1, totalWorkCount: 1 })); + }); + + expect(document.activeElement).toBe(screen.getByRole("button", { name: "Annuler" })); + }); + + it("does not auto-close when a fresh guard event arrives while already open", async () => { + const { system } = await setup(); + await flush(); + + act(() => { + system.emitAppExitWorkGuard(state({ busyAgentCount: 1, totalWorkCount: 1 })); + }); + expect(dialog()).not.toBeNull(); + + act(() => { + system.emitAppExitWorkGuard( + state({ busyAgentCount: 2, activeBackgroundTaskCount: 1, totalWorkCount: 3 }), + ); + }); + + // Still open, summary refreshed in place. + expect(dialog()).not.toBeNull(); + expect( + screen.getByText( + "2 agents travaillent encore et 1 tâche de fond est encore active. Ces travaux seront interrompus si vous quittez IdeA maintenant.", + ), + ).toBeTruthy(); + }); +}); diff --git a/frontend/src/features/appExit/AppExitConfirmDialog.tsx b/frontend/src/features/appExit/AppExitConfirmDialog.tsx new file mode 100644 index 0000000..18b73d7 --- /dev/null +++ b/frontend/src/features/appExit/AppExitConfirmDialog.tsx @@ -0,0 +1,222 @@ +/** + * `AppExitConfirmDialog` — app-wide confirmation shown when closing the main + * window would interrupt active work (ticket #83). Mounted once near the App + * root, alongside `AnnouncementsProvider`; subscribes to the backend's + * `app-exit-work-guard` event via {@link SystemGateway.onAppExitWorkGuard} and + * renders nothing until the guard actually fires. + * + * The backend owns the decision: it intercepts `WindowEvent::CloseRequested` + * on the `main` window, aggregates active work across every open project, and + * only emits the guard event when closing would interrupt something. This + * component is purely presentational on top of that signal — it never derives + * "work in progress" itself from local state, which would risk missing work + * in a project/tab the frontend hasn't refreshed. + * + * `Quitter quand même` calls `confirmAppExit()`, which bypasses the guard once + * and re-requests the real main-window close (same teardown order as an + * unguarded quit). `Annuler` only clears local dialog state — no backend call, + * the window and every session stay untouched. + */ + +import { useEffect, useRef, useState } from "react"; + +import type { AppExitWorkGuardDetail, AppExitWorkGuardState } from "@/domain"; +import { useGateways } from "@/app/di"; +import { Button, zIndex } from "@/shared"; + +const MAX_DETAIL_LINES = 5; + +/** The narrative body sentence, per carnet #83's exact wording rules. */ +function bodyText(state: AppExitWorkGuardState): string { + if (state.totalWorkCount === 1) { + return "1 travail actif sera interrompu si vous quittez IdeA maintenant."; + } + const parts: string[] = []; + if (state.busyAgentCount > 0) { + parts.push( + state.busyAgentCount === 1 + ? "1 agent travaille encore" + : `${state.busyAgentCount} agents travaillent encore`, + ); + } + if (state.activeBackgroundTaskCount > 0) { + parts.push( + state.activeBackgroundTaskCount === 1 + ? "1 tâche de fond est encore active" + : `${state.activeBackgroundTaskCount} tâches de fond sont encore actives`, + ); + } + return `${parts.join(" et ")}. Ces travaux seront interrompus si vous quittez IdeA maintenant.`; +} + +/** One compact detail line, per carnet #83's exact line format. */ +function detailLine(detail: AppExitWorkGuardDetail): string { + if (detail.kind === "busyAgent") { + return `Agent ${detail.agentName} — ${detail.projectName}`; + } + const shortTaskId = detail.taskId.slice(0, 8); + return `Tâche de fond ${shortTaskId} — ${detail.projectName}`; +} + +function describeError(e: unknown): string { + if (e && typeof e === "object" && "message" in e) { + return String((e as { message: unknown }).message); + } + return String(e); +} + +const TITLE_ID = "app-exit-confirm-title"; +const DESC_ID = "app-exit-confirm-desc"; + +export function AppExitConfirmDialog() { + const { system } = useGateways(); + const [guard, setGuard] = useState(null); + const [closing, setClosing] = useState(false); + const [error, setError] = useState(null); + const cancelRef = useRef(null); + const dialogRef = useRef(null); + + useEffect(() => { + let cancelled = false; + let unsub: (() => void) | undefined; + system + .onAppExitWorkGuard((state) => { + // Any emission implies the backend just intercepted a close with work + // in progress. If the dialog is already open, this refreshes the + // summary in place rather than closing/reopening it (carnet #83: the + // popup never auto-closes on its own). + setGuard(state); + }) + .then((u) => { + if (cancelled) u(); + else unsub = u; + }) + .catch(() => { + /* guard relay unavailable in this environment (e.g. focused test DI) */ + }); + return () => { + cancelled = true; + unsub?.(); + }; + }, [system]); + + // Mount-only focus: an open dialog focuses the safe choice once. Re-running + // on every render (e.g. a refreshed summary) would steal focus back from + // wherever the user tabbed to. + useEffect(() => { + if (guard) cancelRef.current?.focus(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [guard != null]); + + function cancel() { + if (closing) return; + setGuard(null); + setError(null); + } + + async function confirmExit() { + setClosing(true); + setError(null); + try { + await system.confirmAppExit(); + } catch (e) { + setError(describeError(e)); + } finally { + setClosing(false); + } + } + + useEffect(() => { + if (!guard) return; + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + e.preventDefault(); + cancel(); + return; + } + if (e.key !== "Tab") return; + // Simple focus trap: wrap Tab/Shift+Tab within the dialog's focusable set. + const focusables = dialogRef.current?.querySelectorAll( + 'button:not(:disabled), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + if (!focusables || focusables.length === 0) return; + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [guard != null, closing]); + + if (!guard) return null; + + const shown = guard.details.slice(0, MAX_DETAIL_LINES); + const remaining = guard.details.length - shown.length; + + return ( + // No `onClick` here: clicking outside the dialog is deliberately a no-op + // (carnet #83 — avoid an ambiguous accidental dismissal of a destructive + // confirmation). +
+
e.stopPropagation()} + className="flex w-full max-w-[520px] min-w-[440px] flex-col gap-3 rounded-lg border border-border bg-raised p-4 shadow-xl" + > +

+ Du travail est encore en cours +

+

+ {bodyText(guard)} +

+

+ Annulez la fermeture pour laisser les agents et les tâches se terminer. +

+ + {shown.length > 0 && ( +
    + {shown.map((detail, i) => ( +
  • {`• ${detailLine(detail)}`}
  • + ))} + {remaining > 0 &&
  • + {remaining} autre{remaining > 1 ? "s" : ""}
  • } +
+ )} + + {error && ( +

+ IdeA n'a pas pu quitter correctement. Réessayez ou consultez les logs. +

+ )} + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/features/appExit/index.ts b/frontend/src/features/appExit/index.ts new file mode 100644 index 0000000..5fd41b0 --- /dev/null +++ b/frontend/src/features/appExit/index.ts @@ -0,0 +1,6 @@ +/** + * App-exit confirmation feature (ticket #83) — the "work in progress" quit + * guard popup. + */ + +export { AppExitConfirmDialog } from "./AppExitConfirmDialog"; diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 6c617bd..c4b822d 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -12,6 +12,7 @@ import type { Agent, AgentDrift, AgentProfile, + AppExitWorkGuardState, DomainEvent, EmbedderEngines, EmbedderProfile, @@ -76,6 +77,20 @@ export interface SystemGateway { * sites go through this port; the Tauri plugin is only imported in the adapter. */ pickFolder(): Promise; + /** + * Subscribes to the app-exit work-in-progress guard (ticket #83): fired when + * closing the main window is intercepted because it would interrupt active + * agents/background tasks. Desktop-only — the web transport returns an inert + * unsubscribe (never fires; there is no interceptable window to guard). + */ + onAppExitWorkGuard( + handler: (state: AppExitWorkGuardState) => void, + ): Promise; + /** + * Bypasses the guard once and requests the main window to close for real + * (ticket #83) — the user chose "Quitter quand même". + */ + confirmAppExit(): Promise; } /** Input for {@link AgentGateway.createAgent}. */ From 60f4b33e530b97828b9533891648069f0babcb66 Mon Sep 17 00:00:00 2001 From: Blomios Date: Mon, 20 Jul 2026 19:18:39 +0200 Subject: [PATCH 2/2] feat(backend): guard de fermeture "travail en cours" (#83) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose l'état du guard de sortie applicative (GetAppExitWorkGuardState) : agents busy + tâches d'arrière-plan actives à travers tous les projets ouverts, avec détails compacts pour la popup de confirmation. Le handler CloseRequested d'app-tauri interroge ce guard avant de laisser la fenêtre se fermer, et respecte la confirmation explicite de l'utilisateur (EXIT_GUARD_CONFIRMED) pour ne pas la redemander en boucle. QA vert (backend + frontend). Co-Authored-By: Claude Sonnet 5 --- crates/app-tauri/src/commands.rs | 85 ++++--- crates/app-tauri/src/lib.rs | 295 ++++++++++++++++++++---- crates/application/src/lib.rs | 16 +- crates/application/src/workstate/mod.rs | 129 +++++++++++ crates/application/tests/workstate.rs | 168 +++++++++++++- crates/backend/src/dto.rs | 117 +++++++++- crates/backend/src/lib.rs | 54 +++-- 7 files changed, 755 insertions(+), 109 deletions(-) diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index d8ed44e..d8581c8 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -36,32 +36,32 @@ use crate::dto::{ parse_layout_id, parse_memory_slug, parse_model_server_id, parse_node_id, parse_profile_id, parse_project_id, parse_session_id, parse_skill_id, parse_task_id, parse_template_id, parse_ticket_id, save_model_server_input, AgentDriftListDto, AgentDto, AgentListDto, - AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, - BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto, - CloneOpenCodeProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto, - CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto, - CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto, - CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto, - DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto, - EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, - ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, - GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, - GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, - InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, - LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, - ModelServerConfigDto, ModelServerConfigListDto, OpenTerminalRequestDto, - PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, - ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectWorkStateDto, - ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto, - ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, - ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResumableAgentListDto, - SaveEmbedderProfileRequestDto, SaveModelServerRequestDto, SaveProfileRequestDto, - SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto, - StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, - SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto, - TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto, - UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto, - UpdateMemoryRequestDto, UpdateProjectContextRequestDto, + AppExitWorkGuardStateDto, AssignSkillRequestDto, AttachLiveAgentRequestDto, + AttachLiveAgentResponseDto, BackgroundTaskDto, ChangeAgentProfileDto, + ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto, + ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto, + CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto, + CreateProjectRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto, + DeleteLayoutRequestDto, DeleteLayoutResultDto, DeliveredDelegationRequestDto, + DetectProfilesRequestDto, DetectProfilesResponseDto, EffectivePermissionsDto, + EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto, + FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, + GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto, + HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto, + LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, + MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto, + ModelServerConfigListDto, OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto, + ProfileListDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto, + ProjectPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto, + ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, + RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto, + ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, + SaveModelServerRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, + SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto, + StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, + TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto, + UpdateAgentContextRequestDto, UpdateAgentMcpToolPermissionsRequestDto, + UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto, UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto, WriteTerminalRequestDto, }; @@ -1437,6 +1437,39 @@ pub async fn get_project_work_state( .map_err(ErrorDto::from) } +/// `get_app_exit_work_guard_state` — aggregate active work across all open projects. +/// +/// # Errors +/// Returns an [`ErrorDto`] if an open project or its work-state read model cannot be read. +#[tauri::command] +pub async fn get_app_exit_work_guard_state( + app: AppHandle, +) -> Result { + crate::read_app_exit_work_guard_state(&app) + .await + .map(AppExitWorkGuardStateDto::from) + .map_err(ErrorDto::from) +} + +/// `confirm_app_exit` — bypass the close guard once and request main-window shutdown. +/// +/// # Errors +/// Returns an [`ErrorDto`] if the main window cannot be closed programmatically. +#[tauri::command] +pub async fn confirm_app_exit(app: AppHandle) -> Result<(), ErrorDto> { + crate::confirm_next_main_window_close(); + if let Some(window) = app.get_webview_window("main") { + window.close().map_err(|err| ErrorDto { + code: "INTERNAL".to_owned(), + message: format!("failed to close main window: {err}"), + })?; + } else { + crate::shutdown_app_after_confirm(&app); + app.exit(0); + } + Ok(()) +} + /// `read_conversation_page` — human, paginated read of a conversation's **full** /// transcript (lot LS6). Archive-aware (segments + active), text never truncated. /// diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index b97428a..3c77c80 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -29,19 +29,62 @@ pub mod templates; pub mod tickets; use std::process::ExitCode; +use std::sync::atomic::{AtomicBool, Ordering}; -use application::SnapshotOpenWindowsInput; +use application::{AppError, GetAppExitWorkGuardStateInput, SnapshotOpenWindowsInput}; use domain::{ PersistedMonitorState, PersistedWindowKind, PersistedWindowPosition, PersistedWindowSize, PersistedWindowState, ProjectId, }; use tauri::{ - Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow, WebviewWindowBuilder, + Emitter, Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow, + WebviewWindowBuilder, }; use uuid::Uuid; use state::AppState; +static EXIT_GUARD_CONFIRMED: AtomicBool = AtomicBool::new(false); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MainCloseAction { + AllowShutdown, + PreventAndNotify, +} + +fn decide_main_close_action( + has_work_in_progress: bool, + already_confirmed: bool, +) -> MainCloseAction { + if has_work_in_progress && !already_confirmed { + MainCloseAction::PreventAndNotify + } else { + MainCloseAction::AllowShutdown + } +} + +fn should_install_exit_guard(window_label: &str) -> bool { + window_label == "main" +} + +fn apply_main_close_decision( + guard: application::AppExitWorkGuardState, + already_confirmed: bool, + mut prevent_close: impl FnMut(), + mut emit_guard: impl FnMut(application::AppExitWorkGuardState), + mut shutdown: impl FnMut(), +) -> MainCloseAction { + let action = decide_main_close_action(guard.has_work_in_progress, already_confirmed); + match action { + MainCloseAction::PreventAndNotify => { + prevent_close(); + emit_guard(guard); + } + MainCloseAction::AllowShutdown => shutdown(), + } + action +} + /// The `argv[1]` subcommand token that switches the binary into the headless /// `mcp-server` **bridge** mode (cadrage v5 §1.3) instead of launching Tauri. pub const MCP_SERVER_SUBCOMMAND: &str = "mcp-server"; @@ -115,48 +158,35 @@ pub fn run() { // independent of the per-view (navigation/layout) lifecycle — those // must NEVER kill a PTY — and only fires on a genuine app shutdown. // A brutal crash is best-effort and out of scope. - if let Some(window) = app.get_webview_window("main") { + if should_install_exit_guard("main") && app.get_webview_window("main").is_some() { + let window = app + .get_webview_window("main") + .expect("main window existence checked above"); let handle = app.handle().clone(); window.on_window_event(move |event| { - if let tauri::WindowEvent::CloseRequested { .. } = event { - if let Some(state) = handle.try_state::() { - let open_windows = snapshot_open_webview_windows(&handle); - let window_snapshot = - std::sync::Arc::clone(&state.snapshot_open_windows); - let pty = std::sync::Arc::clone(&state.pty_port); - // ORDER IS CRITICAL: freeze `agent_was_running` on every - // agent leaf of every open project FIRST, reading the live - // PTY registry as it stands now; only THEN kill the PTYs. - // If we killed first, the registry would be empty and every - // agent would be persisted as "closed". - let snapshot = std::sync::Arc::clone(&state.snapshot_running_agents); - let model_servers = - std::sync::Arc::clone(&state.ensure_local_model_server); - let embedded_server = std::sync::Arc::clone(&state.embedded_server); - let open_projects = state.open_project_ids(); - let handles = state.terminal_sessions.handles(); - tauri::async_runtime::block_on(async move { - let _ = window_snapshot - .execute(SnapshotOpenWindowsInput { - windows: open_windows, - }) - .await; - for project_id in open_projects { - let _ = snapshot - .execute(application::SnapshotRunningAgentsInput { - project_id, - }) - .await; - } - for h in handles { - let _ = pty.kill(&h).await; - } - let _ = model_servers.stop_on_app_exit().await; - let _ = embedded_server.stop().await; - }); + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + let already_confirmed = consume_exit_guard_confirmation(); + let guard = + tauri::async_runtime::block_on(app_exit_work_guard_state(&handle)); + if let Ok(guard) = guard { + let action = apply_main_close_decision( + guard, + already_confirmed, + || api.prevent_close(), + |guard| { + let _ = handle.emit( + "app-exit-work-guard", + backend::dto::AppExitWorkGuardStateDto::from(guard), + ); + }, + || shutdown_app_after_confirm(&handle), + ); + if action == MainCloseAction::PreventAndNotify { + return; + } + } else { + shutdown_app_after_confirm(&handle); } - - close_non_main_webview_windows(&handle); } }); } @@ -211,6 +241,8 @@ pub fn run() { commands::dismiss_embedder_suggestion, commands::create_agent, commands::list_agents, + commands::get_app_exit_work_guard_state, + commands::confirm_app_exit, tickets::ticket_create, tickets::ticket_read, tickets::ticket_delete, @@ -307,6 +339,79 @@ pub fn run() { .expect("error while running IdeA Tauri application"); } +async fn app_exit_work_guard_state( + handle: &tauri::AppHandle, +) -> Result { + let Some(state) = handle.try_state::() else { + return Ok(application::AppExitWorkGuardState { + has_work_in_progress: false, + busy_agent_count: 0, + active_background_task_count: 0, + details: Vec::new(), + }); + }; + + let mut projects = Vec::new(); + for project_id in state.open_project_ids() { + projects.push(state.project_store.load_project(project_id).await?); + } + state + .get_app_exit_work_guard_state + .execute(GetAppExitWorkGuardStateInput { projects }) + .await +} + +/// Executes the global shutdown teardown after the close guard has allowed exit. +/// +/// The order intentionally mirrors the historical inline `CloseRequested` hook: +/// snapshot windows, snapshot `agent_was_running`, kill PTYs, stop model servers, +/// stop the embedded server, then close secondary webview windows. +pub fn shutdown_app_after_confirm(handle: &tauri::AppHandle) { + if let Some(state) = handle.try_state::() { + let open_windows = snapshot_open_webview_windows(handle); + let window_snapshot = std::sync::Arc::clone(&state.snapshot_open_windows); + let pty = std::sync::Arc::clone(&state.pty_port); + let snapshot = std::sync::Arc::clone(&state.snapshot_running_agents); + let model_servers = std::sync::Arc::clone(&state.ensure_local_model_server); + let embedded_server = std::sync::Arc::clone(&state.embedded_server); + let open_projects = state.open_project_ids(); + let handles = state.terminal_sessions.handles(); + tauri::async_runtime::block_on(async move { + let _ = window_snapshot + .execute(SnapshotOpenWindowsInput { + windows: open_windows, + }) + .await; + for project_id in open_projects { + let _ = snapshot + .execute(application::SnapshotRunningAgentsInput { project_id }) + .await; + } + for h in handles { + let _ = pty.kill(&h).await; + } + let _ = model_servers.stop_on_app_exit().await; + let _ = embedded_server.stop().await; + }); + } + + close_non_main_webview_windows(handle); +} + +pub(crate) fn confirm_next_main_window_close() { + EXIT_GUARD_CONFIRMED.store(true, Ordering::SeqCst); +} + +fn consume_exit_guard_confirmation() -> bool { + EXIT_GUARD_CONFIRMED.swap(false, Ordering::SeqCst) +} + +pub(crate) async fn read_app_exit_work_guard_state( + handle: &tauri::AppHandle, +) -> Result { + app_exit_work_guard_state(handle).await +} + fn close_non_main_webview_windows(handle: &tauri::AppHandle) { for (label, window) in handle.webview_windows() { if !should_close_with_main_window(&label) { @@ -532,8 +637,114 @@ fn persisted_monitor_is_available( #[cfg(test)] mod tests { - use super::{persisted_view_identity_from_label, persisted_window_identity}; + use super::{ + apply_main_close_decision, confirm_next_main_window_close, consume_exit_guard_confirmation, + decide_main_close_action, persisted_view_identity_from_label, persisted_window_identity, + should_install_exit_guard, MainCloseAction, + }; use super::{should_close_with_main_window, PersistedWindowKind}; + use application::AppExitWorkGuardState; + use std::cell::Cell; + + #[test] + fn main_close_without_work_allows_shutdown_without_preventing_close() { + assert_eq!( + decide_main_close_action(false, false), + MainCloseAction::AllowShutdown + ); + } + + #[test] + fn main_close_with_work_prevents_and_notifies_before_shutdown() { + assert_eq!( + decide_main_close_action(true, false), + MainCloseAction::PreventAndNotify + ); + } + + #[test] + fn main_close_with_work_emits_guard_payload_and_skips_shutdown() { + let prevented = Cell::new(false); + let shutdown = Cell::new(false); + let emitted = Cell::new(None); + let guard = AppExitWorkGuardState { + has_work_in_progress: true, + busy_agent_count: 2, + active_background_task_count: 1, + details: Vec::new(), + }; + + let action = apply_main_close_decision( + guard, + false, + || prevented.set(true), + |payload| { + emitted.set(Some(( + payload.busy_agent_count, + payload.active_background_task_count, + ))) + }, + || shutdown.set(true), + ); + + assert_eq!(action, MainCloseAction::PreventAndNotify); + assert!(prevented.get()); + assert_eq!(emitted.get(), Some((2, 1))); + assert!(!shutdown.get()); + } + + #[test] + fn main_close_without_work_runs_shutdown_without_prevent_or_emit() { + let prevented = Cell::new(false); + let shutdown = Cell::new(false); + let emitted = Cell::new(false); + let guard = AppExitWorkGuardState { + has_work_in_progress: false, + busy_agent_count: 0, + active_background_task_count: 0, + details: Vec::new(), + }; + + let action = apply_main_close_decision( + guard, + false, + || prevented.set(true), + |_| emitted.set(true), + || shutdown.set(true), + ); + + assert_eq!(action, MainCloseAction::AllowShutdown); + assert!(!prevented.get()); + assert!(!emitted.get()); + assert!(shutdown.get()); + } + + #[test] + fn confirmed_main_close_bypasses_guard_once_then_rearms() { + confirm_next_main_window_close(); + let first_attempt_confirmed = consume_exit_guard_confirmation(); + assert!(first_attempt_confirmed); + assert_eq!( + decide_main_close_action(true, first_attempt_confirmed), + MainCloseAction::AllowShutdown + ); + + let second_attempt_confirmed = consume_exit_guard_confirmation(); + assert!(!second_attempt_confirmed); + assert_eq!( + decide_main_close_action(true, second_attempt_confirmed), + MainCloseAction::PreventAndNotify + ); + } + + #[test] + fn exit_guard_is_scoped_to_main_window_only() { + assert!(should_install_exit_guard("main")); + assert!(!should_install_exit_guard( + "view-work-00000000-0000-0000-0000-000000000001" + )); + assert!(!should_install_exit_guard("settings")); + } #[test] fn main_window_close_does_not_target_main_again() { diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 820bbc4..53c0eed 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -183,11 +183,13 @@ pub use window::{ RestoreOpenWindowsOutput, SnapshotOpenWindows, SnapshotOpenWindowsInput, }; pub use workstate::{ - AgentBackgroundTaskState, AgentTicketState, AgentWorkState, AttachLiveAgent, - AttachLiveAgentInput, AttachLiveAgentOutput, BackgroundTaskKindLabel, ConversationLogProvider, - ConversationPreviewStatus, ConversationTurnWorkPreview, ConversationWorkSummary, - GetLiveStateLean, GetProjectWorkState, GetProjectWorkStateInput, LeanLiveEntry, LeanLiveState, - LiveWorkSession, ProjectWorkState, ReconcileLiveState, ReconcileLiveStateInput, StopLiveAgent, - StopLiveAgentInput, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, UpdateLiveState, - UpdateLiveStateInput, LIVE_STATE_MAX_ENTRIES, LIVE_STATE_TTL_MS, + AgentBackgroundTaskState, AgentTicketState, AgentWorkState, AppExitWorkGuardDetail, + AppExitWorkGuardState, AttachLiveAgent, AttachLiveAgentInput, AttachLiveAgentOutput, + BackgroundTaskKindLabel, ConversationLogProvider, ConversationPreviewStatus, + ConversationTurnWorkPreview, ConversationWorkSummary, GetAppExitWorkGuardState, + GetAppExitWorkGuardStateInput, GetLiveStateLean, GetProjectWorkState, GetProjectWorkStateInput, + LeanLiveEntry, LeanLiveState, LiveWorkSession, ProjectWorkState, ReconcileLiveState, + ReconcileLiveStateInput, StopLiveAgent, StopLiveAgentInput, StopLiveAgentOutput, + TicketWorkSource, TicketWorkStatus, UpdateLiveState, UpdateLiveStateInput, + LIVE_STATE_MAX_ENTRIES, LIVE_STATE_TTL_MS, }; diff --git a/crates/application/src/workstate/mod.rs b/crates/application/src/workstate/mod.rs index 2e5071d..1daab65 100644 --- a/crates/application/src/workstate/mod.rs +++ b/crates/application/src/workstate/mod.rs @@ -72,6 +72,61 @@ pub struct ProjectWorkState { pub conversations: Vec, } +/// Input for [`GetAppExitWorkGuardState::execute`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GetAppExitWorkGuardStateInput { + /// Projects currently open in the application. + pub projects: Vec, +} + +/// Application-wide shutdown guard summary for the close-confirmation UX. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppExitWorkGuardState { + /// Whether at least one active work item would be interrupted by app exit. + pub has_work_in_progress: bool, + /// Number of busy agents across all open projects. + pub busy_agent_count: usize, + /// Number of non-terminal background tasks across all open projects. + pub active_background_task_count: usize, + /// Best-effort detail for compact UX display. + pub details: Vec, +} + +/// One work item contributing to [`AppExitWorkGuardState`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AppExitWorkGuardDetail { + /// A manifest agent is currently processing a turn. + BusyAgent { + /// Owning project id. + project_id: domain::ProjectId, + /// Owning project display name. + project_name: String, + /// Agent id. + agent_id: AgentId, + /// Agent display name. + agent_name: String, + /// Busy ticket, if carried by the mediator state. + ticket_id: Option, + }, + /// A first-class background task is queued, running or waiting. + ActiveBackgroundTask { + /// Owning project id. + project_id: domain::ProjectId, + /// Owning project display name. + project_name: String, + /// Owning agent id. + agent_id: AgentId, + /// Owning agent display name. + agent_name: String, + /// Stable task id. + task_id: TaskId, + /// Lifecycle state. + state: BackgroundTaskState, + /// Kind discriminant. + kind: BackgroundTaskKindLabel, + }, +} + /// Best-effort, read-only summary of one conversation visible through the tickets. /// /// Derived live from the [`HandoffStore`] (primary source) with a bounded @@ -276,6 +331,80 @@ pub struct GetProjectWorkState { background_tasks: Option>, } +/// Read-only use case aggregating app-wide work that should guard application exit. +pub struct GetAppExitWorkGuardState { + work_state: Arc, +} + +impl GetAppExitWorkGuardState { + /// Builds the app-exit guard from the existing per-project work-state read model. + #[must_use] + pub fn new(work_state: Arc) -> Self { + Self { work_state } + } + + /// Executes the guard aggregation across all currently open projects. + /// + /// # Errors + /// Propagates the per-project work-state read errors. + pub async fn execute( + &self, + input: GetAppExitWorkGuardStateInput, + ) -> Result { + let mut busy_agent_count = 0; + let mut active_background_task_count = 0; + let mut details = Vec::new(); + + for project in input.projects { + let project_id = project.id; + let project_name = project.name.clone(); + let state = self + .work_state + .execute(GetProjectWorkStateInput { + project: project.clone(), + }) + .await?; + + for agent in state.agents { + if agent.busy.is_busy() { + busy_agent_count += 1; + details.push(AppExitWorkGuardDetail::BusyAgent { + project_id, + project_name: project_name.clone(), + agent_id: agent.agent_id, + agent_name: agent.name.clone(), + ticket_id: agent.busy.ticket(), + }); + } + + for task in agent + .background_tasks + .into_iter() + .filter(|task| !task.state.is_terminal()) + { + active_background_task_count += 1; + details.push(AppExitWorkGuardDetail::ActiveBackgroundTask { + project_id, + project_name: project_name.clone(), + agent_id: agent.agent_id, + agent_name: agent.name.clone(), + task_id: task.task_id, + state: task.state, + kind: task.kind, + }); + } + } + } + + Ok(AppExitWorkGuardState { + has_work_in_progress: busy_agent_count > 0 || active_background_task_count > 0, + busy_agent_count, + active_background_task_count, + details, + }) + } +} + impl GetProjectWorkState { /// Builds the read-model use case from existing stores/registries. /// diff --git a/crates/application/tests/workstate.rs b/crates/application/tests/workstate.rs index a153123..5bf09b4 100644 --- a/crates/application/tests/workstate.rs +++ b/crates/application/tests/workstate.rs @@ -8,7 +8,8 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use application::{ - ConversationLogProvider, ConversationPreviewStatus, GetProjectWorkState, + AppExitWorkGuardDetail, ConversationLogProvider, ConversationPreviewStatus, + GetAppExitWorkGuardState, GetAppExitWorkGuardStateInput, GetProjectWorkState, GetProjectWorkStateInput, HandoffProvider, LiveSessionKind, LiveSessions, StructuredSessions, TerminalSessions, TicketWorkSource, TicketWorkStatus, }; @@ -559,9 +560,12 @@ fn background_task( .unwrap(); match state { BackgroundTaskState::Queued => base, - BackgroundTaskState::Running | BackgroundTaskState::Waiting => { - base.transition(state, created_at_ms + 10).unwrap() - } + BackgroundTaskState::Running => base.transition(state, created_at_ms + 10).unwrap(), + BackgroundTaskState::Waiting => base + .transition(BackgroundTaskState::Running, created_at_ms + 10) + .unwrap() + .transition(BackgroundTaskState::Waiting, created_at_ms + 20) + .unwrap(), BackgroundTaskState::Completed | BackgroundTaskState::Failed | BackgroundTaskState::Cancelled @@ -695,6 +699,162 @@ async fn workstate_attaches_live_structured_session_to_manifest_agent() { assert_eq!(live.kind, LiveSessionKind::Structured); } +#[tokio::test] +async fn app_exit_guard_is_false_without_busy_agent_or_active_background_task() { + let a = agent(10, "alpha"); + let f = fixture(std::slice::from_ref(&a)); + insert_pty(&f.pty, sid(1), a.id, nid(100)); + + let guard = GetAppExitWorkGuardState::new(Arc::new(f.usecase)); + let out = guard + .execute(GetAppExitWorkGuardStateInput { + projects: vec![f.project], + }) + .await + .unwrap(); + + assert!(!out.has_work_in_progress); + assert_eq!(out.busy_agent_count, 0); + assert_eq!(out.active_background_task_count, 0); + assert!(out.details.is_empty()); +} + +#[tokio::test] +async fn app_exit_guard_is_true_with_busy_agent() { + let a = agent(10, "alpha"); + let f = fixture(std::slice::from_ref(&a)); + f.input.set_busy( + a.id, + AgentBusyState::Busy { + ticket: ticket_id(77), + since_ms: 1_700_000_000_100, + }, + ); + + let guard = GetAppExitWorkGuardState::new(Arc::new(f.usecase)); + let out = guard + .execute(GetAppExitWorkGuardStateInput { + projects: vec![f.project.clone()], + }) + .await + .unwrap(); + + assert!(out.has_work_in_progress); + assert_eq!(out.busy_agent_count, 1); + assert_eq!(out.active_background_task_count, 0); + assert_eq!( + out.details, + vec![AppExitWorkGuardDetail::BusyAgent { + project_id: f.project.id, + project_name: "demo".to_owned(), + agent_id: a.id, + agent_name: "alpha".to_owned(), + ticket_id: Some(ticket_id(77)), + }] + ); +} + +#[tokio::test] +async fn app_exit_guard_is_true_with_non_terminal_background_tasks() { + let a = agent(10, "alpha"); + let f = background_fixture(std::slice::from_ref(&a)); + f.store.set_tasks(vec![ + background_task( + 1, + f.project.id, + a.id, + 1_700_000_000_000, + BackgroundTaskState::Queued, + false, + ), + background_task( + 2, + f.project.id, + a.id, + 1_700_000_000_100, + BackgroundTaskState::Running, + false, + ), + background_task( + 3, + f.project.id, + a.id, + 1_700_000_000_200, + BackgroundTaskState::Waiting, + false, + ), + ]); + + let guard = GetAppExitWorkGuardState::new(Arc::new(f.usecase)); + let out = guard + .execute(GetAppExitWorkGuardStateInput { + projects: vec![f.project.clone()], + }) + .await + .unwrap(); + + assert!(out.has_work_in_progress); + assert_eq!(out.busy_agent_count, 0); + assert_eq!(out.active_background_task_count, 3); + assert!(out + .details + .iter() + .all(|detail| matches!(detail, AppExitWorkGuardDetail::ActiveBackgroundTask { .. }))); +} + +#[tokio::test] +async fn app_exit_guard_ignores_terminal_background_tasks() { + let a = agent(10, "alpha"); + let f = background_fixture(std::slice::from_ref(&a)); + f.store.set_tasks(vec![ + background_task( + 1, + f.project.id, + a.id, + 1_700_000_000_000, + BackgroundTaskState::Completed, + false, + ), + background_task( + 2, + f.project.id, + a.id, + 1_700_000_000_100, + BackgroundTaskState::Failed, + false, + ), + background_task( + 3, + f.project.id, + a.id, + 1_700_000_000_200, + BackgroundTaskState::Cancelled, + false, + ), + background_task( + 4, + f.project.id, + a.id, + 1_700_000_000_300, + BackgroundTaskState::Expired, + false, + ), + ]); + + let guard = GetAppExitWorkGuardState::new(Arc::new(f.usecase)); + let out = guard + .execute(GetAppExitWorkGuardStateInput { + projects: vec![f.project], + }) + .await + .unwrap(); + + assert!(!out.has_work_in_progress); + assert_eq!(out.busy_agent_count, 0); + assert_eq!(out.active_background_task_count, 0); + assert!(out.details.is_empty()); +} + #[tokio::test] async fn workstate_includes_busy_state_from_input_mediator() { let a = agent(10, "alpha"); diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index d166d16..c438e1e 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -9,12 +9,12 @@ use serde::{Deserialize, Serialize}; use application::{ - AgentBackgroundTaskState, AgentTicketState, AppError, AttachLiveAgentOutput, - BackgroundTaskKindLabel, ConversationPreviewStatus, ConversationTurnWorkPreview, - ConversationWorkSummary, CreateProjectInput, CreateProjectOutput, GitGraphOutput, HealthInput, - HealthReport, LayoutKind, ListProjectsOutput, LiveSessionKind, LiveSessionSnapshot, - OpenProjectOutput, ProjectWorkState, StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, - TurnPage, TurnSource, TurnView, + AgentBackgroundTaskState, AgentTicketState, AppError, AppExitWorkGuardDetail, + AppExitWorkGuardState, AttachLiveAgentOutput, BackgroundTaskKindLabel, + ConversationPreviewStatus, ConversationTurnWorkPreview, ConversationWorkSummary, + CreateProjectInput, CreateProjectOutput, GitGraphOutput, HealthInput, HealthReport, LayoutKind, + ListProjectsOutput, LiveSessionKind, LiveSessionSnapshot, OpenProjectOutput, ProjectWorkState, + StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, TurnPage, TurnSource, TurnView, }; use domain::{AgentBusyState, PageCursor, PageDirection, Project, ProjectId, TurnRole}; @@ -2275,6 +2275,111 @@ impl From for ProjectWorkStateDto { } } +/// App-wide shutdown guard read model for the exit confirmation flow. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AppExitWorkGuardStateDto { + /// Whether at least one active work item would be interrupted by app exit. + pub has_work_in_progress: bool, + /// Number of busy agents across all open projects. + pub busy_agent_count: usize, + /// Number of non-terminal background tasks across all open projects. + pub active_background_task_count: usize, + /// Total active work items. + pub total_work_count: usize, + /// Best-effort compact details for the confirmation dialog. + pub details: Vec, +} + +impl From for AppExitWorkGuardStateDto { + fn from(state: AppExitWorkGuardState) -> Self { + Self { + has_work_in_progress: state.has_work_in_progress, + busy_agent_count: state.busy_agent_count, + active_background_task_count: state.active_background_task_count, + total_work_count: state.busy_agent_count + state.active_background_task_count, + details: state + .details + .into_iter() + .map(AppExitWorkGuardDetailDto::from) + .collect(), + } + } +} + +/// One active work item contributing to [`AppExitWorkGuardStateDto`]. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum AppExitWorkGuardDetailDto { + /// A manifest agent is currently processing a turn. + BusyAgent { + /// Owning project id. + project_id: String, + /// Owning project display name. + project_name: String, + /// Agent id. + agent_id: String, + /// Agent display name. + agent_name: String, + /// Busy ticket id, when available. + ticket_id: Option, + }, + /// A first-class background task is queued, running or waiting. + ActiveBackgroundTask { + /// Owning project id. + project_id: String, + /// Owning project display name. + project_name: String, + /// Owning agent id. + agent_id: String, + /// Owning agent display name. + agent_name: String, + /// Stable task id. + task_id: String, + /// Lifecycle state. + state: String, + /// Kind discriminant. + task_kind: String, + }, +} + +impl From for AppExitWorkGuardDetailDto { + fn from(detail: AppExitWorkGuardDetail) -> Self { + match detail { + AppExitWorkGuardDetail::BusyAgent { + project_id, + project_name, + agent_id, + agent_name, + ticket_id, + } => Self::BusyAgent { + project_id: project_id.to_string(), + project_name, + agent_id: agent_id.to_string(), + agent_name, + ticket_id: ticket_id.map(|id| id.to_string()), + }, + AppExitWorkGuardDetail::ActiveBackgroundTask { + project_id, + project_name, + agent_id, + agent_name, + task_id, + state, + kind, + } => Self::ActiveBackgroundTask { + project_id: project_id.to_string(), + project_name, + agent_id: agent_id.to_string(), + agent_name, + task_id: task_id.to_string(), + state: background_state_label(state).to_owned(), + task_kind: background_kind_label_from_work_state(kind).to_owned(), + }, + } + } +} + /// Request DTO for `attach_live_agent`: bind an already-running agent session to /// a visible layout cell without spawning a new process. #[derive(Debug, Clone, Deserialize)] diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index c4d9417..2c5e8a0 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -21,30 +21,30 @@ use application::{ CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, DeleteMemory, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion, - EnsureLocalModelServer, FirstRunState, GetLiveStateLean, GetMemory, GetProjectPermissions, - GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, - GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, LaunchAgent, - LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListDevices, ListEmbedderProfiles, - ListIssues, ListLayouts, ListMemories, ListModelServers, ListProfiles, ListProjects, - ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions, - LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, - McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, - OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice, - PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext, - ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, - ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, - ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, - RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, - ResolveAgentPermissions, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, - RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveEmbedderProfile, SaveModelServer, - SaveProfile, SessionLimitService, SetActiveLayout, SnapshotOpenWindows, SnapshotRunningAgents, - SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions, - SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice, - UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues, UpdateAgentContext, - UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, - UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectMcpToolPermissions, - UpdateProjectPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, - WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, + EnsureLocalModelServer, FirstRunState, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory, + GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, + GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, + InspectConversation, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, + ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers, + ListProfiles, ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates, + LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, LiveStateProvider, + LiveStateReadProvider, LoadLayout, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow, + MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant, + OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, + ProposeContext, ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, + ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, ReadProjectContext, + ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, ReconcileLiveState, + ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameDevice, + RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, + ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, RevokeAllDevices, RevokeDevice, + RotateConversationLog, SaveEmbedderProfile, SaveModelServer, SaveProfile, SessionLimitService, + SetActiveLayout, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, + StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession, + SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent, + UnassignTicketFromSprint, UnlinkIssues, UpdateAgentContext, UpdateAgentMcpToolPermissions, + UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, + UpdateProjectContext, UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateSkill, + UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, }; use async_trait::async_trait; use domain::ports::{ @@ -1020,6 +1020,8 @@ pub struct BackendCore { pub list_resumable_agents: Arc, /// Read-only live/busy state for the project's manifest agents. pub get_project_work_state: Arc, + /// App-wide work-in-progress guard used before confirmed application exit. + pub get_app_exit_work_guard_state: Arc, /// Human paginated read of a conversation's full transcript (lot LS6). pub read_conversation_page: Arc, /// Best-effort log rotation, triggered off the hot path at thread resume/open (lot LS6). @@ -2233,6 +2235,9 @@ impl BackendCore { ) .with_background_tasks(Arc::clone(&background_tasks_port)), ); + let get_app_exit_work_guard_state = Arc::new(GetAppExitWorkGuardState::new(Arc::clone( + &get_project_work_state, + ))); // Lot LS6 — rotation (hors chemin chaud) + lecture humaine paginée. Tous deux // composent le provider d'archive par root ; la rotation lit aussi le handoff // (plancher `up_to`, INV-LS6). Aucune persistance déclenchée par un `append`. @@ -2543,6 +2548,7 @@ impl BackendCore { change_agent_profile, list_resumable_agents, get_project_work_state, + get_app_exit_work_guard_state, read_conversation_page, rotate_conversation_log, attach_live_agent,