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
6 changes: 5 additions & 1 deletion apps/desktop/src/ipc/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3006,7 +3006,11 @@ export interface LegacyImportRequest {

export type LegacyImportResult = Result<{ data: BootstrapPayload; imported: number }>;

/** `created: false` means an existing project at that path was selected instead of inserted. */
/**
* `created: false` means an existing project at that path was selected instead
* of inserted: one already on the rail, or a removed one put back on it with
* its tickets and Sessions (VC-822).
*/
export type ProjectCreateResult = Result<{ project: Project; created: boolean }>;

export type ProjectUpdateResult = Result<{ project: Project }>;
Expand Down
72 changes: 64 additions & 8 deletions apps/desktop/src/main/data-ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,15 +457,21 @@ 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.
// VC-796: the channel projects the host's `project.remove`. The live work its
// Sessions hold stops first, while the project is still on the rail, and
// every window (a second one included) is told. VC-822: the project is then
// detached, not deleted, so adding its folder again brings its board back.
describe("project removal", () => {
function onRail(projectId: string): boolean {
const bootstrap = invoke<BootstrapResult>("volli:data-bootstrap");
return bootstrap.ok && bootstrap.data.projects.some(({ id }) => id === projectId);
}

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();
// Each stop runs while the project is still on the rail.
expect(onRail(projectId)).toBe(true);
stopped.push(step);
};
const live: ProjectLiveWork = {
Expand All @@ -481,7 +487,7 @@ describe("project removal", () => {
return { live, stopped };
}

it("stops the project's live work before deleting it, then tells every window", async () => {
it("stops the project's live work before detaching it, then tells every window", async () => {
const projectId = createProject();
const { live, stopped } = liveWork(projectId);
registerDataIpcHandlers(
Expand All @@ -496,12 +502,62 @@ describe("project removal", () => {
).resolves.toEqual({ ok: true });

expect(stopped).toEqual(["bindings", "shells", "terminals", "tabs"]);
expect(getProjectById(ctx.db, projectId)).toBeUndefined();
expect(onRail(projectId)).toBe(false);
// 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" });
});

// The defect (VC-822): the removal used to delete the row, and the cascades
// took the board and every Session with it. A re-add found nothing.
it("brings the board back when the same folder is added again", async () => {
const path = freshProjectDir();
const nothingRunning: ProjectLiveWork = {
releaseBindings: async () => ({ stillOpen: [] }),
disposeShells: () => undefined,
killTerminals: () => undefined,
closeBrowserTabs: () => undefined,
mcp: null,
};
registerDataIpcHandlers(
{ ok: true, db: ctx.db },
{
sessionEngine: fixtureSessionEngine,
projectLiveWork: nothingRunning,
detectBaseBranch: async () => "main",
},
);
const added = await invoke<Promise<ProjectCreateResult>>("volli:project-create", {
path,
name: "Kept",
});
if (!added.ok) throw new Error(added.error);
const ticket = createTicket(added.project.id);
await expect(
invoke<Promise<ProjectMutationResult>>("volli:project-remove", added.project.id),
).resolves.toEqual({ ok: true });

// Off every window's rail, its tickets with it.
const hidden = invoke<BootstrapResult>("volli:data-bootstrap");
expect(hidden.ok && hidden.data.projects).toEqual([]);
expect(hidden.ok && Object.keys(hidden.data.ticketsByProject)).toEqual([]);
flushDataChangedForTest();
dataChangedSends.length = 0;

await expect(
invoke<Promise<ProjectCreateResult>>("volli:project-create", { path, name: "Kept" }),
).resolves.toMatchObject({ ok: true, created: false, project: { id: added.project.id } });

const back = invoke<BootstrapResult>("volli:data-bootstrap");
expect(back.ok && back.data.projects.map(({ id }) => id)).toEqual([added.project.id]);
expect(back.ok && back.data.ticketsByProject[added.project.id]).toEqual([
expect.objectContaining({ id: ticket.id, ticketNumber: ticket.ticketNumber }),
]);
// Every window re-reads it: one that seeded an empty board on the add
// must not keep showing it empty.
await expectDataChanged({ projectId: added.project.id, entity: "tickets" });
});

it("keeps the project, and says why, when one of its Sessions will not stop", async () => {
const projectId = createProject();
const { live, stopped } = liveWork(projectId, ["session-1"]);
Expand All @@ -521,7 +577,7 @@ describe("project removal", () => {
});

expect(stopped).toEqual(["bindings"]);
expect(getProjectById(ctx.db, projectId)).toBeDefined();
expect(onRail(projectId)).toBe(true);
expectNoDataChange();
});
});
Expand Down
31 changes: 20 additions & 11 deletions apps/desktop/src/main/data-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,12 +288,10 @@ function buildBootstrapPayload(db: Database.Database): BootstrapPayload {
ticketsByProject[project.id] = [];
labelsByProject[project.id] = [];
}
for (const ticket of listAllTickets(db)) {
(ticketsByProject[ticket.projectId] ??= []).push(ticket);
}
for (const label of listAllLabels(db)) {
(labelsByProject[label.projectId] ??= []).push(label);
}
// Only the projects on the rail: a removed project is detached, not deleted
// (VC-822), and its tickets and labels stay out of every window with it.
for (const ticket of listAllTickets(db)) ticketsByProject[ticket.projectId]?.push(ticket);
for (const label of listAllLabels(db)) labelsByProject[label.projectId]?.push(label);

return { projects, ticketsByProject, labelsByProject, appState };
}
Expand Down Expand Up @@ -555,8 +553,9 @@ export function registerDataIpcHandlers(
"volli:legacy-import": (request: LegacyImportRequest): LegacyImportResult => {
// Idempotent-safe: only import into a genuinely empty projects
// table; a second call (e.g. a relaunch racing the renderer) just
// hands back the current state instead of re-importing over it.
if (countProjects(db) > 0) {
// hands back the current state instead of re-importing over it. A
// removed project still holds its row (VC-822), so it counts here.
if (countProjects(db, "all") > 0) {
return { ok: true, data: buildBootstrapPayload(db), imported: 0 };
}
const legacyProjects = sanitizeLegacyProjects(request.projects);
Expand Down Expand Up @@ -594,8 +593,17 @@ export function registerDataIpcHandlers(

// The rules live in host-core (VC-623), shared with the operator's
// `volli project add` on a headless host: one validation, every door.
// A registration is announced (VC-822): adding a removed project's folder
// again brings back its board, which a window only reads on a change.
"volli:project-create": (input: ProjectCreateInput): Promise<ProjectCreateResult> =>
createProject({ db, detectBaseBranch: options.detectBaseBranch }, input),
createProject(
{
db,
detectBaseBranch: options.detectBaseBranch,
onCreated: (project) => broadcastDataChanged({ projectId: project.id }),
},
input,
),

/**
* Whether one project's registered folder is still there (VC-430) — the
Expand Down Expand Up @@ -637,8 +645,9 @@ export function registerDataIpcHandlers(

/**
* 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.
* live work stopped, then the project detached with its tickets and
* Sessions kept (VC-822), 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 });
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1532,7 +1532,7 @@ const appStartup = app.whenReady().then(async () => {
automations: runtimeAutomations,
busyWorktreeSites,
interruptTicketSessions: interruptTicketSessionsAnnounced,
// What removing a project stops before its delete (VC-796): every door
// What removing a project stops before it detaches it (VC-796): every door
// that removes one, the window's `volli:project-remove` included.
projectLiveWork: hostProjectLiveWork({
runtime: () => sessionRuntime,
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -568,7 +568,10 @@ const api = {
projects: {
pickFolder: (): Promise<PickFolderResult> => invoke("volli:pick-project-folder"),
syncRoots: (paths: string[]): Promise<void> => invoke("volli:sync-project-roots", paths),
/** Creates a project row, or (`created: false`) returns the existing one already tracked at `path`. */
/**
* Creates a project row, or (`created: false`) returns the existing one
* tracked at `path`, re-attaching it if it was removed (VC-822).
*/
create: (input: ProjectCreateInput): Promise<ProjectCreateResult> =>
invoke("volli:project-create", input),
/** Updates the project's pinned automation base branch and/or worktree setup command. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ export function RemoveProjectDialog({ project, open, onOpenChange }: RemoveProje
<AlertDialogHeader>
<AlertDialogTitle>Remove {project.name} from Volli?</AlertDialogTitle>
<AlertDialogDescription>
The folder on disk is untouched.
{/* Removal detaches the project (VC-822): nothing is deleted. */}
The folder and its tickets and Sessions are kept. Add the folder again to bring them
back.
{busyLines.map((line) => (
<span key={line} className="mt-2 block text-destructive">
{line}
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/renderer/src/stores/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,7 @@ describe("removeProject", () => {
});

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),
// The host's `project.remove` ended the PTYs before its detach (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
Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/src/renderer/src/stores/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -665,9 +665,10 @@ export function createProjectsStore(
if (!result) return;
localProjectIds.delete(id);

// The host stopped the project's live work before it deleted it (VC-796):
// The host stopped the project's live work before it detached 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
// and whichever door removed it. Its tickets and Sessions are kept for
// a re-add of the folder (VC-822). 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.
Expand Down
10 changes: 10 additions & 0 deletions apps/hostd/src/host-protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
hostResourceWorkspace,
isLiteralLoopback,
parseListen,
servedWorkspace,
sessionWorkspace,
} from "./host-protocol";

Expand Down Expand Up @@ -140,6 +141,15 @@ describe("hostd's board feed and resource port (VC-565)", () => {
]);
});

// VC-822: a removed project is detached, not deleted; no Workspace door
// serves it until its folder is added again.
it("serves a Workspace only while its project is on the rail", () => {
expect(servedWorkspace(db, PROJECT)).toEqual({ id: PROJECT, epoch: 4 });
db.prepare("UPDATE projects SET removed_at = 9 WHERE id = ?").run(PROJECT);
expect(servedWorkspace(db, PROJECT)).toBeNull();
expect(servedWorkspace(db, "no-such-project")).toBeNull();
});

it("answers a Session from its ledger and every board kind from the board", async () => {
const resolve = hostResourceWorkspace(db, {
getSession: async () => null as never,
Expand Down
10 changes: 7 additions & 3 deletions apps/hostd/src/host-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import {
createBoardChangeFeed,
type BoardChangeFeed,
} from "@volli/host-core/board";
import { getProjectById, getTicketRow, listProjects, prepared } from "@volli/host-core/db";
import { getTicketRow, isProjectAttached, listProjects, prepared } from "@volli/host-core/db";
import { admittedHandlers, ROUTER_POLICY, type HostHandlerMap } from "@volli/host-core/handlers";
import {
HOST_CONNECTION_ONLY_FEATURES,
Expand Down Expand Up @@ -146,12 +146,16 @@ export function parseListen(value: string): HostProtocolBind | string {
return { host, port };
}

/** The highest epoch this host records for a project, or null when it has no such project. */
/**
* The highest epoch this host records for a project, or null when it has no
* such project on its rail: a removed project is detached, not deleted
* (VC-822), and no Workspace door serves it.
*/
export function servedWorkspace(
db: Database.Database,
workspaceId: string,
): ServedWorkspace | null {
if (getProjectById(db, workspaceId) === undefined) return null;
if (!isProjectAttached(db, workspaceId)) return null;
// An aggregate answers one row, with a null epoch when there is none.
const { epoch } = prepared<[string], { epoch: number | null }>(
db,
Expand Down
16 changes: 15 additions & 1 deletion packages/host-core/src/agent-dispatch/project-verbs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export async function projectAddVerb(
if (dryRun) {
const target = { kind: "project" as const, id: plan.project.id, label: plan.project.name };
// A folder already tracked is answered, not written: say so in the preview.
// A removed one is re-attached (VC-822), which the preview names too.
const preview =
plan.kind === "existing"
? dryRunResponse(request, target, {
Expand All @@ -70,7 +71,20 @@ export async function projectAddVerb(
`${plan.project.path} is already the project ${plan.project.name}; nothing changes.`,
],
})
: dryRunResponse(request, { ...target, id: null });
: plan.kind === "reattached"
? dryRunResponse(request, target, {
durableWrites: [
{
resource: "project",
operation: "update",
summary: "Put the removed Project row back on the rail.",
},
],
humanVisibleEffects: [
`${plan.project.path} was the removed project ${plan.project.name}; it comes back under that name, with its tickets and Sessions.`,
],
})
: dryRunResponse(request, { ...target, id: null });
return preview!;
}
return {
Expand Down
60 changes: 59 additions & 1 deletion packages/host-core/src/automation-services.headless.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { afterEach, expect, it, vi } from "vite-plus/test";
import { nextScheduleOccurrence } from "@volli/shared";
import { createHostAutomations, type AutomationSessionPorts } from "./automation-services";
import { openTestDb, testProject, type TestDb } from "./db/test-helpers";
import { insertProject } from "./db/projects-repo";
import { detachProject, insertProject } from "./db/projects-repo";
import { listSkippedOccurrencesForProject } from "./db/automations-repo";

let ctx: TestDb | undefined;
Expand Down Expand Up @@ -76,3 +76,61 @@ it("keeps CRUD and the unattended schedule alive without a window or attention a
await automations.settled();
}
});

// VC-822: removal detaches a project and keeps its Automations, so its schedule
// still comes due. The real Run door refuses it, and the skip says why.
it("records a removed project's due schedule as a skip that names the removal", async () => {
vi.useFakeTimers();
const now = Date.UTC(2026, 9, 4, 12);
vi.setSystemTime(now);
ctx = openTestDb();
const project = testProject();
insertProject(ctx.db, project);
const log = { error: vi.fn() };
const automations = createHostAutomations({ db: ctx.db, events: { publish: vi.fn() }, log });
const schedule = { preset: "hourly" as const, minute: 30, timeZone: "UTC" };
const created = await automations.service.create({
commandId: randomUUID(),
projectId: project.id,
name: "Removed sweep",
instructions: "Check the project",
trigger: { kind: "schedule", schedule },
runtime: { kind: "tier", tier: "fast" },
});
if (!created.ok) throw new Error(created.error);
await automations.service.setEnabled({
commandId: randomUUID(),
automationId: created.automation.id,
enabled: true,
});
detachProject(ctx.db, project.id, now);

const create = vi.fn();
automations.start(() => ({
sessions: { create, attach: vi.fn() },
promptSupply: vi.fn(),
deliverInstructions: vi.fn(),
reportInstructionDeliveryFailure: vi.fn(),
readSessionActivity: vi.fn(),
}));
try {
const due = nextScheduleOccurrence({ schedule, staggerKey: created.automation.id, after: now });
await vi.advanceTimersByTimeAsync(due - now);
await vi.waitFor(() =>
expect(listSkippedOccurrencesForProject(ctx!.db, project.id)).toMatchObject([
{
reason: {
kind: "run-refused",
code: "PROJECT_REMOVED",
error: "The project was removed from Volli.",
},
},
]),
);
expect(create).not.toHaveBeenCalled();
expect(log.error).not.toHaveBeenCalled();
} finally {
automations.stop();
await automations.settled();
}
});
Loading
Loading