FN-6984: fix mission draft discard scoping

Fix mission interview draft discard so the Missions view can remove the intended draft without crossing project or tab ownership boundaries.

- Send the current session tab id when discarding drafts from MissionManager.
- Scope backend discard handling to mission-interview rows in the requested project.
- Cover desktop, mobile, stale, locked, project-scoped, and owning-tab discard cases.
- Add a patch changeset for the published CLI package.

Files changed:
 .changeset/fn-6984-mission-draft-discard.md        |   7 ++
 .../dashboard/app/components/MissionManager.tsx    |   8 +-
 .../MissionManager.delete-confirm.test.tsx         | 137 +++++++++++++++++++++
 .../mission-interview-drafts-routes.test.ts        |  46 +++++++
 packages/dashboard/src/mission-interview.ts        |  28 ++++-
 packages/dashboard/src/mission-routes.ts           |   5 +-
 6 files changed, 223 insertions(+), 8 deletions(-)

Fusion-Task-Id: FN-6984

Fusion-Task-Lineage: dff01077-6e37-4ff9-9e5e-7eb24f0e010a
This commit is contained in:
gsxdsm
2026-06-25 03:12:08 -07:00
parent f5e1b966ab
commit d984fce0ad
6 changed files with 223 additions and 8 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix discarding mission interview drafts from the Missions view.
category: fix
dev: Preserves project and owning-tab scope for mission interview draft discard requests.

View File

