From 7d115ac6e6bc4cbc599e19ae6be7d7e2eddc1b47 Mon Sep 17 00:00:00 2001 From: Hussain Phalasiya Date: Sun, 18 Oct 2026 14:15:45 +0300 Subject: [PATCH] VC-796: host-owned project.remove stops a project's live work before its delete Removing a project deleted its row (and, by cascade, every Session and its event history) while the Sessions' structured bindings, background shells, terminals and Browser Tabs stayed live; the only teardown ran in the renderer, after the delete, and killed terminal PTYs alone. project.remove (a DESKTOP_ENTRIES key and a handler-map entry) now releases the project's Sessions' bindings, disposes their shells, kills their terminals, closes the project's tabs and cancels its MCP sign-ins; then deletes; then forgets the cascaded MCP servers' credentials, ends the board feed and publishes data-changed to every window. volli:project-remove is its projection, and the renderer only forgets its own views. --- .../desktop/src/main/data-ipc.test-support.ts | 7 +- apps/desktop/src/main/data-ipc.test.ts | 68 +++- apps/desktop/src/main/data-ipc.ts | 23 +- apps/desktop/src/main/index.ts | 16 +- .../rail/remove-project-busy.test.ts | 72 ++++ .../components/rail/remove-project-busy.ts | 49 +++ .../components/rail/remove-project-dialog.tsx | 27 +- .../src/renderer/src/stores/projects.test.ts | 13 +- .../src/renderer/src/stores/projects.ts | 22 +- .../src/terminal/session-lifecycle.test.ts | 85 ++--- .../src/terminal/session-lifecycle.ts | 38 +- docs/protocol/protocol.schema.json | 26 ++ packages/host-core/src/db/projects-repo.ts | 12 + packages/host-core/src/entries/handlers.ts | 6 + .../src/handlers/host-handlers.test.ts | 41 +++ .../host-core/src/handlers/host-handlers.ts | 30 +- packages/host-core/src/mcp/settings.test.ts | 41 ++- packages/host-core/src/mcp/settings.ts | 23 ++ packages/host-core/src/project-remove.test.ts | 337 ++++++++++++++++++ packages/host-core/src/project-remove.ts | 165 +++++++++ .../host-core/src/pty/manager.stream.test.ts | 21 ++ packages/host-core/src/pty/manager.ts | 14 + .../host-core/src/worktree/agent-sites.ts | 41 ++- packages/host-core/vite.config.ts | 4 + packages/session-rpc/src/desktop-ipc.test.ts | 1 + packages/session-rpc/src/desktop-ipc.ts | 1 + .../session-rpc/src/desktop-router.test.ts | 2 + packages/session-rpc/src/desktop-router.ts | 14 + .../src/handler-projection.test.ts | 1 + .../src/session-handlers.test-support.test.ts | 6 +- .../src/session-handlers.test-support.ts | 1 + packages/shared/src/desktop-entries.test.ts | 1 + packages/shared/src/desktop-entries.ts | 9 + 33 files changed, 1093 insertions(+), 124 deletions(-) create mode 100644 apps/desktop/src/renderer/src/components/rail/remove-project-busy.test.ts create mode 100644 apps/desktop/src/renderer/src/components/rail/remove-project-busy.ts create mode 100644 packages/host-core/src/project-remove.test.ts create mode 100644 packages/host-core/src/project-remove.ts diff --git a/apps/desktop/src/main/data-ipc.test-support.ts b/apps/desktop/src/main/data-ipc.test-support.ts index 1d692c3ce..35637262b 100644 --- a/apps/desktop/src/main/data-ipc.test-support.ts +++ b/apps/desktop/src/main/data-ipc.test-support.ts @@ -10,7 +10,7 @@ * files may import (`host-core-testing-boundary.test.ts`). */ import type { DbHandle } from "@volli/host-core"; -import { createHostHandlers } from "@volli/host-core/handlers"; +import { createHostHandlers, type ProjectLiveWork } from "@volli/host-core/handlers"; import type { TicketMovedNotice } from "@volli/shared"; import { windowEventBus } from "./broadcast"; @@ -22,10 +22,12 @@ export type TestDataIpcOptions = Omit & { handlers?: Options["handlers"]; interruptTicketSessions?: (ticketId: string) => string[] | Promise; onDeliberateMove?: (notice: TicketMovedNotice) => void; + /** What `project.remove` stops before its delete (VC-796); absent, it answers unavailable. */ + projectLiveWork?: ProjectLiveWork; }; export function registerDataIpcHandlers(handle: DbHandle, options: TestDataIpcOptions): void { - const { interruptTicketSessions, onDeliberateMove, handlers, ...rest } = options; + const { interruptTicketSessions, onDeliberateMove, projectLiveWork, handlers, ...rest } = options; register(handle, { ...rest, handlers: @@ -58,6 +60,7 @@ export function registerDataIpcHandlers(handle: DbHandle, options: TestDataIpcOp busyWorktreeSites: rest.busyWorktreeSites ?? (async () => [{ directory: "/", surface: "agent" }]), ...(interruptTicketSessions === undefined ? {} : { interruptTicketSessions }), + ...(projectLiveWork === undefined ? {} : { projectLiveWork }), ...(rest.detachedWork === undefined ? {} : { detachedWork: rest.detachedWork }), // The board's commands own these effects now (VC-565): the archive's // tool-output release and the sidebar's Session signals. diff --git a/apps/desktop/src/main/data-ipc.test.ts b/apps/desktop/src/main/data-ipc.test.ts index 79ec89f7c..3dad0126e 100644 --- a/apps/desktop/src/main/data-ipc.test.ts +++ b/apps/desktop/src/main/data-ipc.test.ts @@ -234,6 +234,7 @@ vi.mock("../../../../packages/host-core/src/worktree/index", async () => ({ import { flushDataChangedForTest } from "./broadcast"; import { registerDataIpcHandlers } from "./data-ipc.test-support"; +import type { ProjectLiveWork } from "@volli/host-core/handlers"; import { watchSessionActivity } from "@volli/host-core/sessions"; import { createTestSessionEngine, @@ -450,19 +451,72 @@ describe("volli:database", () => { * path, and that bargain is only honest if something refuses it earlier, where a * person is present to be told. */ +// VC-796: the channel projects the host's `project.remove`. The cascade takes +// every Session of the project, so the live work they hold stops first, while +// the row is still there, and every window (a second one included) is told. describe("project removal", () => { - it("tells the host after the project is gone, so it releases the board feed it held", () => { - const removed = vi.fn<(projectId: string) => void>(); + function liveWork(projectId: string, stillOpen: readonly string[] = []) { + const stopped: string[] = []; + const stop = (step: string) => { + // Each stop runs while the project, and so its Sessions, still exist. + expect(getProjectById(ctx.db, projectId)).toBeDefined(); + stopped.push(step); + }; + const live: ProjectLiveWork = { + releaseBindings: async () => { + stop("bindings"); + return { stillOpen }; + }, + disposeShells: () => stop("shells"), + killTerminals: () => stop("terminals"), + closeBrowserTabs: () => stop("tabs"), + mcp: null, + }; + return { live, stopped }; + } + + it("stops the project's live work before deleting it, then tells every window", async () => { + const projectId = createProject(); + const { live, stopped } = liveWork(projectId); registerDataIpcHandlers( { ok: true, db: ctx.db }, - { sessionEngine: fixtureSessionEngine, onProjectRemoved: removed }, + { sessionEngine: fixtureSessionEngine, projectLiveWork: live }, ); + flushDataChangedForTest(); + dataChangedSends.length = 0; + + await expect( + invoke>("volli:project-remove", projectId), + ).resolves.toEqual({ ok: true }); + + expect(stopped).toEqual(["bindings", "shells", "terminals", "tabs"]); + expect(getProjectById(ctx.db, projectId)).toBeUndefined(); + // Untargeted, so a window that still holds the project re-reads the list + // without it; the fan-out reaches every window, a second one included. + await expectDataChanged({ entity: "tickets" }); + }); + + it("keeps the project, and says why, when one of its Sessions will not stop", async () => { const projectId = createProject(); - removed.mockImplementation(() => { - expect(getProjectById(ctx.db, projectId)).toBeUndefined(); + const { live, stopped } = liveWork(projectId, ["session-1"]); + registerDataIpcHandlers( + { ok: true, db: ctx.db }, + { sessionEngine: fixtureSessionEngine, projectLiveWork: live }, + ); + flushDataChangedForTest(); + dataChangedSends.length = 0; + + await expect( + invoke>("volli:project-remove", projectId), + ).resolves.toEqual({ + ok: false, + error: + "A Session in this project is still running, so nothing was removed. Stop it and try again.", }); - expect(invoke("volli:project-remove", projectId)).toEqual({ ok: true }); - expect(removed).toHaveBeenCalledExactlyOnceWith(projectId); + + expect(stopped).toEqual(["bindings"]); + expect(getProjectById(ctx.db, projectId)).toBeDefined(); + expectNoDataChange(); }); }); diff --git a/apps/desktop/src/main/data-ipc.ts b/apps/desktop/src/main/data-ipc.ts index 56c971aa6..a8c8b615f 100644 --- a/apps/desktop/src/main/data-ipc.ts +++ b/apps/desktop/src/main/data-ipc.ts @@ -13,7 +13,6 @@ import { MIN_READER_VERSION_KEY, listAllLabels, countProjects, - deleteProject, getProjectById, insertProject, listProjects, @@ -226,8 +225,14 @@ import { type HostHandlers, } from "@volli/host-core/handlers"; -/** The handler keys this channel table projects: the board's (VC-565) and the move (VC-668). */ -type BoardChannelKey = "ticket.move" | Extract; +/** + * The handler keys this channel table projects: the board's (VC-565), the + * move (VC-668) and the project's removal (VC-796). + */ +type BoardChannelKey = + | "ticket.move" + | "project.remove" + | Extract; import type { HandlerCall } from "@volli/shared"; /** The person at this desktop's own window, whose reply carries the board. */ @@ -406,8 +411,6 @@ export function registerDataIpcHandlers( * output. Absent (tests, degraded boot) means nothing is removed. */ piSessionsDirectory?: string; - /** Told after a project is removed: the host releases what it held for it (its board feed). */ - onProjectRemoved?: (projectId: string) => void; }, ): void { if (!handle.ok) { @@ -631,9 +634,13 @@ export function registerDataIpcHandlers( return { ok: true, project: outcome.project, aftermath: outcome.aftermath }; }, - "volli:project-remove": (id: string): ProjectMutationResult => { - deleteProject(db, id); - options.onProjectRemoved?.(id); + /** + * The window's projection of the host's removal (VC-796): the project's + * live work stopped, then the delete, its feed ended and every window + * told. The renderer keeps only its own views' forgetting. + */ + "volli:project-remove": async (id: string): Promise => { + await board("project.remove", { projectId: id }); return { ok: true }; }, diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 3cb1b177f..62531dbbe 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -169,6 +169,7 @@ import type { OpenNativeBinding } from "@volli/session-engine"; import { admittedHandlers, createHostHandlers, + hostProjectLiveWork, ROUTER_POLICY, type HostHandlerMap, } from "@volli/host-core/handlers"; @@ -1529,6 +1530,19 @@ const appStartup = app.whenReady().then(async () => { automations: runtimeAutomations, busyWorktreeSites, interruptTicketSessions: interruptTicketSessionsAnnounced, + // What removing a project stops before its delete (VC-796): every door + // that removes one, the window's `volli:project-remove` included. + projectLiveWork: hostProjectLiveWork({ + runtime: () => sessionRuntime, + shells: backgroundShells, + terminals: () => ptyManagerRef ?? null, + browser: browserTabs, + mcp: mcpSettings, + newCommandId: randomUUID, + onReleaseError: (sessionId, error) => { + log.error("could not release session of a removed project", { sessionId, error }); + }, + }), ...(liveHost === undefined ? {} : { detachedWork: liveHost.detachedWork }), // This Mac's recent log (VC-699): main's, the in-process host's and the // renderer's lines, which the dev log viewer reads through `host.logs`. @@ -1856,8 +1870,6 @@ const appStartup = app.whenReady().then(async () => { mcpSettings: mcpSettings ?? undefined, // Archiving or deleting a ticket drops its Sessions' saved tool output (VC-469). piSessionsDirectory, - // A removed Workspace's board feed is released, its followers told to resnapshot. - onProjectRemoved: (projectId) => boardFeed.dispose(projectId), }); // Pi sidecar cleanup is a separate, explicit surface: registration performs // no scan and no deletion. The read-only inventory must run before its diff --git a/apps/desktop/src/renderer/src/components/rail/remove-project-busy.test.ts b/apps/desktop/src/renderer/src/components/rail/remove-project-busy.test.ts new file mode 100644 index 000000000..0cab12a16 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/rail/remove-project-busy.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { BackgroundShellState, ChatSessionRecord } from "@volli/shared"; + +import { openTurnCount, removeProjectBusyLines, runningShellCount } from "./remove-project-busy"; + +function chat(activity: ChatSessionRecord["activity"]): ChatSessionRecord { + return { activity } as ChatSessionRecord; +} + +function shell(shellId: string, projectId: string, state: "running" | "exited") { + return { shellId, projectId, state } as BackgroundShellState; +} + +// VC-796: the host stops structured Sessions and background shells along with +// terminals, so the confirm names each kind it would end. +describe("removeProjectBusyLines", () => { + it("names nothing when removing ends nothing", () => { + expect(removeProjectBusyLines({ busyProcesses: [], openTurns: 0, runningShells: 0 })).toEqual( + [], + ); + }); + + it("names a busy terminal, an open turn and a running shell, each on its own line", () => { + expect( + removeProjectBusyLines({ busyProcesses: ["vite"], openTurns: 1, runningShells: 1 }), + ).toEqual([ + "“vite” is still running. Removing the project will end it.", + "1 agent Session has a turn open. Removing the project will end it.", + "1 background shell is running. Removing the project will end it.", + ]); + }); + + it("counts several of each", () => { + expect( + removeProjectBusyLines({ busyProcesses: ["vite", "vite"], openTurns: 3, runningShells: 2 }), + ).toEqual([ + "2 terminals are still running (vite). Removing the project will end them.", + "3 agent Sessions have turns open. Removing the project will end them.", + "2 background shells are running. Removing the project will end them.", + ]); + }); +}); + +describe("openTurnCount", () => { + it("counts working and waiting Sessions, and nothing before the listing loads", () => { + expect(openTurnCount(undefined)).toBe(0); + expect( + openTurnCount([ + chat("working"), + chat("waiting"), + chat("idle"), + chat("stopped"), + chat("interrupted"), + ]), + ).toBe(2); + }); +}); + +describe("runningShellCount", () => { + it("counts only this project's running shells", () => { + expect( + runningShellCount( + { + a: shell("a", "p", "running"), + b: shell("b", "p", "exited"), + c: shell("c", "q", "running"), + }, + "p", + ), + ).toBe(1); + }); +}); diff --git a/apps/desktop/src/renderer/src/components/rail/remove-project-busy.ts b/apps/desktop/src/renderer/src/components/rail/remove-project-busy.ts new file mode 100644 index 000000000..08e213313 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/rail/remove-project-busy.ts @@ -0,0 +1,49 @@ +/** + * What removing a project would end, for its confirm (VC-796). + * + * The host stops every kind of live work a project's Sessions hold before it + * deletes the project, so the warning names each kind, never blended: a busy + * terminal, a structured Session with a turn open, a running background shell. + * Each read is what this window already holds; a count it cannot read yet is + * zero, never a reason to hold the dialog back. + */ +import type { BackgroundShellState, ChatSessionRecord } from "@volli/shared"; + +import { describeBusy } from "@renderer/terminal/close-guard"; + +const TAIL = ". Removing the project will end"; + +/** Structured Sessions of the project with a turn open: working, or blocked on a question. */ +export function openTurnCount(chat: readonly ChatSessionRecord[] | undefined): number { + return (chat ?? []).filter(({ activity }) => activity === "working" || activity === "waiting") + .length; +} + +/** The project's background shells still running. */ +export function runningShellCount( + shells: Readonly>, + projectId: string, +): number { + return Object.values(shells).filter( + (shell) => shell.projectId === projectId && shell.state === "running", + ).length; +} + +/** One warning line per kind with anything at stake; empty when removing ends nothing. */ +export function removeProjectBusyLines(work: { + busyProcesses: string[]; + openTurns: number; + runningShells: number; +}): string[] { + const lines: string[] = []; + if (work.busyProcesses.length > 0) lines.push(describeBusy(work.busyProcesses, TAIL)); + if (work.openTurns === 1) lines.push(`1 agent Session has a turn open${TAIL} it.`); + else if (work.openTurns > 1) { + lines.push(`${work.openTurns} agent Sessions have turns open${TAIL} them.`); + } + if (work.runningShells === 1) lines.push(`1 background shell is running${TAIL} it.`); + else if (work.runningShells > 1) { + lines.push(`${work.runningShells} background shells are running${TAIL} them.`); + } + return lines; +} diff --git a/apps/desktop/src/renderer/src/components/rail/remove-project-dialog.tsx b/apps/desktop/src/renderer/src/components/rail/remove-project-dialog.tsx index 7a0e6e166..0ecc98914 100644 --- a/apps/desktop/src/renderer/src/components/rail/remove-project-dialog.tsx +++ b/apps/desktop/src/renderer/src/components/rail/remove-project-dialog.tsx @@ -11,9 +11,13 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@renderer/components/ui/alert-dialog"; +import { useBackgroundShellsStore } from "@renderer/stores/background-shells"; +import { useProjectSessionsStore } from "@renderer/stores/project-sessions"; import { useProjectsStore } from "@renderer/stores/projects"; import { sessionPanes, useSessionsStore } from "@renderer/stores/sessions"; -import { busySessionInfo, describeBusy } from "@renderer/terminal/close-guard"; +import { busySessionInfo } from "@renderer/terminal/close-guard"; + +import { openTurnCount, removeProjectBusyLines, runningShellCount } from "./remove-project-busy"; interface RemoveProjectDialogProps { project: Project; @@ -24,7 +28,7 @@ interface RemoveProjectDialogProps { /** * The live pane ids removing `projectId` would kill: its Board Sessions PLUS * every ticket session whose scope belongs to it (mirrors - * `killProjectTicketSessions`'s enumeration). Only panes still live (no exit + * `forgetProjectSessions`'s enumeration). Only panes still live (no exit * code) — an exited pane has no PTY left to end. */ function liveSessionIdsForProject(projectId: string): string[] { @@ -50,6 +54,15 @@ export function RemoveProjectDialog({ project, open, onOpenChange }: RemoveProje // Busy foreground processes across the project's live sessions, filled in when // the (fail-open) probe resolves — the dialog renders immediately regardless. const [busyProcesses, setBusyProcesses] = React.useState([]); + // The structured Sessions and background shells the host also stops (VC-796), + // read from what this window already holds. + const openTurns = useProjectSessionsStore((state) => + openTurnCount(state.byProject[project.id]?.chat), + ); + const runningShells = useBackgroundShellsStore((state) => + runningShellCount(state.byId, project.id), + ); + const busyLines = removeProjectBusyLines({ busyProcesses, openTurns, runningShells }); React.useEffect(() => { if (!open) { @@ -57,6 +70,8 @@ export function RemoveProjectDialog({ project, open, onOpenChange }: RemoveProje return; } let cancelled = false; + // Fail-open like the probe: a listing that cannot be read counts nothing. + void useProjectSessionsStore.getState().ensure(project.id); void busySessionInfo(liveSessionIdsForProject(project.id)).then((busy) => { if (!cancelled) setBusyProcesses(busy.map((entry) => entry.process)); }); @@ -72,11 +87,11 @@ export function RemoveProjectDialog({ project, open, onOpenChange }: RemoveProje Remove {project.name} from Volli? The folder on disk is untouched. - {busyProcesses.length > 0 && ( - - {describeBusy(busyProcesses, ". Removing the project will end")} + {busyLines.map((line) => ( + + {line} - )} + ))} diff --git a/apps/desktop/src/renderer/src/stores/projects.test.ts b/apps/desktop/src/renderer/src/stores/projects.test.ts index 67af953e8..b33519e08 100644 --- a/apps/desktop/src/renderer/src/stores/projects.test.ts +++ b/apps/desktop/src/renderer/src/stores/projects.test.ts @@ -494,9 +494,10 @@ describe("removeProject", () => { expect(useBoardStore.getState().ticketsByProject[only.id]).toBeUndefined(); }); - it("kills the removed project's live PTYs and forgets its terminal sessions", async () => { - // removeProject must tear PTYs down explicitly (no terminal view is - // mounted here — or in headless flows — to do it as an unmount side effect). + it("forgets the removed project's terminal sessions, leaving their PTYs to the host", async () => { + // The host's `project.remove` ended the PTYs before its delete (VC-796), + // for every window; the store only forgets this window's views of them, + // with no terminal view mounted to do it as an unmount side effect. // Keeps `appState` in the stub too: zustand's `persist` middleware writes // through on EVERY action call, even a logical no-op (see the module doc // above) — removeProject's cascade always touches useWorkspaceStore. @@ -514,10 +515,10 @@ describe("removeProject", () => { await store.getState().removeProject(only.id); expect(useSessionsStore.getState().byOwner[only.id]).toBeUndefined(); - expect(kill).toHaveBeenCalledWith("s1"); + expect(kill).not.toHaveBeenCalled(); }); - it("kills the removed project's ticket-scoped sessions too", async () => { + it("forgets the removed project's ticket-scoped sessions too", async () => { const kill = vi .fn<(sessionId: string) => Promise<{ ok: boolean }>>() .mockResolvedValue({ ok: true }); @@ -539,7 +540,7 @@ describe("removeProject", () => { await store.getState().removeProject(only.id); expect(useSessionsStore.getState().byOwner["tk1"]).toBeUndefined(); - expect(kill).toHaveBeenCalledWith("ts1"); + expect(kill).not.toHaveBeenCalled(); }); it("disposes the removed project's own ticketless chat clients", async () => { diff --git a/apps/desktop/src/renderer/src/stores/projects.ts b/apps/desktop/src/renderer/src/stores/projects.ts index d2114310c..b61f04736 100644 --- a/apps/desktop/src/renderer/src/stores/projects.ts +++ b/apps/desktop/src/renderer/src/stores/projects.ts @@ -38,10 +38,7 @@ import { create } from "zustand"; import { sessionRpcClient } from "@renderer/lib/session-rpc-ipc-link"; import { toastError } from "@renderer/lib/toast"; -import { - killProjectSessions, - killProjectTicketSessions, -} from "@renderer/terminal/session-lifecycle"; +import { forgetProjectSessions } from "@renderer/terminal/session-lifecycle"; import { useBoardStore } from "./board"; import { hostOfProject, isRemoteProject, useHostConnectionStore } from "./host-connection"; @@ -668,21 +665,18 @@ export function createProjectsStore( if (!result) return; localProjectIds.delete(id); - // Removal, per-workspace-UI cleanup, and session teardown are one - // invariant, enforced here so no removal path (dialog today, context - // menu / CLI later) can forget the forget. Each kill* helper kills every - // live PTY and disposes its engine explicitly — teardown does NOT depend - // on a terminal view being mounted — then drops the session record. - // Ticket sessions are keyed by ticketId; killProjectTicketSessions finds - // them from the SESSIONS store (not the board's live ticket list), so an - // archived ticket's sessions are torn down too, not just live ones. - killProjectTicketSessions(id); + // The host stopped the project's live work before it deleted it (VC-796): + // its Sessions' bindings, shells, terminals and tabs, for every window + // and whichever door removed it. What is left here is this window's own + // views of it. `forgetProjectSessions` disposes the terminal engines + // and drops their records (the PTYs behind them are already gone), the + // archived tickets' included, whether or not a view is mounted. + forgetProjectSessions(id); useWorkspaceStore.getState().forget(id); // `board.forget` owns chat teardown as well as its owner-key bookkeeping, // so a local removal and an authoritative board hydration that loses the // same project share one disposal path. useBoardStore.getState().forget(id); - killProjectSessions(id); // Re-read FRESH: a concurrent add/reorder may have changed `projects` // while the remove IPC was in flight; computing the next list from the diff --git a/apps/desktop/src/renderer/src/terminal/session-lifecycle.test.ts b/apps/desktop/src/renderer/src/terminal/session-lifecycle.test.ts index 5a043fff8..6a5581408 100644 --- a/apps/desktop/src/renderer/src/terminal/session-lifecycle.test.ts +++ b/apps/desktop/src/renderer/src/terminal/session-lifecycle.test.ts @@ -13,8 +13,7 @@ import { closeTerminalPane, closeTerminalSession, closeTicketSession, - killProjectSessions, - killProjectTicketSessions, + forgetProjectSessions, killTicketSessions, renameTerminalSession, } from "./session-lifecycle"; @@ -156,41 +155,6 @@ describe("closeTerminalPane", () => { }); }); -describe("killProjectSessions", () => { - it("kills live tabs, skips exited ones, disposes every engine, and forgets the project", () => { - useSessionsStore.getState().addSession(projectScope("p"), "s1", shellLaunch("Terminal 1")); - useSessionsStore.getState().addSession(projectScope("p"), "s2", shellLaunch("Terminal 2")); - useSessionsStore.getState().markExited("s1", 130); - useSessionsStore.getState().addSession(projectScope("other"), "o1", shellLaunch("Terminal 1")); - - killProjectSessions("p"); - - expect(killMock).toHaveBeenCalledTimes(1); - expect(killMock).toHaveBeenCalledWith("s2"); - expect(vi.mocked(disposeEngine)).toHaveBeenCalledWith("s1"); - expect(vi.mocked(disposeEngine)).toHaveBeenCalledWith("s2"); - expect(useSessionsStore.getState().byOwner["p"]).toBeUndefined(); - expect(useSessionsStore.getState().byOwner["other"]?.tabs).toHaveLength(1); - }); - - it("tears down every pane inside split tabs", () => { - useSessionsStore.getState().addSession(projectScope("p"), "s1", shellLaunch("Terminal 1")); - useSessionsStore.getState().addSplit("p", "s1", "s1", "s2", "horizontal"); - - killProjectSessions("p"); - - expect(vi.mocked(disposeEngine)).toHaveBeenCalledTimes(2); - expect(killMock).toHaveBeenCalledTimes(2); - }); - - it("is a no-op for a project with no sessions", () => { - killProjectSessions("never-added"); - - expect(killMock).not.toHaveBeenCalled(); - expect(vi.mocked(disposeEngine)).not.toHaveBeenCalled(); - }); -}); - describe("closeTicketSession", () => { it("drops the ticket tab, disposes its engine, and kills its live PTY", () => { useSessionsStore.getState().addSession(ticketScope("p", "t1"), "s1", shellLaunch("Session 1")); @@ -335,32 +299,41 @@ describe("renameTerminalSession", () => { }); }); -describe("killProjectTicketSessions", () => { - it("kills a project's ticket sessions (including ones the board no longer lists) and leaves other projects and Board Sessions alone", () => { +// VC-796: the host ends a removed project's PTYs before its delete, for every +// window; the renderer only forgets the views that drew them. +describe("forgetProjectSessions", () => { + it("disposes and forgets the project's Board and ticket sessions, archived tickets' too, killing nothing", () => { + useSessionsStore.getState().addSession(projectScope("p"), "sp", shellLaunch("Terminal 1")); + useSessionsStore.getState().addSplit("p", "sp", "sp", "sp2", "horizontal"); useSessionsStore.getState().addSession(ticketScope("p", "t1"), "s1", shellLaunch("Session 1")); useSessionsStore.getState().addSession(ticketScope("p", "t2"), "s2", shellLaunch("Session 1")); + useSessionsStore.getState().markExited("s2", 0); useSessionsStore.getState().addSession(ticketScope("q", "t3"), "s3", shellLaunch("Session 1")); - useSessionsStore.getState().addSession(projectScope("p"), "sp", shellLaunch("Terminal 1")); - - killProjectTicketSessions("p"); - - expect(useSessionsStore.getState().byOwner["t1"]).toBeUndefined(); - expect(useSessionsStore.getState().byOwner["t2"]).toBeUndefined(); - // A ticket under a different project is untouched, as is p's Board Session. + useSessionsStore.getState().addSession(projectScope("q"), "sq", shellLaunch("Terminal 1")); + + forgetProjectSessions("p"); + + for (const ownerId of ["p", "t1", "t2"]) { + expect(useSessionsStore.getState().byOwner[ownerId]).toBeUndefined(); + } + expect( + vi + .mocked(disposeEngine) + .mock.calls.map(([sessionId]) => sessionId) + .toSorted(), + ).toEqual(["s1", "s2", "sp", "sp2"]); + // Another project's sessions are untouched, and no PTY is killed from here. expect(useSessionsStore.getState().byOwner["t3"]?.tabs).toHaveLength(1); - expect(useSessionsStore.getState().byOwner["p"]?.tabs).toHaveLength(1); - expect(killMock).toHaveBeenCalledWith("s1"); - expect(killMock).toHaveBeenCalledWith("s2"); - expect(killMock).not.toHaveBeenCalledWith("s3"); - expect(killMock).not.toHaveBeenCalledWith("sp"); + expect(useSessionsStore.getState().byOwner["q"]?.tabs).toHaveLength(1); + expect(killMock).not.toHaveBeenCalled(); }); - it("is a no-op when the project has no ticket sessions", () => { - useSessionsStore.getState().addSession(projectScope("p"), "sp", shellLaunch("Terminal 1")); + it("is a no-op for a project with no sessions", () => { + useSessionsStore.getState().addSession(projectScope("q"), "sq", shellLaunch("Terminal 1")); - killProjectTicketSessions("p"); + forgetProjectSessions("never-added"); - expect(killMock).not.toHaveBeenCalled(); - expect(useSessionsStore.getState().byOwner["p"]?.tabs).toHaveLength(1); + expect(vi.mocked(disposeEngine)).not.toHaveBeenCalled(); + expect(useSessionsStore.getState().byOwner["q"]?.tabs).toHaveLength(1); }); }); diff --git a/apps/desktop/src/renderer/src/terminal/session-lifecycle.ts b/apps/desktop/src/renderer/src/terminal/session-lifecycle.ts index 07e55e79d..8934c7280 100644 --- a/apps/desktop/src/renderer/src/terminal/session-lifecycle.ts +++ b/apps/desktop/src/renderer/src/terminal/session-lifecycle.ts @@ -61,32 +61,38 @@ export function closeTicketSession(ticketId: string, sessionId: string): void { closeTerminalSession(ticketId, sessionId); } -/** Tear down every ticket session of a ticket (e.g. its project was removed). */ +/** Tear down every ticket session of a ticket: dispose its engines, kill its live PTYs. */ export function killTicketSessions(ticketId: string): void { killOwnerSessions(ticketId); } -/** Tear down every session of a removed project, whether or not views are mounted. */ -export function killProjectSessions(projectId: string): void { - killOwnerSessions(projectId); -} - /** - * Tear down every TICKET session whose scope belongs to `projectId`, keyed off - * the sessions store rather than the board's live ticket list — so archived - * tickets (whose ids `ticketsByProject` no longer holds) don't leak a PTY when - * their project is removed. Each ticket owner's tabs all share the ticket's + * Forget every terminal view of a removed project (VC-796): its Board Sessions' + * owner, and every ticket owner whose scope belongs to it — keyed off the + * sessions store rather than the board's live ticket list, so an archived + * ticket's views go too. Each ticket owner's tabs all share the ticket's * scope, so one tab's scope identifies the whole container's project. + * + * The host ended these PTYs before it deleted the project, for every window + * at once, so nothing is killed from here: this disposes the engines that drew + * them and drops the records, whether or not a view is mounted. */ -export function killProjectTicketSessions(projectId: string): void { +export function forgetProjectSessions(projectId: string): void { const owners = Object.entries(useSessionsStore.getState().byOwner) - .filter(([, container]) => - container.tabs.some( - (tab) => tab.scope.kind === "ticket" && tab.scope.projectId === projectId, - ), + .filter( + ([ownerId, container]) => + ownerId === projectId || + container.tabs.some( + (tab) => tab.scope.kind === "ticket" && tab.scope.projectId === projectId, + ), ) .map(([ownerId]) => ownerId); - for (const ownerId of owners) killOwnerSessions(ownerId); + for (const ownerId of owners) { + for (const tab of useSessionsStore.getState().byOwner[ownerId]!.tabs) { + for (const pane of sessionPanes(tab.layout)) disposeEngine(pane.sessionId); + } + useSessionsStore.getState().forgetOwner(ownerId); + } } /** Dispose every engine + kill every live PTY under an owner, then forget it. */ diff --git a/docs/protocol/protocol.schema.json b/docs/protocol/protocol.schema.json index 10792f962..7571f6cb6 100644 --- a/docs/protocol/protocol.schema.json +++ b/docs/protocol/protocol.schema.json @@ -5037,6 +5037,32 @@ "outputValidation": "network-and-tests", "voidOutput": false }, + "project.remove": { + "compatibility": "host-command", + "input": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "projectId": { + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "projectId" + ], + "type": "object" + }, + "kind": "mutation", + "noInput": false, + "output": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "null" + }, + "outputValidation": "network-and-tests", + "voidOutput": false + }, "project.reorder": { "compatibility": "host-command", "input": { diff --git a/packages/host-core/src/db/projects-repo.ts b/packages/host-core/src/db/projects-repo.ts index c88582339..c96d44d98 100644 --- a/packages/host-core/src/db/projects-repo.ts +++ b/packages/host-core/src/db/projects-repo.ts @@ -517,6 +517,18 @@ export function insertProject(db: Database.Database, project: Project): void { }); } +/** + * Every Session of a project, ended or not: what its removal's cascade would + * take, and so what has to stop first (VC-796). + */ +export function listProjectSessionIds(db: Database.Database, projectId: string): string[] { + return ( + prepared(db, "SELECT id FROM sessions WHERE project_id = ? ORDER BY id").all(projectId) as { + id: string; + }[] + ).map(({ id }) => id); +} + /** Deletes a project; `ON DELETE CASCADE` takes its tickets/labels/ticket_events with it. */ export function deleteProject(db: Database.Database, id: string): void { prepared(db, "DELETE FROM projects WHERE id = ?").run(id); diff --git a/packages/host-core/src/entries/handlers.ts b/packages/host-core/src/entries/handlers.ts index 2f1d8657f..09171916b 100644 --- a/packages/host-core/src/entries/handlers.ts +++ b/packages/host-core/src/entries/handlers.ts @@ -31,6 +31,12 @@ export type { RemoteHostUpdateWhen, } from "../handlers/remote-hosts-port"; export type { RemoteSignInsPort } from "../handlers/remote-sign-ins-port"; +// What `project.remove` stops before its delete (VC-796), over a host's services. +export { + hostProjectLiveWork, + type ProjectLiveWork, + type ProjectLiveWorkSources, +} from "../project-remove"; export type { HostLinkRelayPort, HostScopeRelayPort } from "../handlers/host-link-relay-port"; export type { BrowserShortcutsPort } from "../handlers/browser-shortcuts-port"; export { diff --git a/packages/host-core/src/handlers/host-handlers.test.ts b/packages/host-core/src/handlers/host-handlers.test.ts index d2c0aabc8..ddc63dcdd 100644 --- a/packages/host-core/src/handlers/host-handlers.test.ts +++ b/packages/host-core/src/handlers/host-handlers.test.ts @@ -30,6 +30,8 @@ import { openTestDb, testProject, type TestDb } from "../db/test-helpers"; import { listProjects } from "../db/projects-repo"; import { getTicketRow } from "../db/tickets-repo"; import { BrowserPictureStore } from "../browser/picture-store"; +import { createBoardChangeFeed } from "../board/change-feed"; +import type { ProjectLiveWork } from "../project-remove"; import type { RuntimeAutomations } from "../session-runtime/automations"; import { createTicketCommand } from "../ticket-commands"; import { runGitCapturing, runGitCapturingAsync } from "../worktree/git"; @@ -770,6 +772,45 @@ describe("desktop-only commands", () => { expect(listProjects(ctx.db).map(({ id }) => id)).toEqual(["second", PROJECT]); }); + // VC-796: the order itself is `../project-remove`'s; here, that the map + // runs it against this host's feed and bus, and refuses without live work. + it("removes a project after its live work stops, ends its feed and tells every window", async () => { + insertProject(ctx.db, testProject({ id: "second", ticketPrefix: "SE" })); + const boardFeed = createBoardChangeFeed(); + boardFeed.cursor(PROJECT); + const stopped: string[] = []; + const projectLiveWork: ProjectLiveWork = { + releaseBindings: async () => { + stopped.push("bindings"); + return { stillOpen: [] }; + }, + disposeShells: () => stopped.push("shells"), + killTerminals: () => stopped.push("terminals"), + closeBrowserTabs: () => stopped.push("tabs"), + mcp: null, + }; + + expect( + await handlers({ boardFeed, projectLiveWork })["project.remove"]( + { projectId: PROJECT }, + WINDOW, + ), + ).toBeNull(); + + expect(stopped).toEqual(["bindings", "shells", "terminals", "tabs"]); + expect(listProjects(ctx.db).map(({ id }) => id)).toEqual(["second"]); + expect(boardFeed.size).toBe(0); + expect(publish.mock.calls).toEqual([["data-changed", {}]]); + }); + + it("refuses to remove a project on a host that cannot stop its live work", async () => { + expect( + await unavailable(() => handlers()["project.remove"]({ projectId: PROJECT }, WINDOW)), + ).toBe("Removing a project is unavailable on this host"); + expect(listProjects(ctx.db).map(({ id }) => id)).toEqual([PROJECT]); + expect(publish).not.toHaveBeenCalled(); + }); + it("reads the host's trim settings, defaults included", async () => { expect(await handlers()["worktree.trimSettings"](undefined, WINDOW)).toMatchObject({ trimOnFinish: expect.any(Boolean), diff --git a/packages/host-core/src/handlers/host-handlers.ts b/packages/host-core/src/handlers/host-handlers.ts index 2e1185efe..8efd209b1 100644 --- a/packages/host-core/src/handlers/host-handlers.ts +++ b/packages/host-core/src/handlers/host-handlers.ts @@ -138,6 +138,7 @@ import type { SessionAttachInput, SessionStartInput, Sessions } from "../session import { createBoardHandlers, type BoardHandlerSignatures } from "../board/commands"; import { createBoardChangeFeed, type BoardChangeFeed } from "../board/change-feed"; import { reorderProjects } from "../db/projects-repo"; +import { removeProject, type ProjectLiveWork } from "../project-remove"; import { executeTicketMove, type TicketMoveCommandInput, @@ -289,6 +290,8 @@ export interface HostHandlerSignatures extends BoardHandlerSignatures { { shellId: string } >; readonly "project.reorder": HostHandler<{ orderedIds: readonly string[] }, null>; + /** Stops a project's live work, then deletes it (VC-796): `../project-remove`. */ + readonly "project.remove": HostHandler<{ projectId: string }, null>; readonly "worktree.trimSettings": HostHandler; /** * Remote hosts this desktop added over SSH (VC-700 PR 2), all host-placed: @@ -496,6 +499,13 @@ export interface HostHandlerOptions { } | null; /** Where Pi keeps saved tool output, which an archive or delete releases (VC-469). */ readonly piSessionsDirectory?: string; + /** + * The live work this host holds for a project's Sessions, which removing + * the project stops before its delete (VC-796). Absent or null: a host that + * cannot stop it, where `project.remove` answers unavailable rather than + * deleting Sessions out from under their work. + */ + readonly projectLiveWork?: ProjectLiveWork | null; /** * The remote hosts this desktop added over SSH, and their add flows * (VC-700 PR 2): desktop main's registry. Absent or null (hostd, a desktop @@ -539,6 +549,7 @@ const SESSION_LISTING_UNAVAILABLE = "The Session listing is unavailable on this const HOST_SCOPE_RELAY_UNAVAILABLE = "Remote host operations are unavailable on this host"; const HOST_LINK_RELAY_UNAVAILABLE = "Remote projects are unavailable on this host"; const BROWSER_SHORTCUTS_UNAVAILABLE = "Browser Tab shortcuts are unavailable on this host"; +const PROJECT_REMOVAL_UNAVAILABLE = "Removing a project is unavailable on this host"; /** * Runs a handler with the ids its input names joined to the operation's log @@ -620,6 +631,7 @@ function hostHandlerEntries( const hostLinkRelay = () => present(options.hostLinkRelay ?? null, HOST_LINK_RELAY_UNAVAILABLE); const browserShortcuts = () => present(options.browserShortcuts ?? null, BROWSER_SHORTCUTS_UNAVAILABLE); + const feed = options.boardFeed ?? createBoardChangeFeed(); const worktree = (database: Database.Database) => options.worktree ?? worktreeDeps(database, ports, { dataDir: options.dataDir }); @@ -669,7 +681,7 @@ function hostHandlerEntries( db, now, events: ports.events, - feed: options.boardFeed ?? createBoardChangeFeed(), + feed, worktree, busyWorktreeSites: options.busyWorktreeSites, detachedWork: options.detachedWork, @@ -829,6 +841,22 @@ function hostHandlerEntries( reorderProjects(board(), orderedIds, now()); return null; }, + // Every window hears it, the asking one included, so a second window + // drops the project. Untapped: the removed Workspace's feed has ended, + // and no other Workspace changed. + "project.remove": ({ projectId }) => + joined({ projectId }, async () => { + await removeProject( + { + db: board(), + live: present(options.projectLiveWork ?? null, PROJECT_REMOVAL_UNAVAILABLE), + disposeFeed: (id) => feed.dispose(id), + announce: () => feed.untapped(() => ports.events.publish("data-changed", {})), + }, + projectId, + ); + return null; + }), "worktree.trimSettings": () => getTrimSettings(board()), // Remote hosts (VC-700 PR 2): desktop main's registry, through its port. "hosts.snapshot": () => remoteHosts().snapshot(), diff --git a/packages/host-core/src/mcp/settings.test.ts b/packages/host-core/src/mcp/settings.test.ts index 1b4b30dbd..ef2813d23 100644 --- a/packages/host-core/src/mcp/settings.test.ts +++ b/packages/host-core/src/mcp/settings.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { MCP_CONNECTION_TIMEOUT_MS, MCP_ERROR_MAX_CHARS } from "@volli/shared"; import type { McpProtocolClient } from "./discovery"; -import { insertProject } from "../db/projects-repo"; +import { deleteProject, insertProject } from "../db/projects-repo"; +import { MemoryMcpCredentialStore } from "./credential-store"; import { openTestDb, testProject, type TestDb } from "../db/test-helpers"; import { McpSettingsService, MCP_CANCELLED_MESSAGE } from "./settings"; @@ -185,6 +186,44 @@ describe("McpSettingsService", () => { }); }); +// VC-796: a removed project's servers go with its row (ON DELETE CASCADE), so +// their credentials have to go by name, and only once the row is gone. +describe("McpSettingsService on a project's removal", () => { + it("names the project's servers, then forgets only the credentials whose row is gone", async () => { + const credentials = new MemoryMcpCredentialStore(); + const settings = new McpSettingsService({ + db: ctx.db, + now: () => 100, + credentials, + open: async (): Promise => ({ + listTools: async () => listedTools, + callTool: vi.fn(async () => ({ content: [] })), + close: closed, + }), + }); + insertProject(ctx.db, testProject({ id: "p2", path: "/repo/other" })); + await settings.save({ projectId: "p1", server: draft, enabledTools: [] }); + await settings.save({ + projectId: "p2", + server: { ...draft, id: "server-2" }, + enabledTools: [], + }); + credentials.update("server-1", () => ({ secrets: { TOKEN: "one" } })); + credentials.update("server-2", () => ({ secrets: { TOKEN: "two" } })); + + const serverIds = settings.disconnectProject("p1"); + expect(serverIds).toEqual(["server-1"]); + // Before the delete commits, nothing is forgotten. + settings.forgetRemovedServers(serverIds); + expect(credentials.read("server-1")).toEqual({ secrets: { TOKEN: "one" } }); + + deleteProject(ctx.db, "p1"); + settings.forgetRemovedServers([...serverIds, "server-2"]); + expect(credentials.read("server-1")).toBeUndefined(); + expect(credentials.read("server-2")).toEqual({ secrets: { TOKEN: "two" } }); + }); +}); + describe("McpSettingsService cancellation and provenance (VC-380)", () => { it("honours a caller's signal during an install and says it was cancelled", async () => { const controller = new AbortController(); diff --git a/packages/host-core/src/mcp/settings.ts b/packages/host-core/src/mcp/settings.ts index 0346a4356..462042ffc 100644 --- a/packages/host-core/src/mcp/settings.ts +++ b/packages/host-core/src/mcp/settings.ts @@ -580,6 +580,29 @@ export class McpSettingsService { return { ok: true }; } + /** + * Before a project is removed (VC-796): ends every sign-in still in flight + * for its servers, and names them so {@link forgetRemovedServers} can drop + * their credentials once the cascade has taken the rows. A Session's own + * connections are its attachment's, and close when its binding is released. + */ + disconnectProject(projectId: string): readonly string[] { + const serverIds = this.list(projectId).map(({ id }) => id); + for (const serverId of serverIds) this.#oauth.cancelSignIn(serverId); + return serverIds; + } + + /** + * After a project is removed: every credential its servers held, for the + * reason {@link remove} drops one server's. A server whose row is still + * there (the removal did not commit) keeps its own. + */ + forgetRemovedServers(serverIds: readonly string[]): void { + for (const serverId of serverIds) { + if (getMcpServer(this.#db, serverId) === undefined) this.#store.delete(serverId); + } + } + #prepare( projectId: string, candidate: unknown, diff --git a/packages/host-core/src/project-remove.test.ts b/packages/host-core/src/project-remove.test.ts new file mode 100644 index 000000000..a20de3dc8 --- /dev/null +++ b/packages/host-core/src/project-remove.test.ts @@ -0,0 +1,337 @@ +/** + * Removing a project (VC-796): its live work stops BEFORE the cascade takes + * its Sessions, and the windows hear about it after. + * + * Every port records the step it was called at together with what the + * database still held then, so "before the delete" is read off the database + * rather than inferred from call order alone. + */ +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; +import type { OpenNativeBinding } from "@volli/session-engine"; + +import { getProjectById, insertProject, listProjectSessionIds } from "./db/projects-repo"; +import { openTestDb, testProject, type TestDb } from "./db/test-helpers"; +import { + hostProjectLiveWork, + ProjectRemovalRefused, + removeProject, + type ProjectLiveWork, + type ProjectLiveWorkSources, + type ProjectRemovalPorts, +} from "./project-remove"; +import type { AgentSiteRuntime } from "./worktree/agent-sites"; + +let ctx: TestDb; + +beforeEach(() => { + seed(); +}); + +afterEach(() => { + ctx.cleanup(); +}); + +function insertSession(id: string, projectId: string): void { + ctx.db + .prepare( + "INSERT INTO sessions (id, project_id, ticket_id, title, created_at) VALUES (?, ?, NULL, ?, 1)", + ) + .run(id, projectId, id); +} + +interface Step { + step: string; + /** Whether the project row was still there when the step ran. */ + projectRow: boolean; + /** The project's Session rows when the step ran. */ + sessions: readonly string[]; + arg?: unknown; +} + +function recorder(overrides: Partial = {}) { + const steps: Step[] = []; + const note = (step: string, arg?: unknown) => { + steps.push({ + step, + projectRow: getProjectById(ctx.db, "p1") !== undefined, + sessions: listProjectSessionIds(ctx.db, "p1"), + ...(arg === undefined ? {} : { arg }), + }); + }; + const live: ProjectLiveWork = { + releaseBindings: async (ids) => { + note("releaseBindings", [...ids].toSorted()); + return { stillOpen: [] }; + }, + disposeShells: (ids) => note("disposeShells", [...ids].toSorted()), + killTerminals: (ids) => note("killTerminals", [...ids].toSorted()), + closeBrowserTabs: (projectId) => note("closeBrowserTabs", projectId), + mcp: { + disconnect: (projectId) => { + note("mcp.disconnect", projectId); + return ["server-1"]; + }, + forget: (serverIds) => note("mcp.forget", serverIds), + }, + ...overrides, + }; + const ports: ProjectRemovalPorts = { + db: ctx.db, + live, + disposeFeed: (projectId) => note("disposeFeed", projectId), + announce: () => note("announce"), + }; + return { steps, ports }; +} + +function seed(): void { + ctx = openTestDb(); + insertProject(ctx.db, testProject({ id: "p1", path: "/repo/one" })); + insertProject(ctx.db, testProject({ id: "p2", path: "/repo/two" })); + insertSession("s-board", "p1"); + insertSession("s-ticket", "p1"); + insertSession("s-other", "p2"); +} + +describe("removeProject", () => { + it("stops every Session's live work while its rows still exist, then deletes, then tells", async () => { + const { steps, ports } = recorder(); + + await removeProject(ports, "p1"); + + const both = ["s-board", "s-ticket"]; + expect(steps).toEqual([ + { step: "releaseBindings", projectRow: true, sessions: both, arg: both }, + { step: "disposeShells", projectRow: true, sessions: both, arg: both }, + { step: "killTerminals", projectRow: true, sessions: both, arg: both }, + { step: "closeBrowserTabs", projectRow: true, sessions: both, arg: "p1" }, + { step: "mcp.disconnect", projectRow: true, sessions: both, arg: "p1" }, + // The cascade has run by now: the row and its Sessions are gone. + { step: "mcp.forget", projectRow: false, sessions: [], arg: ["server-1"] }, + { step: "disposeFeed", projectRow: false, sessions: [], arg: "p1" }, + { step: "announce", projectRow: false, sessions: [] }, + ]); + expect(getProjectById(ctx.db, "p2")).toBeDefined(); + expect(listProjectSessionIds(ctx.db, "p2")).toEqual(["s-other"]); + }); + + it("refuses, stopping and deleting nothing else, when a binding will not close", async () => { + const { steps, ports } = recorder({ + releaseBindings: async () => ({ stillOpen: ["s-ticket"] }), + }); + + const refusal = removeProject(ports, "p1"); + + await expect(refusal).rejects.toBeInstanceOf(ProjectRemovalRefused); + await expect(refusal).rejects.toThrow( + "A Session in this project is still running, so nothing was removed. Stop it and try again.", + ); + expect(steps).toEqual([]); + expect(getProjectById(ctx.db, "p1")).toBeDefined(); + expect(listProjectSessionIds(ctx.db, "p1").toSorted()).toEqual(["s-board", "s-ticket"]); + }); + + it("refuses when a Session started while the others were being released", async () => { + const { steps, ports } = recorder({ + releaseBindings: async () => { + insertSession("s-late", "p1"); + insertSession("s-later", "p1"); + return { stillOpen: [] }; + }, + }); + + const refusal = removeProject(ports, "p1"); + + await expect(refusal).rejects.toThrow( + "2 Sessions in this project are still running, so nothing was removed. Stop them and try again.", + ); + await expect(refusal).rejects.toMatchObject({ stillOpen: ["s-late", "s-later"] }); + expect(steps).toEqual([]); + expect(getProjectById(ctx.db, "p1")).toBeDefined(); + }); + + it("removes a project on a host that keeps no MCP settings", async () => { + const { steps, ports } = recorder({ mcp: null }); + + await removeProject(ports, "p1"); + + expect(steps.map(({ step }) => step)).toEqual([ + "releaseBindings", + "disposeShells", + "killTerminals", + "closeBrowserTabs", + "disposeFeed", + "announce", + ]); + expect(getProjectById(ctx.db, "p1")).toBeUndefined(); + }); + + it("does nothing for an id no project has", async () => { + const { steps, ports } = recorder(); + + await removeProject(ports, "missing"); + + expect(steps).toEqual([]); + expect(getProjectById(ctx.db, "p1")).toBeDefined(); + }); +}); + +function binding(sessionId: string, directory: string): OpenNativeBinding { + return { + sessionId, + directory, + attachmentId: `attachment-${sessionId}`, + lastProgressAt: 0, + inFlightTools: [], + }; +} + +/** A runtime holding bindings with turns open; a release closes one unless it is stuck. */ +function runtime(stuck: ReadonlySet = new Set()) { + const bound = new Map([ + // A ticket worktree need not sit under the project folder: the Session + // id, not the directory, is what selects it. + ["s-board", binding("s-board", "/repo/one")], + ["s-ticket", binding("s-ticket", "/elsewhere/worktrees/VC-1")], + ["s-other", binding("s-other", "/repo/two")], + ]); + const released: { sessionId: string; origin: unknown }[] = []; + const port: AgentSiteRuntime = { + openNativeBindings: () => [...bound.values()], + projection: async () => ({ projection: { turnActive: true } }), + command: async (request) => { + if (request.sessionId === "s-throws") throw new Error("executor would not stop"); + released.push({ sessionId: request.sessionId, origin: request.origin }); + if (!stuck.has(request.sessionId)) bound.delete(request.sessionId); + return {}; + }, + }; + return { port, bound, released }; +} + +function sources(overrides: Partial = {}) { + const calls: string[] = []; + const closedTabs: string[] = []; + const errors: string[] = []; + const built: ProjectLiveWorkSources = { + runtime: () => null, + shells: { disposeSession: (sessionId) => calls.push(`shell:${sessionId}`) }, + terminals: () => ({ + killSessions: (ids) => calls.push(`terminals:${[...ids].join(",")}`), + }), + browser: { + list: ({ projectId }) => + projectId === "p1" ? [{ tabId: "tab-person" }, { tabId: "tab-agent" }] : [], + close: (tabId) => closedTabs.push(tabId), + }, + mcp: { + disconnectProject: (projectId) => { + calls.push(`mcp.disconnect:${projectId}`); + return ["server-1"]; + }, + forgetRemovedServers: (ids) => calls.push(`mcp.forget:${ids.join(",")}`), + }, + newCommandId: () => "command-1", + onReleaseError: (sessionId) => errors.push(sessionId), + ...overrides, + }; + return { built, calls, closedTabs, errors }; +} + +describe("hostProjectLiveWork", () => { + it("releases only the named Sessions' bindings, wherever they are rooted, as the person's act", async () => { + const held = runtime(); + const { built } = sources({ runtime: () => held.port }); + + const report = await hostProjectLiveWork(built).releaseBindings( + new Set(["s-board", "s-ticket"]), + ); + + expect(report.stillOpen).toEqual([]); + expect(held.released).toEqual([ + { sessionId: "s-board", origin: { kind: "user" } }, + { sessionId: "s-ticket", origin: { kind: "user" } }, + ]); + expect([...held.bound.keys()]).toEqual(["s-other"]); + }); + + it("reports a binding that stayed open, and one whose release threw", async () => { + const held = runtime(new Set(["s-ticket"])); + held.bound.set("s-throws", binding("s-throws", "/repo/one")); + const { built, errors } = sources({ runtime: () => held.port }); + + const report = await hostProjectLiveWork(built).releaseBindings( + new Set(["s-board", "s-ticket", "s-throws"]), + ); + + expect(report.stillOpen.toSorted()).toEqual(["s-throws", "s-ticket"]); + expect(errors).toEqual(["s-throws"]); + }); + + it("has nothing to release on a launch without a runtime", async () => { + const { built } = sources(); + expect(await hostProjectLiveWork(built).releaseBindings(new Set(["s-board"]))).toEqual({ + stillOpen: [], + }); + }); + + it("ends each Session's shells and terminals, the project's tabs, and its MCP sign-ins", () => { + const { built, calls, closedTabs } = sources(); + const live = hostProjectLiveWork(built); + + live.disposeShells(new Set(["s-board", "s-ticket"])); + live.killTerminals(new Set(["s-board", "s-ticket"])); + live.closeBrowserTabs("p1"); + expect(live.mcp?.disconnect("p1")).toEqual(["server-1"]); + live.mcp?.forget(["server-1"]); + + expect(calls).toEqual([ + "shell:s-board", + "shell:s-ticket", + "terminals:s-board,s-ticket", + "mcp.disconnect:p1", + "mcp.forget:server-1", + ]); + expect(closedTabs).toEqual(["tab-person", "tab-agent"]); + }); + + it("skips what this host does not hold: no terminals yet, no browser, no MCP settings", () => { + const { built, calls } = sources({ terminals: () => null, browser: null, mcp: null }); + const live = hostProjectLiveWork(built); + + live.killTerminals(new Set(["s-board"])); + live.closeBrowserTabs("p1"); + + expect(live.mcp).toBeNull(); + expect(calls).toEqual([]); + }); + + // The acceptance case end to end: a turn open in a ticket worktree outside + // the project folder, removed through the host's own live work. + it("leaves no open binding, shell or terminal for a removed project's Sessions", async () => { + const held = runtime(); + const { built, calls } = sources({ runtime: () => held.port }); + const disposed: string[] = []; + + await removeProject( + { + db: ctx.db, + live: hostProjectLiveWork(built), + disposeFeed: (projectId) => disposed.push(projectId), + announce: () => undefined, + }, + "p1", + ); + + expect([...held.bound.keys()]).toEqual(["s-other"]); + expect(calls).toEqual([ + "shell:s-board", + "shell:s-ticket", + "terminals:s-board,s-ticket", + "mcp.disconnect:p1", + "mcp.forget:server-1", + ]); + expect(disposed).toEqual(["p1"]); + expect(getProjectById(ctx.db, "p1")).toBeUndefined(); + }); +}); diff --git a/packages/host-core/src/project-remove.ts b/packages/host-core/src/project-remove.ts new file mode 100644 index 000000000..daf0fc808 --- /dev/null +++ b/packages/host-core/src/project-remove.ts @@ -0,0 +1,165 @@ +/** + * Removing a project from the host (VC-796). + * + * The delete is one statement, and it cascades: every Session of the project + * and its whole event history go with the row (`sessions.project_id` and + * `session_events.session_id` are `ON DELETE CASCADE`). So the work those + * Sessions are doing has to stop FIRST. A structured turn left running past + * the delete keeps spending and keeps acting in the folder while every append + * it makes fails the foreign key; a shell, a terminal or a tab left open is a + * process or a page nothing in the product names any more. + * + * The order is the whole module, and each step is a port a test can watch: + * + * 1. stop the live work rooted in the project: its Sessions' structured + * bindings, background shells and terminals, its Browser Tabs, and the MCP + * sign-ins still in flight for its servers; + * 2. delete the row; + * 3. forget the credentials the cascaded MCP servers held; + * 4. end the project's board feed; + * 5. tell every window, so a second one drops the project too. + * + * A binding that will not close refuses the removal before anything else is + * stopped or deleted: deleting a Session's history under a turn that is still + * running is the defect, and the person can stop that Session and try again. + * + * The cascade itself is unchanged here: what a removal keeps is a separate + * decision. + */ +import type Database from "better-sqlite3"; + +import { deleteProject, getProjectById, listProjectSessionIds } from "./db/projects-repo"; +import { releaseAgentSessions, type AgentSiteRuntime } from "./worktree/agent-sites"; + +/** What a release left bound, by Session id. */ +export interface ProjectBindingRelease { + readonly stillOpen: readonly string[]; +} + +/** + * The live work a host holds for a project's Sessions, as one removal stops + * it. Every member but {@link releaseBindings} is synchronous and best-effort: + * a process that ignores its signal is the host's to finish killing, and must + * not make a project unremovable. + */ +export interface ProjectLiveWork { + /** Ends every structured binding these Sessions hold (`adapter.release`). */ + releaseBindings(sessionIds: ReadonlySet): Promise; + /** Kills the background shells these Sessions started. */ + disposeShells(sessionIds: ReadonlySet): void; + /** Kills these Sessions' terminals, whichever window holds them. */ + killTerminals(sessionIds: ReadonlySet): void; + /** Closes every Browser Tab of the project, the person's own included. */ + closeBrowserTabs(projectId: string): void; + /** + * The project's MCP servers: `disconnect` ends their in-flight sign-ins and + * names them; `forget` drops their stored credentials after the delete. + */ + readonly mcp: { + disconnect(projectId: string): readonly string[]; + forget(serverIds: readonly string[]): void; + } | null; +} + +export interface ProjectRemovalPorts { + readonly db: Database.Database; + readonly live: ProjectLiveWork; + /** Ends the project's board feed: its followers are told to resnapshot. */ + disposeFeed(projectId: string): void; + /** Tells every window the project list changed. */ + announce(): void; +} + +/** + * Raised when a Session of the project is still running after the stop: its + * binding would not close, or it started while the others were stopping. + */ +export class ProjectRemovalRefused extends Error { + constructor(readonly stillOpen: readonly string[]) { + super( + stillOpen.length === 1 + ? "A Session in this project is still running, so nothing was removed. Stop it and try again." + : `${stillOpen.length} Sessions in this project are still running, so nothing was removed. Stop them and try again.`, + ); + this.name = "ProjectRemovalRefused"; + } +} + +/** + * Removes one project, its live work stopped first. An id no project has is a + * no-op: there is nothing to stop and nothing to tell. + */ +export async function removeProject(ports: ProjectRemovalPorts, projectId: string): Promise { + if (getProjectById(ports.db, projectId) === undefined) return; + const sessionIds = new Set(listProjectSessionIds(ports.db, projectId)); + + const { stillOpen } = await ports.live.releaseBindings(sessionIds); + if (stillOpen.length > 0) throw new ProjectRemovalRefused(stillOpen); + // The release awaited: a Session started meanwhile was not released, and + // the cascade would take its history from under it. From here to the + // delete nothing awaits, so this is the last such gap. + const started = listProjectSessionIds(ports.db, projectId).filter((id) => !sessionIds.has(id)); + if (started.length > 0) throw new ProjectRemovalRefused(started); + ports.live.disposeShells(sessionIds); + ports.live.killTerminals(sessionIds); + ports.live.closeBrowserTabs(projectId); + const serverIds = ports.live.mcp?.disconnect(projectId) ?? []; + + deleteProject(ports.db, projectId); + + ports.live.mcp?.forget(serverIds); + ports.disposeFeed(projectId); + ports.announce(); +} + +/** The host services a project's live work is held in, as {@link hostProjectLiveWork} reads them. */ +export interface ProjectLiveWorkSources { + /** The structured runtime, or null on a launch without one. Read at removal time. */ + runtime(): AgentSiteRuntime | null; + readonly shells: { disposeSession(sessionId: string): void }; + /** The terminal supervisor, or null before it exists. Read at removal time. */ + terminals(): { killSessions(sessionIds: ReadonlySet): unknown } | null; + readonly browser: { + list(scope: { projectId: string }): readonly { tabId: string }[]; + close(tabId: string): void; + } | null; + readonly mcp: { + disconnectProject(projectId: string): readonly string[]; + forgetRemovedServers(serverIds: readonly string[]): void; + } | null; + newCommandId(): string; + /** A release that threw; the binding is then reported still open, and the removal refused. */ + onReleaseError(sessionId: string, error: unknown): void; +} + +/** A host's {@link ProjectLiveWork}, over the services that hold it. */ +export function hostProjectLiveWork(sources: ProjectLiveWorkSources): ProjectLiveWork { + const { browser, mcp } = sources; + return { + releaseBindings: async (sessionIds) => { + const runtime = sources.runtime(); + if (runtime === null) return { stillOpen: [] }; + return releaseAgentSessions(runtime, sessionIds, { + newCommandId: () => sources.newCommandId(), + onError: (sessionId, error) => sources.onReleaseError(sessionId, error), + }); + }, + disposeShells: (sessionIds) => { + for (const sessionId of sessionIds) sources.shells.disposeSession(sessionId); + }, + killTerminals: (sessionIds) => { + sources.terminals()?.killSessions(sessionIds); + }, + closeBrowserTabs: (projectId) => { + if (browser === null) return; + for (const { tabId } of browser.list({ projectId })) browser.close(tabId); + }, + mcp: + mcp === null + ? null + : { + disconnect: (projectId) => mcp.disconnectProject(projectId), + forget: (serverIds) => mcp.forgetRemovedServers(serverIds), + }, + }; +} diff --git a/packages/host-core/src/pty/manager.stream.test.ts b/packages/host-core/src/pty/manager.stream.test.ts index fd5f4de65..6178c2522 100644 --- a/packages/host-core/src/pty/manager.stream.test.ts +++ b/packages/host-core/src/pty/manager.stream.test.ts @@ -213,6 +213,27 @@ describe("attach (VC-560)", () => { }); }); +// VC-796: a removed project's terminals end on the host, before its delete, +// whichever window drew them. +describe("killSessions", () => { + it("kills exactly the named live sessions, attached or not, and names them", async () => { + const first = makeClient("window-1"); + const second = makeClient("window-2"); + const a = await start(first); + const b = await start(second); + const kept = await start(first); + + const killed = manager.killSessions(new Set([a.sessionId, b.sessionId, "not-live"])); + + expect(killed.toSorted()).toEqual([a.sessionId, b.sessionId].toSorted()); + expect(a.pty.kill).toHaveBeenCalledOnce(); + expect(b.pty.kill).toHaveBeenCalledOnce(); + expect(kept.pty.kill).not.toHaveBeenCalled(); + expect(manager.workspaceIdFor(a.sessionId)).toBeUndefined(); + expect(manager.workspaceIdFor(kept.sessionId)).toBe("w"); + }); +}); + describe("detach (VC-560)", () => { it("is owner-only, answering a stranger as it answers an unknown id", async () => { const holder = makeClient("holder"); diff --git a/packages/host-core/src/pty/manager.ts b/packages/host-core/src/pty/manager.ts index 58013c219..cfd665d83 100644 --- a/packages/host-core/src/pty/manager.ts +++ b/packages/host-core/src/pty/manager.ts @@ -1634,6 +1634,20 @@ export class PtyManager { } } + /** + * Kills the live sessions among `sessionIds`, whichever window holds them, + * and names the ones it killed: a removed project's terminals (VC-796), + * which the host ends before the delete takes their Sessions' rows. An id + * with no live session is skipped. + */ + killSessions(sessionIds: ReadonlySet): string[] { + const killed = Array.from(this.sessions.keys()).filter((sessionId) => + sessionIds.has(sessionId), + ); + for (const sessionId of killed) this.killSession(sessionId); + return killed; + } + /** Kills every live session. Wired to `before-quit`. */ killAll(): void { // Snapshot the ids first: killSession() mutates the map as it forgets sessions. diff --git a/packages/host-core/src/worktree/agent-sites.ts b/packages/host-core/src/worktree/agent-sites.ts index cf9c11a6d..86afa959a 100644 --- a/packages/host-core/src/worktree/agent-sites.ts +++ b/packages/host-core/src/worktree/agent-sites.ts @@ -138,13 +138,48 @@ export async function releaseAgentSites( directory: string, deps: { newCommandId: () => string; onError: (sessionId: string, error: unknown) => void }, ): Promise { - const bindings = agentSitesWithin(runtime, directory); + return releaseBindings( + runtime, + () => agentSitesWithin(runtime, directory), + { kind: "volli", reason: "worktree-notice" }, + deps, + ); +} + +/** + * Ends every binding these Sessions hold, wherever it is rooted: a removed + * project's Sessions (VC-796) are rooted in the project folder and in ticket + * worktrees that need not sit under it, so the directory cannot select them. + * + * The same release, report and failure stance as {@link releaseAgentSites}; + * the origin is the person's, because removing the project was their act. + */ +export async function releaseAgentSessions( + runtime: AgentSiteRuntime, + sessionIds: ReadonlySet, + deps: { newCommandId: () => string; onError: (sessionId: string, error: unknown) => void }, +): Promise { + return releaseBindings( + runtime, + () => runtime.openNativeBindings().filter(({ sessionId }) => sessionIds.has(sessionId)), + { kind: "user" }, + deps, + ); +} + +async function releaseBindings( + runtime: AgentSiteRuntime, + select: () => readonly OpenNativeBinding[], + origin: SessionOrigin, + deps: { newCommandId: () => string; onError: (sessionId: string, error: unknown) => void }, +): Promise { + const bindings = select(); if (bindings.length === 0) return { released: [], stillOpen: [] }; for (const binding of bindings) { try { await runtime.command({ commandId: deps.newCommandId(), - origin: { kind: "volli", reason: "worktree-notice" }, + origin, sessionId: binding.sessionId, command: { kind: "adapter.release", attachmentId: binding.attachmentId }, }); @@ -152,7 +187,7 @@ export async function releaseAgentSites( deps.onError(binding.sessionId, error); } } - const remaining = agentSitesWithin(runtime, directory); + const remaining = select(); const stillOpen = new Set(remaining.map(({ sessionId }) => sessionId)); return { released: bindings diff --git a/packages/host-core/vite.config.ts b/packages/host-core/vite.config.ts index 40eb968d1..526973aef 100644 --- a/packages/host-core/vite.config.ts +++ b/packages/host-core/vite.config.ts @@ -64,6 +64,10 @@ export default defineConfig({ // applies the same rules. A branch wrong here is a second project for // one folder, or two projects sharing one ticket prefix. "src/project-create.ts", + // Removing a project (VC-796): the order its live work stops in, before + // the delete whose cascade takes its Sessions. A branch wrong here is a + // turn still spending after its history is gone. + "src/project-remove.ts", // The ports every moved service asks its host through (VC-554), and // what a headless host answers with: a refusal that reads wrong is // what a person on a server sees instead of their link opening. diff --git a/packages/session-rpc/src/desktop-ipc.test.ts b/packages/session-rpc/src/desktop-ipc.test.ts index 96ed4c693..9220e8295 100644 --- a/packages/session-rpc/src/desktop-ipc.test.ts +++ b/packages/session-rpc/src/desktop-ipc.test.ts @@ -79,6 +79,7 @@ describe("the desktop's IPC exposure", () => { "board.setLabelColor", "shells.moveToBackground", "project.reorder", + "project.remove", "worktree.trimSettings", "hosts.snapshot", "hosts.subscribe", diff --git a/packages/session-rpc/src/desktop-ipc.ts b/packages/session-rpc/src/desktop-ipc.ts index 67689965a..ad77bb48f 100644 --- a/packages/session-rpc/src/desktop-ipc.ts +++ b/packages/session-rpc/src/desktop-ipc.ts @@ -160,6 +160,7 @@ export const DESKTOP_IPC_EXPOSURE = Object.freeze({ // renderer and main in one bundle and are not cross-version promises. "shells.moveToBackground": "ipc", "project.reorder": "ipc", + "project.remove": "ipc", "worktree.trimSettings": "ipc", "hosts.snapshot": "ipc", "hosts.subscribe": "ipc", diff --git a/packages/session-rpc/src/desktop-router.test.ts b/packages/session-rpc/src/desktop-router.test.ts index c803af6de..d8c60967d 100644 --- a/packages/session-rpc/src/desktop-router.test.ts +++ b/packages/session-rpc/src/desktop-router.test.ts @@ -272,6 +272,7 @@ function recordingHandlers(fixture: Host): DesktopRouterHandlers { return { "shells.moveToBackground": record("shells.moveToBackground", { shellId: "shell-1" }), "project.reorder": record("project.reorder", null), + "project.remove": record("project.remove", null), "worktree.trimSettings": record("worktree.trimSettings", TRIM), "hosts.snapshot": record("hosts.snapshot", SNAPSHOT), "hosts.subscribe": stream("hosts.subscribe", SNAPSHOT), @@ -1384,6 +1385,7 @@ describe("the desktop router's grammar", () => { [ "shells.moveToBackground", "project.reorder", + "project.remove", "worktree.trimSettings", "hosts.snapshot", "hosts.subscribe", diff --git a/packages/session-rpc/src/desktop-router.ts b/packages/session-rpc/src/desktop-router.ts index 13ae45c57..d8a9e4dc6 100644 --- a/packages/session-rpc/src/desktop-router.ts +++ b/packages/session-rpc/src/desktop-router.ts @@ -68,10 +68,12 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { AsyncQueue } from "./async-queue"; +import { boardId } from "./board-schema"; import { createCatalogBuilders, hostAnswer, HostProcedureError, + PROJECT_RESOURCE, type CatalogCallerContext, type CatalogMismatch, type ProcedurePaths, @@ -126,6 +128,7 @@ export interface DesktopRouterHandlers { { shellId: string } >; readonly "project.reorder": HostHandler<{ orderedIds: readonly string[] }, null>; + readonly "project.remove": HostHandler<{ projectId: string }, null>; readonly "worktree.trimSettings": HostHandler; /** Remote hosts this desktop added over SSH (VC-700 PR 2): desktop main's registry. */ readonly "hosts.snapshot": HostHandler; @@ -460,6 +463,17 @@ export function createDesktopRouter() { .input(z.object({ orderedIds: z.array(z.string()) })) .output(z.null()) .mutation(({ ctx, input }) => ctx.handlers["project.reorder"](input, ctx.call)), + /** + * `volli:project-remove` projects the same handler (VC-796): the + * project's live work stopped, then the delete, then every window told. + */ + remove: workspaceProcedure( + "project.remove", + z.strictObject({ projectId: boardId }), + ({ projectId }) => ({ kind: PROJECT_RESOURCE, id: projectId }), + ) + .output(z.null()) + .mutation(({ ctx, input }) => ctx.handlers["project.remove"](input, ctx.call)), }, worktree: { /** Was `volli:worktree-trim-settings-get`: the host-level trim settings. */ diff --git a/packages/session-rpc/src/handler-projection.test.ts b/packages/session-rpc/src/handler-projection.test.ts index 91e9df487..703e57bf9 100644 --- a/packages/session-rpc/src/handler-projection.test.ts +++ b/packages/session-rpc/src/handler-projection.test.ts @@ -68,6 +68,7 @@ const SAMPLE_INPUTS: { toolCallId: "bash-1", }, "project.reorder": { orderedIds: [PROJECT] }, + "project.remove": { projectId: PROJECT }, "worktree.trimSettings": undefined, "hosts.snapshot": undefined, "hosts.subscribe": undefined, diff --git a/packages/session-rpc/src/session-handlers.test-support.test.ts b/packages/session-rpc/src/session-handlers.test-support.test.ts index 511c384aa..2fe55fe39 100644 --- a/packages/session-rpc/src/session-handlers.test-support.test.ts +++ b/packages/session-rpc/src/session-handlers.test-support.test.ts @@ -58,6 +58,7 @@ function callAll(handlers: SessionRouterHandlers & DesktopRouterHandlers) { () => handlers["session.reconcile"]({ sessionId: "s", attachmentId: "a" }, CALL), () => handlers["shells.moveToBackground"]({ sessionId: "s", toolCallId: "bash-1" }, CALL), () => handlers["project.reorder"]({ orderedIds: [] }, CALL), + () => handlers["project.remove"]({ projectId: "p" }, CALL), () => handlers["worktree.trimSettings"](undefined, CALL), () => handlers["hosts.snapshot"](undefined, CALL), () => handlers["hosts.subscribe"](undefined, CALL, sink), @@ -175,6 +176,7 @@ describe("sessionHandlersFrom", () => { desktop: { "shells.moveToBackground": port as never, "project.reorder": port as never, + "project.remove": port as never, "worktree.trimSettings": port as never, "hosts.snapshot": port as never, "hosts.subscribe": port as never, @@ -204,7 +206,7 @@ describe("sessionHandlersFrom", () => { }, }; for (const call of callAll(sessionHandlersFrom(ports))) await call(); - expect(port).toHaveBeenCalledTimes(43); + expect(port).toHaveBeenCalledTimes(44); expect(port).toHaveBeenCalledWith({ flowId: "f", questionId: "q1", password: "p" }, CALL); expect(runtime.command).toHaveBeenCalledWith({ commandId: "cancel", @@ -251,6 +253,6 @@ describe("sessionHandlersFrom", () => { // projects' four (VC-710), the active add flows' one (VC-720), and // the host-scoped projects' two and HOST relay's three (VC-722), // and moving a running command to the background (VC-757). - expect(Object.keys(context.handlers)).toHaveLength(86); + expect(Object.keys(context.handlers)).toHaveLength(87); }); }); diff --git a/packages/session-rpc/src/session-handlers.test-support.ts b/packages/session-rpc/src/session-handlers.test-support.ts index c55c6d75a..ea475d127 100644 --- a/packages/session-rpc/src/session-handlers.test-support.ts +++ b/packages/session-rpc/src/session-handlers.test-support.ts @@ -229,6 +229,7 @@ export function sessionHandlersFrom( need(ports.desktop?.["shells.moveToBackground"], RUNTIME)(input, call), "project.reorder": (input, call) => need(ports.desktop?.["project.reorder"], BOARD)(input, call), + "project.remove": (input, call) => need(ports.desktop?.["project.remove"], BOARD)(input, call), "worktree.trimSettings": (input, call) => need(ports.desktop?.["worktree.trimSettings"], BOARD)(input, call), "hosts.snapshot": remote("hosts.snapshot"), diff --git a/packages/shared/src/desktop-entries.test.ts b/packages/shared/src/desktop-entries.test.ts index bd8ce46a4..88cb4c978 100644 --- a/packages/shared/src/desktop-entries.test.ts +++ b/packages/shared/src/desktop-entries.test.ts @@ -47,6 +47,7 @@ describe("the desktop-only tier", () => { expect(hostCommands.map(({ key }) => key)).toEqual([ "project.reorder", "worktree.trimSettings", + "project.remove", ]); for (const entry of DESKTOP_ENTRIES) { const clientLocal = diff --git a/packages/shared/src/desktop-entries.ts b/packages/shared/src/desktop-entries.ts index 588db54c6..d40ffbcaa 100644 --- a/packages/shared/src/desktop-entries.ts +++ b/packages/shared/src/desktop-entries.ts @@ -389,6 +389,15 @@ export const DESKTOP_ENTRIES = [ idempotency: "natural", summary: "Give keyboard focus back to a shown Browser page whose shortcut did nothing.", }, + // VC-796: the host's removal, which `volli:project-remove` projects. It + // stops the project's live work before the delete its cascade makes. + { + key: "project.remove", + compatibility: "host-command", + placement: "workspace", + idempotency: "natural", + summary: "Remove a project from Volli: stop its Sessions' live work, then delete it.", + }, ] as const satisfies readonly DesktopEntryDeclaration[]; export type DesktopEntry = (typeof DESKTOP_ENTRIES)[number];