Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions apps/desktop/src/main/data-ipc.test-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -22,10 +22,12 @@ export type TestDataIpcOptions = Omit<Options, "handlers"> & {
handlers?: Options["handlers"];
interruptTicketSessions?: (ticketId: string) => string[] | Promise<string[]>;
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:
Expand Down Expand Up @@ -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.
Expand Down
68 changes: 61 additions & 7 deletions apps/desktop/src/main/data-ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,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,
Expand Down Expand Up @@ -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<Promise<ProjectMutationResult>>("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<Promise<ProjectMutationResult>>("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<ProjectMutationResult>("volli:project-remove", projectId)).toEqual({ ok: true });
expect(removed).toHaveBeenCalledExactlyOnceWith(projectId);

expect(stopped).toEqual(["bindings"]);
expect(getProjectById(ctx.db, projectId)).toBeDefined();
expectNoDataChange();
});
});

Expand Down
23 changes: 15 additions & 8 deletions apps/desktop/src/main/data-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
MIN_READER_VERSION_KEY,
listAllLabels,
countProjects,
deleteProject,
getProjectById,
insertProject,
listProjects,
Expand Down Expand Up @@ -227,8 +226,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<keyof HostHandlers, `board.${string}`>;
/**
* 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<keyof HostHandlers, `board.${string}`>;
import type { HandlerCall } from "@volli/shared";

/** The person at this desktop's own window, whose reply carries the board. */
Expand Down Expand Up @@ -407,8 +412,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) {
Expand Down Expand Up @@ -632,9 +635,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<ProjectMutationResult> => {
await board("project.remove", { projectId: id });
return { ok: true };
},

Expand Down
16 changes: 14 additions & 2 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ import type { OpenNativeBinding } from "@volli/session-engine";
import {
admittedHandlers,
createHostHandlers,
hostProjectLiveWork,
ROUTER_POLICY,
type HostHandlerMap,
} from "@volli/host-core/handlers";
Expand Down Expand Up @@ -1531,6 +1532,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`.
Expand Down Expand Up @@ -1858,8 +1872,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
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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<Record<string, BackgroundShellState>>,
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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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[] {
Expand All @@ -50,13 +54,24 @@ 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<string[]>([]);
// 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) {
setBusyProcesses([]);
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));
});
Expand All @@ -72,11 +87,11 @@ export function RemoveProjectDialog({ project, open, onOpenChange }: RemoveProje
<AlertDialogTitle>Remove {project.name} from Volli?</AlertDialogTitle>
<AlertDialogDescription>
The folder on disk is untouched.
{busyProcesses.length > 0 && (
<span className="mt-2 block text-destructive">
{describeBusy(busyProcesses, ". Removing the project will end")}
{busyLines.map((line) => (
<span key={line} className="mt-2 block text-destructive">
{line}
</span>
)}
))}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
Expand Down
Loading
Loading