@@ -106,6 +106,7 @@ import {
import type { AutopilotState, MissionInterviewDraftSummary } from "./mission-types";
import { readCache, SWR_CACHE_KEYS, writeCache } from "../utils/swrCache";
import { getRelativeTimeBucket } from "../utils/relativeTimeAgo";
import { getSessionTabId } from "../utils/getSessionTabId";
const MISSION_SIDEBAR_DEFAULT_WIDTH = 300;
const MISSION_SIDEBAR_MIN_WIDTH = 220;
@@ -616,6 +617,7 @@ function normalizeMissionHierarchy(mission: MissionWithHierarchy): MissionWithHi
export function MissionManager({ isOpen, isInline = false, onClose, addToast, projectId, onSelectTask, availableTasks = [], resumeSessionId, targetMissionId, milestoneSliceResumeSessionId, onMilestoneSliceResumeFetchError, onNavigateToGoal }: MissionManagerProps) {
const { t } = useTranslation("app");
const { confirm } = useConfirm();
const sessionTabId = useMemo(() => getSessionTabId(), []);
const isActive = isInline || isOpen;
const cacheSuffix = projectId ?? "";
const missionsCacheKey = `${SWR_CACHE_KEYS.MISSIONS_PREFIX}${cacheSuffix}`;
@@ -4232,7 +4234,11 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const handleDiscardInterviewSession = async (sessionId: string) => {
try {
await discardMissionInterviewDraft(sessionId, projectId);
/*
FNXC:MissionDraftDiscard 2026-06-24-02:42:
The mission draft Discard confirmation must send the current browser tab id so a draft locked by this tab can be removed while a draft actively owned by another tab returns the lock warning and stays visible.
*/
await discardMissionInterviewDraft(sessionId, projectId, sessionTabId);
setMissionInterviewDrafts((current) => current.filter((session) => session.id !== sessionId));
} catch (err) {
if (err instanceof ApiRequestError && err.status === 409) {

View File

@@ -16,8 +16,10 @@ const mockFetchValidationRuns = vi.fn();
const mockFetchAiSessions = vi.fn();
const mockFetchAiSession = vi.fn();
const mockFetchMissionInterviewDrafts = vi.fn();
const mockDiscardMissionInterviewDraft = vi.fn();
const mockDeleteMission = vi.fn();
const mockSubscribeSse = vi.fn(() => vi.fn());
const mockGetSessionTabId = vi.fn(() => "mission-manager-tab");
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
@@ -38,6 +40,10 @@ vi.mock("../../sse-bus", () => ({
subscribeSse: (...args: unknown[]) => mockSubscribeSse(...args),
}));
vi.mock("../../utils/getSessionTabId", () => ({
getSessionTabId: () => mockGetSessionTabId(),
}));
vi.mock("../MissionInterviewModal", () => ({
MissionInterviewModal: () => null,
}));
@@ -62,6 +68,7 @@ vi.mock("../../api", async (importOriginal) => {
fetchAiSessions: (...args: unknown[]) => mockFetchAiSessions(...args),
fetchAiSession: (...args: unknown[]) => mockFetchAiSession(...args),
fetchMissionInterviewDrafts: (...args: unknown[]) => mockFetchMissionInterviewDrafts(...args),
discardMissionInterviewDraft: (...args: unknown[]) => mockDiscardMissionInterviewDraft(...args),
deleteMission: (...args: unknown[]) => mockDeleteMission(...args),
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }),
};
@@ -132,6 +139,7 @@ function setupMocks() {
mockFetchAiSessions.mockResolvedValue([]);
mockFetchAiSession.mockResolvedValue(null);
mockFetchMissionInterviewDrafts.mockResolvedValue([]);
mockDiscardMissionInterviewDraft.mockResolvedValue({ removed: true });
mockDeleteMission.mockResolvedValue(undefined);
}
@@ -144,6 +152,39 @@ function renderMissionManager(addToast = vi.fn()) {
return { ...result, addToast };
}
function makeDraft(overrides: Partial<{
id: string;
title: string;
status: "generating" | "awaiting_input" | "error" | "complete";
projectId: string | null;
createdAt: string;
updatedAt: string;
hasConversation: boolean;
}> = {}) {
const id = overrides.id ?? "draft-1";
return {
id,
title: overrides.title ?? id,
status: overrides.status ?? "awaiting_input",
projectId: overrides.projectId ?? projectId,
createdAt: overrides.createdAt ?? "2026-01-03T00:00:00.000Z",
updatedAt: overrides.updatedAt ?? "2026-01-03T00:00:00.000Z",
hasConversation: overrides.hasConversation ?? true,
};
}
function getConfirmPanel() {
const panel = document.querySelector(".mission-confirm-panel");
expect(panel).not.toBeNull();
return panel as HTMLElement;
}
function clickConfirmPanelAction(label: "Discard" | "Delete" | "Cancel") {
const button = Array.from(getConfirmPanel().querySelectorAll("button")).find((candidate) => candidate.textContent?.trim() === label);
expect(button).toBeDefined();
fireEvent.click(button as HTMLButtonElement);
}
async function findMissionListItem(title: string): Promise<HTMLElement> {
const titleNode = await screen.findByText(title);
const item = titleNode.closest(".mission-list__item");
@@ -240,4 +281,100 @@ describe("MissionManager mission delete confirmation", () => {
expect(screen.getByText("API Redesign")).toBeInTheDocument();
expect(addToast).toHaveBeenCalledWith("delete failed upstream", "error");
});
it("discards the selected desktop draft row without removing duplicate-titled drafts or missions", async () => {
mockFetchMissionInterviewDrafts.mockResolvedValue([
makeDraft({ id: "draft-duplicate-a", title: "Shared draft", status: "awaiting_input" }),
makeDraft({ id: "draft-duplicate-b", title: "Shared draft", status: "complete" }),
makeDraft({ id: "draft-error", title: "Error draft", status: "error" }),
]);
renderMissionManager();
const planReady = await screen.findByText("Plan ready");
const selectedDraft = planReady.closest(".mission-list__item");
expect(selectedDraft).not.toBeNull();
fireEvent.click(within(selectedDraft as HTMLElement).getByRole("button", { name: "Discard draft" }));
const panel = getConfirmPanel();
expect(panel).toHaveTextContent("Discard this interview draft?");
clickConfirmPanelAction("Discard");
await waitFor(() => {
expect(mockDiscardMissionInterviewDraft).toHaveBeenCalledWith("draft-duplicate-b", projectId, "mission-manager-tab");
});
await waitFor(() => {
expect(document.querySelector(".mission-confirm-panel")).toBeNull();
});
expect(screen.getAllByText("Build Auth System").length).toBeGreaterThan(0);
expect(screen.getByText("API Redesign")).toBeInTheDocument();
expect(screen.getByText("Awaiting input")).toBeInTheDocument();
expect(screen.queryByText("Plan ready")).not.toBeInTheDocument();
expect(screen.getByText("Error draft")).toBeInTheDocument();
});
it("discards a mobile draft row from the stacked list", async () => {
mockViewportMode.mockReturnValue("mobile");
mockFetchMissionInterviewDrafts.mockResolvedValue([
makeDraft({ id: "draft-mobile", title: "Mobile draft", status: "awaiting_input" }),
]);
renderMissionManager();
const draftTitle = await screen.findByText("Mobile draft");
const draftRow = draftTitle.closest(".mission-list__item");
expect(draftRow).not.toBeNull();
fireEvent.click((draftRow as HTMLElement).querySelector('button[aria-label="Discard draft"]') as HTMLButtonElement);
clickConfirmPanelAction("Discard");
await waitFor(() => {
expect(mockDiscardMissionInterviewDraft).toHaveBeenCalledWith("draft-mobile", projectId, "mission-manager-tab");
});
expect(screen.queryByText("Mobile draft")).not.toBeInTheDocument();
expect(document.querySelector(".mission-confirm-panel")).toBeNull();
});
it("removes an already-gone draft after a 404 discard response", async () => {
const { ApiRequestError } = await import("../../api");
mockFetchMissionInterviewDrafts.mockResolvedValue([
makeDraft({ id: "draft-stale", title: "Stale draft", status: "awaiting_input" }),
]);
mockDiscardMissionInterviewDraft.mockRejectedValueOnce(new ApiRequestError("missing", 404));
const addToast = vi.fn();
renderMissionManager(addToast);
const staleDraft = await screen.findByText("Stale draft");
const draftRow = staleDraft.closest(".mission-list__item");
expect(draftRow).not.toBeNull();
fireEvent.click((draftRow as HTMLElement).querySelector('button[aria-label="Discard draft"]') as HTMLButtonElement);
clickConfirmPanelAction("Discard");
await waitFor(() => {
expect(screen.queryByText("Stale draft")).not.toBeInTheDocument();
});
expect(document.querySelector(".mission-confirm-panel")).toBeNull();
expect(addToast).not.toHaveBeenCalledWith("Failed to discard draft", "error");
});
it("keeps a locked draft row after a 409 discard response", async () => {
const { ApiRequestError } = await import("../../api");
mockFetchMissionInterviewDrafts.mockResolvedValue([
makeDraft({ id: "draft-locked", title: "Locked draft", status: "awaiting_input" }),
]);
mockDiscardMissionInterviewDraft.mockRejectedValueOnce(new ApiRequestError("locked", 409));
const addToast = vi.fn();
renderMissionManager(addToast);
const lockedDraft = await screen.findByText("Locked draft");
const draftRow = lockedDraft.closest(".mission-list__item");
expect(draftRow).not.toBeNull();
fireEvent.click((draftRow as HTMLElement).querySelector('button[aria-label="Discard draft"]') as HTMLButtonElement);
clickConfirmPanelAction("Discard");
await waitFor(() => {
expect(mockDiscardMissionInterviewDraft).toHaveBeenCalledWith("draft-locked", projectId, "mission-manager-tab");
});
expect(screen.getByText("Locked draft")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Discard draft" })).toBeInTheDocument();
expect(document.querySelector(".mission-confirm-panel")).toBeNull();
expect(addToast).toHaveBeenCalledWith("Draft is open in another tab", "error");
});
});

View File

@@ -165,6 +165,31 @@ describe("mission interview draft routes", () => {
expect(aiSessionStore.get("draft-cold")).toBeNull();
});
it("POST /interview/drafts/:sessionId/discard is scoped by project and leaves other session types intact", async () => {
aiSessionStore.upsert(makeRow({ id: "draft-project-b", title: "Project B", projectId: "project-b", status: "awaiting_input" }));
aiSessionStore.upsert(makeRow({ id: "planning-row", type: "planning", title: "Planning", projectId: "project-a", status: "awaiting_input" }));
const wrongProject = await request(
app,
"POST",
"/api/missions/interview/drafts/draft-project-b/discard?projectId=project-a",
JSON.stringify({}),
{ "content-type": "application/json" },
);
const planningRow = await request(
app,
"POST",
"/api/missions/interview/drafts/planning-row/discard?projectId=project-a",
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(wrongProject.status).toBe(404);
expect(planningRow.status).toBe(404);
expect(aiSessionStore.get("draft-project-b")).not.toBeNull();
expect(aiSessionStore.get("planning-row")).not.toBeNull();
});
it("POST /interview/drafts/:sessionId/discard returns 404 when the session does not exist", async () => {
const res = await request(app, "POST", "/api/missions/interview/drafts/missing/discard", JSON.stringify({}), {
"content-type": "application/json",
@@ -174,6 +199,27 @@ describe("mission interview draft routes", () => {
expect((res.body as { error: string }).error).toContain("missing");
});
it("POST /interview/drafts/:sessionId/discard allows the owning tab to discard a locked draft", async () => {
aiSessionStore.upsert(makeRow({ id: "draft-owned", title: "Owned draft", status: "awaiting_input" }));
db.prepare("UPDATE ai_sessions SET lockedByTab = ?, lockedAt = ? WHERE id = ?").run(
"tab-owner",
"2026-05-12T00:00:00.000Z",
"draft-owned",
);
const res = await request(
app,
"POST",
"/api/missions/interview/drafts/draft-owned/discard",
JSON.stringify({ tabId: "tab-owner" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true, removed: true });
expect(aiSessionStore.get("draft-owned")).toBeNull();
});
it("POST /interview/drafts/:sessionId/discard returns 409 when locked by another tab", async () => {
aiSessionStore.upsert(makeRow({ id: "draft-locked", title: "Locked draft", status: "awaiting_input" }));
db.prepare("UPDATE ai_sessions SET lockedByTab = ?, lockedAt = ? WHERE id = ?").run(

View File

@@ -1362,16 +1362,32 @@ export function listMissionInterviewDrafts(projectId?: string): MissionInterview
});
}
export async function discardMissionInterviewSession(sessionId: string): Promise<{ removed: boolean }> {
try {
function isMissionInterviewSessionInProjectScope(sessionProjectId: string | null | undefined, projectId?: string): boolean {
if (projectId) {
return sessionProjectId === projectId;
}
return sessionProjectId == null;
}
export async function discardMissionInterviewSession(sessionId: string, projectId?: string): Promise<{ removed: boolean }> {
const hotSession = sessions.get(sessionId);
if (hotSession) {
if (!isMissionInterviewSessionInProjectScope(hotSession.projectId, projectId)) {
return { removed: false };
}
await cancelMissionInterviewSession(sessionId);
return { removed: true };
} catch (error) {
if (!(error instanceof SessionNotFoundError)) {
throw error;
}
}
const persistedSession = _aiSessionStore?.get(sessionId);
if (persistedSession?.type === "mission_interview" && !isMissionInterviewSessionInProjectScope(persistedSession.projectId, projectId)) {
return { removed: false };
}
/*
FNXC:MissionDraftDiscard 2026-06-24-02:47:
Draft discard uses the same project scope as draft listing: a project-scoped request can remove only that project's mission interview rows, and an unscoped request can remove only unscoped drafts. Ordinary planning sessions are excluded by the type guard.
*/
const removed = _aiSessionStore?.deleteByIdAndType(sessionId, "mission_interview") ?? false;
return { removed };
}

View File

@@ -742,6 +742,9 @@ export function createMissionRouter(
const tabId = typeof req.body?.tabId === "string" && req.body.tabId.trim().length > 0
? req.body.tabId.trim()
: undefined;
const projectId = typeof req.query.projectId === "string" && req.query.projectId.trim().length > 0
? req.query.projectId.trim()
: undefined;
if (!sessionId || typeof sessionId !== "string") {
throw badRequest("sessionId is required");
@@ -757,7 +760,7 @@ export function createMissionRouter(
}
const { discardMissionInterviewSession } = await import("./mission-interview.js");
const result = await discardMissionInterviewSession(sessionId);
const result = await discardMissionInterviewSession(sessionId, projectId);
if (!result.removed) {
throw notFound(`Mission interview session ${sessionId} not found or expired`);
}