feat(FN-1153): add cross-tab locks for AI planning sessions
- Bump SQLite schema to v19 with ai_sessions lock columns and lock index, and align migration coverage in core DB tests - Extend AiSessionStore with acquire/release/force lock APIs, stale lock cleanup, and lock metadata in ai_session update summaries - Enforce lock checks on planning, subtask, and mission interview mutation routes with 409 conflict responses while keeping stream reads unaffected - Add frontend tab identity + useSessionLock hook and wire Planning, Subtask, and Mission modals to pass tabId, show lock overlay, and support Take Control - Expand dashboard route/e2e and modal tests to validate lock enforcement, lock handoff, and lock-aware session reentry behavior
This commit is contained in:
@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
|
||||
|
||||
expect(tableNames.has("task_documents")).toBe(true);
|
||||
expect(tableNames.has("task_document_revisions")).toBe(true);
|
||||
expect(db.getSchemaVersion()).toBe(18);
|
||||
expect(db.getSchemaVersion()).toBe(19);
|
||||
|
||||
const index = db
|
||||
.prepare(
|
||||
|
||||
@@ -91,6 +91,7 @@ describe("Database", () => {
|
||||
expect(indexNames).toContain("idxAgentHeartbeatsRunId");
|
||||
expect(indexNames).toContain("idxAiSessionsStatus");
|
||||
expect(indexNames).toContain("idxAiSessionsType");
|
||||
expect(indexNames).toContain("idxAiSessionsLock");
|
||||
expect(indexNames).toContain("idxMessagesCreatedAt");
|
||||
expect(indexNames).toContain("idxMessagesFrom");
|
||||
expect(indexNames).toContain("idxMessagesTo");
|
||||
@@ -105,7 +106,7 @@ describe("Database", () => {
|
||||
});
|
||||
|
||||
it("seeds schema version", () => {
|
||||
expect(db.getSchemaVersion()).toBe(18);
|
||||
expect(db.getSchemaVersion()).toBe(19);
|
||||
});
|
||||
|
||||
it("seeds lastModified", () => {
|
||||
@@ -128,7 +129,7 @@ describe("Database", () => {
|
||||
|
||||
it("is idempotent - calling init() twice does not fail", () => {
|
||||
expect(() => db.init()).not.toThrow();
|
||||
expect(db.getSchemaVersion()).toBe(18);
|
||||
expect(db.getSchemaVersion()).toBe(19);
|
||||
});
|
||||
|
||||
it("does not overwrite existing config on re-init", () => {
|
||||
@@ -735,7 +736,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 5 (includes v1→v2, v2→v3, v3→v4, and v4→v5 migrations)
|
||||
expect(db.getSchemaVersion()).toBe(18);
|
||||
expect(db.getSchemaVersion()).toBe(19);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -760,11 +761,11 @@ describe("schema migrations", () => {
|
||||
const db = new Database(kbDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(18);
|
||||
expect(db.getSchemaVersion()).toBe(19);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(18);
|
||||
expect(db.getSchemaVersion()).toBe(19);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -780,7 +781,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(18);
|
||||
expect(db.getSchemaVersion()).toBe(19);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "agentRatings" }]);
|
||||
@@ -804,7 +805,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(18);
|
||||
expect(db.getSchemaVersion()).toBe(19);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "mission_events" }]);
|
||||
@@ -908,7 +909,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 5
|
||||
expect(db.getSchemaVersion()).toBe(18);
|
||||
expect(db.getSchemaVersion()).toBe(19);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -1118,7 +1119,7 @@ describe("createDatabase factory", () => {
|
||||
const db = createDatabase(kbDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(18);
|
||||
expect(db.getSchemaVersion()).toBe(19);
|
||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||
|
||||
db.close();
|
||||
|
||||
@@ -59,7 +59,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
|
||||
|
||||
// ── Schema Definition ────────────────────────────────────────────────
|
||||
|
||||
const SCHEMA_VERSION = 18;
|
||||
const SCHEMA_VERSION = 19;
|
||||
|
||||
function normalizeTaskComments(
|
||||
steeringComments: SteeringComment[] | undefined,
|
||||
@@ -731,6 +731,17 @@ export class Database {
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxTaskDocumentRevisionsTaskKey ON task_document_revisions(taskId, key)`);
|
||||
});
|
||||
}
|
||||
|
||||
if (version < 19) {
|
||||
this.applyMigration(19, () => {
|
||||
if (!this.hasTable("ai_sessions")) {
|
||||
return;
|
||||
}
|
||||
this.addColumnIfMissing("ai_sessions", "lockedByTab", "TEXT");
|
||||
this.addColumnIfMissing("ai_sessions", "lockedAt", "TEXT");
|
||||
this.db.exec("CREATE INDEX IF NOT EXISTS idxAiSessionsLock ON ai_sessions(lockedByTab)");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1245,11 +1245,12 @@ export function startPlanningStreaming(
|
||||
export function respondToPlanning(
|
||||
sessionId: string,
|
||||
responses: Record<string, unknown>,
|
||||
projectId?: string
|
||||
projectId?: string,
|
||||
tabId?: string,
|
||||
): Promise<PlanningSession> {
|
||||
return api<PlanningSession>(withProjectId("/planning/respond", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sessionId, responses }),
|
||||
body: JSON.stringify({ sessionId, responses, tabId }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1257,20 +1258,22 @@ export function respondToPlanning(
|
||||
export function retryPlanningSession(
|
||||
sessionId: string,
|
||||
projectId?: string,
|
||||
tabId?: string,
|
||||
): Promise<{ success: boolean; sessionId: string }> {
|
||||
return api<{ success: boolean; sessionId: string }>(
|
||||
withProjectId(`/planning/${encodeURIComponent(sessionId)}/retry`, projectId),
|
||||
{
|
||||
method: "POST",
|
||||
...(tabId ? { body: JSON.stringify({ tabId }) } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Cancel an active planning session */
|
||||
export function cancelPlanning(sessionId: string, projectId?: string): Promise<void> {
|
||||
export function cancelPlanning(sessionId: string, projectId?: string, tabId?: string): Promise<void> {
|
||||
return api<void>(withProjectId("/planning/cancel", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sessionId }),
|
||||
body: JSON.stringify({ sessionId, tabId }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1819,11 +1822,13 @@ export function startSubtaskBreakdown(description: string, projectId?: string):
|
||||
export function retrySubtaskSession(
|
||||
sessionId: string,
|
||||
projectId?: string,
|
||||
tabId?: string,
|
||||
): Promise<{ success: boolean; sessionId: string }> {
|
||||
return api<{ success: boolean; sessionId: string }>(
|
||||
withProjectId(`/subtasks/${encodeURIComponent(sessionId)}/retry`, projectId),
|
||||
{
|
||||
method: "POST",
|
||||
...(tabId ? { body: JSON.stringify({ tabId }) } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1933,10 +1938,10 @@ export function createTasksFromBreakdown(
|
||||
});
|
||||
}
|
||||
|
||||
export function cancelSubtaskBreakdown(sessionId: string, projectId?: string): Promise<void> {
|
||||
export function cancelSubtaskBreakdown(sessionId: string, projectId?: string, tabId?: string): Promise<void> {
|
||||
return api<void>(withProjectId("/subtasks/cancel", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sessionId }),
|
||||
body: JSON.stringify({ sessionId, tabId }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3252,11 +3257,12 @@ export function startMissionInterview(missionTitle: string, projectId?: string):
|
||||
export function respondToMissionInterview(
|
||||
sessionId: string,
|
||||
responses: Record<string, unknown>,
|
||||
projectId?: string
|
||||
projectId?: string,
|
||||
tabId?: string,
|
||||
): Promise<MissionInterviewResponse> {
|
||||
return api<MissionInterviewResponse>(withProjectId("/missions/interview/respond", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sessionId, responses }),
|
||||
body: JSON.stringify({ sessionId, responses, tabId }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3264,20 +3270,22 @@ export function respondToMissionInterview(
|
||||
export function retryMissionInterviewSession(
|
||||
sessionId: string,
|
||||
projectId?: string,
|
||||
tabId?: string,
|
||||
): Promise<{ success: boolean; sessionId: string }> {
|
||||
return api<{ success: boolean; sessionId: string }>(
|
||||
withProjectId(`/missions/interview/${encodeURIComponent(sessionId)}/retry`, projectId),
|
||||
{
|
||||
method: "POST",
|
||||
...(tabId ? { body: JSON.stringify({ tabId }) } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Cancel an active mission interview session */
|
||||
export function cancelMissionInterview(sessionId: string, projectId?: string): Promise<void> {
|
||||
export function cancelMissionInterview(sessionId: string, projectId?: string, tabId?: string): Promise<void> {
|
||||
return api<void>(withProjectId("/missions/interview/cancel", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sessionId }),
|
||||
body: JSON.stringify({ sessionId, tabId }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3392,6 +3400,7 @@ export interface AiSessionSummary {
|
||||
status: "generating" | "awaiting_input" | "complete" | "error";
|
||||
title: string;
|
||||
projectId: string | null;
|
||||
lockedByTab: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -3409,6 +3418,7 @@ export interface AiSessionDetail extends AiSessionSummary {
|
||||
thinkingOutput: string;
|
||||
error: string | null;
|
||||
createdAt: string;
|
||||
lockedAt: string | null;
|
||||
}
|
||||
|
||||
export function parseConversationHistory(raw: string): ConversationHistoryEntry[] {
|
||||
@@ -3436,6 +3446,38 @@ export async function fetchAiSession(id: string): Promise<AiSessionDetail | null
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function acquireSessionLock(
|
||||
sessionId: string,
|
||||
tabId: string,
|
||||
): Promise<{ acquired: boolean; currentHolder: string | null }> {
|
||||
const result = await api<{ acquired: boolean; currentHolder?: string | null }>(
|
||||
`/ai-sessions/${encodeURIComponent(sessionId)}/lock`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ tabId }),
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
acquired: result.acquired,
|
||||
currentHolder: result.currentHolder ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function releaseSessionLock(sessionId: string, tabId: string): Promise<void> {
|
||||
return api<void>(`/ai-sessions/${encodeURIComponent(sessionId)}/lock`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ tabId }),
|
||||
});
|
||||
}
|
||||
|
||||
export function forceAcquireSessionLock(sessionId: string, tabId: string): Promise<void> {
|
||||
return api<void>(`/ai-sessions/${encodeURIComponent(sessionId)}/lock/force`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ tabId }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteAiSession(id: string): Promise<void> {
|
||||
await fetch(buildApiUrl(`/ai-sessions/${encodeURIComponent(id)}`), { method: "DELETE" });
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ const mockCreateMissionFromInterview = vi.fn();
|
||||
const mockConnectMissionInterviewStream = vi.fn();
|
||||
const mockFetchAiSession = vi.fn();
|
||||
const mockParseConversationHistory = vi.fn();
|
||||
const mockAcquireSessionLock = vi.fn();
|
||||
const mockReleaseSessionLock = vi.fn();
|
||||
const mockForceAcquireSessionLock = vi.fn();
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
startMissionInterview: (...args: any[]) => mockStartMissionInterview(...args),
|
||||
@@ -20,6 +23,9 @@ vi.mock("../api", () => ({
|
||||
connectMissionInterviewStream: (...args: any[]) => mockConnectMissionInterviewStream(...args),
|
||||
fetchAiSession: (...args: any[]) => mockFetchAiSession(...args),
|
||||
parseConversationHistory: (...args: any[]) => mockParseConversationHistory(...args),
|
||||
acquireSessionLock: (...args: any[]) => mockAcquireSessionLock(...args),
|
||||
releaseSessionLock: (...args: any[]) => mockReleaseSessionLock(...args),
|
||||
forceAcquireSessionLock: (...args: any[]) => mockForceAcquireSessionLock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/modalPersistence", () => ({
|
||||
@@ -65,6 +71,9 @@ describe("MissionInterviewModal", () => {
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
||||
mockReleaseSessionLock.mockResolvedValue(undefined);
|
||||
mockForceAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
||||
});
|
||||
|
||||
function renderModal() {
|
||||
@@ -77,6 +86,32 @@ describe("MissionInterviewModal", () => {
|
||||
);
|
||||
}
|
||||
|
||||
it("shows lock overlay and allows take-control", async () => {
|
||||
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
|
||||
mockAcquireSessionLock.mockResolvedValueOnce({ acquired: false, currentHolder: "tab-other" });
|
||||
|
||||
renderModal();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
||||
target: { value: "Build a mission planning workflow" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Start Interview"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("session-lock-overlay")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Take Control"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockForceAcquireSessionLock).toHaveBeenCalledWith("mission-session-1", "tab-self");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("session-lock-overlay")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows reconnecting indicator without clearing current question", async () => {
|
||||
renderModal();
|
||||
|
||||
@@ -189,7 +224,7 @@ describe("MissionInterviewModal", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRetryMissionInterviewSession).toHaveBeenCalledWith("mission-session-1", undefined);
|
||||
expect(mockRetryMissionInterviewSession).toHaveBeenCalledWith("mission-session-1", undefined, expect.any(String));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("What is the target scope?")).toBeInTheDocument();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { PlanningQuestion } from "@fusion/core";
|
||||
import {
|
||||
startMissionInterview,
|
||||
@@ -38,8 +38,11 @@ import {
|
||||
Trash2,
|
||||
Minimize2,
|
||||
RefreshCw,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import { ConversationHistory } from "./ConversationHistory";
|
||||
import { useSessionLock } from "../hooks/useSessionLock";
|
||||
import { getSessionTabId } from "../utils/getSessionTabId";
|
||||
|
||||
interface MissionInterviewModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -92,6 +95,13 @@ export function MissionInterviewModal({
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
|
||||
const currentSessionIdRef = useRef<string | null>(null);
|
||||
const [lockSessionId, setLockSessionId] = useState<string | null>(resumeSessionId ?? null);
|
||||
const sessionTabId = useMemo(() => getSessionTabId(), []);
|
||||
const {
|
||||
isLockedByOther,
|
||||
takeControl,
|
||||
isLoading: isLockLoading,
|
||||
} = useSessionLock(isOpen ? lockSessionId : null);
|
||||
|
||||
const connectToMissionInterviewStream = useCallback(
|
||||
(sessionId: string) => {
|
||||
@@ -157,6 +167,7 @@ export function MissionInterviewModal({
|
||||
try {
|
||||
const { sessionId } = await startMissionInterview(goal.trim(), projectId);
|
||||
currentSessionIdRef.current = sessionId;
|
||||
setLockSessionId(sessionId);
|
||||
clearMissionGoal(projectId);
|
||||
|
||||
connectToMissionInterviewStream(sessionId);
|
||||
@@ -166,6 +177,7 @@ export function MissionInterviewModal({
|
||||
setError(err.message || "Failed to start interview session");
|
||||
setView({ type: "initial" });
|
||||
currentSessionIdRef.current = null;
|
||||
setLockSessionId(null);
|
||||
}
|
||||
},
|
||||
[connectToMissionInterviewStream, missionGoal, projectId]
|
||||
@@ -201,6 +213,7 @@ export function MissionInterviewModal({
|
||||
hasAutoStartedRef.current = false;
|
||||
setIsReconnecting(false);
|
||||
setIsRetrying(false);
|
||||
setLockSessionId(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
@@ -215,6 +228,7 @@ export function MissionInterviewModal({
|
||||
|
||||
const parsedHistory = parseConversationHistory(session.conversationHistory);
|
||||
setConversationHistory(parsedHistory);
|
||||
setLockSessionId(session.id);
|
||||
setResponseHistory(
|
||||
parsedHistory
|
||||
.map((entry) => entry.response)
|
||||
@@ -318,7 +332,7 @@ export function MissionInterviewModal({
|
||||
|
||||
if (view.type === "question" || view.type === "summary" || view.type === "error") {
|
||||
try {
|
||||
await cancelMissionInterview(view.sessionId, projectId);
|
||||
await cancelMissionInterview(view.sessionId, projectId, sessionTabId);
|
||||
} catch {
|
||||
// Ignore errors on cancel
|
||||
}
|
||||
@@ -336,8 +350,9 @@ export function MissionInterviewModal({
|
||||
setHasProgress(false);
|
||||
setIsCreating(false);
|
||||
currentSessionIdRef.current = null;
|
||||
setLockSessionId(null);
|
||||
onClose();
|
||||
}, [missionGoal, hasProgress, view, onClose, projectId]);
|
||||
}, [missionGoal, hasProgress, view, onClose, projectId, sessionTabId]);
|
||||
|
||||
// Escape key handler
|
||||
useEffect(() => {
|
||||
@@ -377,14 +392,14 @@ export function MissionInterviewModal({
|
||||
setStreamingOutput("");
|
||||
|
||||
try {
|
||||
await respondToMissionInterview(sessionId, responses, projectId);
|
||||
await respondToMissionInterview(sessionId, responses, projectId, sessionTabId);
|
||||
setHasProgress(true);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to submit response");
|
||||
setView({ type: "question", sessionId, question: view.question });
|
||||
}
|
||||
},
|
||||
[view, projectId]
|
||||
[view, projectId, sessionTabId]
|
||||
);
|
||||
|
||||
const handleRetryFromError = useCallback(async () => {
|
||||
@@ -401,7 +416,8 @@ export function MissionInterviewModal({
|
||||
|
||||
try {
|
||||
currentSessionIdRef.current = retrySessionId;
|
||||
await retryMissionInterviewSession(retrySessionId, projectId);
|
||||
setLockSessionId(retrySessionId);
|
||||
await retryMissionInterviewSession(retrySessionId, projectId, sessionTabId);
|
||||
} catch (err: any) {
|
||||
streamConnectionRef.current?.close();
|
||||
streamConnectionRef.current = null;
|
||||
@@ -414,7 +430,7 @@ export function MissionInterviewModal({
|
||||
} finally {
|
||||
setIsRetrying(false);
|
||||
}
|
||||
}, [connectToMissionInterviewStream, projectId, view]);
|
||||
}, [connectToMissionInterviewStream, projectId, sessionTabId, view]);
|
||||
|
||||
const handleApprovePlan = useCallback(async () => {
|
||||
if (view.type !== "summary") return;
|
||||
@@ -441,6 +457,7 @@ export function MissionInterviewModal({
|
||||
setHasProgress(false);
|
||||
setIsCreating(false);
|
||||
currentSessionIdRef.current = null;
|
||||
setLockSessionId(null);
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to create mission");
|
||||
@@ -628,12 +645,32 @@ export function MissionInterviewModal({
|
||||
setEditedSummary(null);
|
||||
setResponseHistory([]);
|
||||
setConversationHistory([]);
|
||||
setLockSessionId(null);
|
||||
streamConnectionRef.current?.close();
|
||||
streamConnectionRef.current = null;
|
||||
}}
|
||||
isCreating={isCreating}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLockedByOther && (
|
||||
<div className="session-lock-overlay" data-testid="session-lock-overlay">
|
||||
<div className="session-lock-banner">
|
||||
<Lock size={16} />
|
||||
<span>This session is active in another tab</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void takeControl();
|
||||
}}
|
||||
disabled={isLockLoading}
|
||||
className="btn btn-primary session-lock-take-control"
|
||||
>
|
||||
{isLockLoading ? "Taking control..." : "Take Control"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, render, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||
import { act, render, renderHook, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||
import { PlanningModeModal } from "./PlanningModeModal";
|
||||
import { TaskDetailModal } from "./TaskDetailModal";
|
||||
import { useSessionLock } from "../hooks/useSessionLock";
|
||||
import { getSessionTabId } from "../utils/getSessionTabId";
|
||||
import type { Task, TaskDetail, PlanningQuestion, PlanningSummary, MergeResult } from "@fusion/core";
|
||||
|
||||
// Mock the API functions
|
||||
@@ -17,6 +19,9 @@ const mockCreateTasksFromPlanning = vi.fn();
|
||||
const mockFetchAiSession = vi.fn();
|
||||
const mockParseConversationHistory = vi.fn();
|
||||
const mockFetchModels = vi.fn();
|
||||
const mockAcquireSessionLock = vi.fn();
|
||||
const mockReleaseSessionLock = vi.fn();
|
||||
const mockForceAcquireSessionLock = vi.fn();
|
||||
const mockUploadAttachment = vi.fn();
|
||||
const mockDeleteAttachment = vi.fn();
|
||||
const mockUpdateTask = vi.fn();
|
||||
@@ -40,6 +45,9 @@ vi.mock("../api", () => ({
|
||||
createTasksFromPlanning: (...args: any[]) => mockCreateTasksFromPlanning(...args),
|
||||
fetchAiSession: (...args: any[]) => mockFetchAiSession(...args),
|
||||
parseConversationHistory: (...args: any[]) => mockParseConversationHistory(...args),
|
||||
acquireSessionLock: (...args: any[]) => mockAcquireSessionLock(...args),
|
||||
releaseSessionLock: (...args: any[]) => mockReleaseSessionLock(...args),
|
||||
forceAcquireSessionLock: (...args: any[]) => mockForceAcquireSessionLock(...args),
|
||||
uploadAttachment: (...args: any[]) => mockUploadAttachment(...args),
|
||||
deleteAttachment: (...args: any[]) => mockDeleteAttachment(...args),
|
||||
updateTask: (...args: any[]) => mockUpdateTask(...args),
|
||||
@@ -126,12 +134,55 @@ const mockTaskDetail = {
|
||||
paused: false,
|
||||
} as TaskDetail;
|
||||
|
||||
class MockEventSource {
|
||||
static instances: MockEventSource[] = [];
|
||||
|
||||
url: string;
|
||||
closed = false;
|
||||
private listeners = new Map<string, Set<(event: MessageEvent) => void>>();
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
MockEventSource.instances.push(this);
|
||||
}
|
||||
|
||||
addEventListener(event: string, listener: (event: MessageEvent) => void): void {
|
||||
const set = this.listeners.get(event) ?? new Set();
|
||||
set.add(listener);
|
||||
this.listeners.set(event, set);
|
||||
}
|
||||
|
||||
removeEventListener(event: string, listener: (event: MessageEvent) => void): void {
|
||||
const set = this.listeners.get(event);
|
||||
if (!set) return;
|
||||
set.delete(listener);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
}
|
||||
|
||||
emit(event: string, data: unknown): void {
|
||||
const listeners = this.listeners.get(event);
|
||||
if (!listeners) return;
|
||||
const message = { data: JSON.stringify(data) } as MessageEvent;
|
||||
listeners.forEach((listener) => listener(message));
|
||||
}
|
||||
|
||||
static reset(): void {
|
||||
MockEventSource.instances = [];
|
||||
}
|
||||
}
|
||||
|
||||
describe("PlanningModeModal", () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const mockOnTaskCreated = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
MockEventSource.reset();
|
||||
vi.stubGlobal("EventSource", MockEventSource as any);
|
||||
window.sessionStorage.clear();
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
|
||||
// Default mock for streaming
|
||||
@@ -153,6 +204,9 @@ describe("PlanningModeModal", () => {
|
||||
favoriteProviders: [],
|
||||
favoriteModels: [],
|
||||
});
|
||||
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
||||
mockReleaseSessionLock.mockResolvedValue(undefined);
|
||||
mockForceAcquireSessionLock.mockResolvedValue(undefined);
|
||||
|
||||
// Default: simulate receiving a question after a brief delay
|
||||
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
@@ -401,6 +455,80 @@ describe("PlanningModeModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows locked overlay and allows take-control", async () => {
|
||||
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
|
||||
mockAcquireSessionLock.mockResolvedValueOnce({ acquired: false, currentHolder: "tab-other" });
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
|
||||
target: { value: "Build auth system" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Start Planning"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("session-lock-overlay")).toBeDefined();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("Take Control"));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockForceAcquireSessionLock).toHaveBeenCalledWith("session-123", "tab-self");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("session-lock-overlay")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("allows normal question interaction when lock is acquired", async () => {
|
||||
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
|
||||
target: { value: "Build auth system" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Start Planning"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("session-lock-overlay")).toBeNull();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("What is the scope?")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Small"));
|
||||
fireEvent.click(screen.getByText("Continue"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRespondToPlanning).toHaveBeenCalledWith(
|
||||
"session-123",
|
||||
{ "q-scope": "small" },
|
||||
undefined,
|
||||
"tab-self",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error message when planning fails", async () => {
|
||||
// Override the default mock to simulate an error
|
||||
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
@@ -472,7 +600,7 @@ describe("PlanningModeModal", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-123", undefined);
|
||||
expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-123", undefined, expect.any(String));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("What is the scope?")).toBeDefined();
|
||||
@@ -1599,3 +1727,118 @@ describe("PlanningModeModal", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSessionTabId", () => {
|
||||
it("creates and persists a per-tab id in sessionStorage", () => {
|
||||
window.sessionStorage.clear();
|
||||
|
||||
const first = getSessionTabId();
|
||||
const second = getSessionTabId();
|
||||
|
||||
expect(first).toBeTruthy();
|
||||
expect(second).toBe(first);
|
||||
expect(window.sessionStorage.getItem("fusion-tab-id")).toBe(first);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSessionLock", () => {
|
||||
beforeEach(() => {
|
||||
MockEventSource.reset();
|
||||
vi.stubGlobal("EventSource", MockEventSource as any);
|
||||
window.sessionStorage.clear();
|
||||
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
||||
mockReleaseSessionLock.mockResolvedValue(undefined);
|
||||
mockForceAcquireSessionLock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("acquires on mount and releases on unmount", async () => {
|
||||
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
|
||||
|
||||
const { unmount } = renderHook(() => useSessionLock("session-1"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAcquireSessionLock).toHaveBeenCalledWith("session-1", "tab-self");
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReleaseSessionLock).toHaveBeenCalledWith("session-1", "tab-self");
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes locked state and allows taking control", async () => {
|
||||
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
|
||||
mockAcquireSessionLock.mockResolvedValueOnce({ acquired: false, currentHolder: "tab-other" });
|
||||
|
||||
const { result } = renderHook(() => useSessionLock("session-2"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLockedByOther).toBe(true);
|
||||
expect(result.current.currentHolder).toBe("tab-other");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.takeControl();
|
||||
});
|
||||
|
||||
expect(mockForceAcquireSessionLock).toHaveBeenCalledWith("session-2", "tab-self");
|
||||
expect(result.current.isLockedByOther).toBe(false);
|
||||
expect(result.current.currentHolder).toBeNull();
|
||||
});
|
||||
|
||||
it("updates lock state from ai_session:updated SSE events and uses sendBeacon on beforeunload", async () => {
|
||||
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
|
||||
const sendBeaconSpy = vi.fn(() => true);
|
||||
vi.stubGlobal("navigator", {
|
||||
...window.navigator,
|
||||
sendBeacon: sendBeaconSpy,
|
||||
} as Navigator);
|
||||
|
||||
const { result } = renderHook(() => useSessionLock("session-3"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAcquireSessionLock).toHaveBeenCalledWith("session-3", "tab-self");
|
||||
});
|
||||
|
||||
const source = MockEventSource.instances[0];
|
||||
expect(source).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
source?.emit("ai_session:updated", {
|
||||
id: "session-3",
|
||||
type: "planning",
|
||||
status: "awaiting_input",
|
||||
title: "Session",
|
||||
projectId: null,
|
||||
lockedByTab: "tab-other",
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.isLockedByOther).toBe(true);
|
||||
expect(result.current.currentHolder).toBe("tab-other");
|
||||
|
||||
act(() => {
|
||||
source?.emit("ai_session:updated", {
|
||||
id: "session-3",
|
||||
type: "planning",
|
||||
status: "awaiting_input",
|
||||
title: "Session",
|
||||
projectId: null,
|
||||
lockedByTab: "tab-self",
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.isLockedByOther).toBe(false);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("beforeunload"));
|
||||
});
|
||||
|
||||
expect(sendBeaconSpy).toHaveBeenCalledWith(
|
||||
"/api/ai-sessions/session-3/lock/beacon?tabId=tab-self",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,9 +21,11 @@ import {
|
||||
getPlanningDescription,
|
||||
clearPlanningDescription,
|
||||
} from "../hooks/modalPersistence";
|
||||
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, Minimize2, RefreshCw } from "lucide-react";
|
||||
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, Minimize2, RefreshCw, Lock } from "lucide-react";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { ConversationHistory } from "./ConversationHistory";
|
||||
import { useSessionLock } from "../hooks/useSessionLock";
|
||||
import { getSessionTabId } from "../utils/getSessionTabId";
|
||||
|
||||
interface PlanningModeModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -96,6 +98,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
|
||||
const currentSessionIdRef = useRef<string | null>(null);
|
||||
const [lockSessionId, setLockSessionId] = useState<string | null>(resumeSessionId ?? null);
|
||||
const sessionTabId = useMemo(() => getSessionTabId(), []);
|
||||
const {
|
||||
isLockedByOther,
|
||||
takeControl,
|
||||
isLoading: isLockLoading,
|
||||
} = useSessionLock(isOpen ? lockSessionId : null);
|
||||
const [planningModelProvider, setPlanningModelProvider] = useState<string | undefined>(undefined);
|
||||
const [planningModelId, setPlanningModelId] = useState<string | undefined>(undefined);
|
||||
const [loadedModels, setLoadedModels] = useState<ModelInfo[]>([]);
|
||||
@@ -225,6 +234,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
|
||||
const { sessionId } = await startPlanningStreaming(plan.trim(), projectId, modelOverride);
|
||||
currentSessionIdRef.current = sessionId;
|
||||
setLockSessionId(sessionId);
|
||||
|
||||
connectToPlanningStream(sessionId);
|
||||
setResponseHistory([]);
|
||||
@@ -233,6 +243,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
setError(err.message || "Failed to start planning session");
|
||||
setView({ type: "initial" });
|
||||
currentSessionIdRef.current = null;
|
||||
setLockSessionId(null);
|
||||
}
|
||||
}, [connectToPlanningStream, initialPlan, planningModelId, planningModelProvider, projectId]);
|
||||
|
||||
@@ -280,6 +291,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
if (cancelled || !session) return;
|
||||
|
||||
currentSessionIdRef.current = resumeSessionId;
|
||||
setLockSessionId(resumeSessionId);
|
||||
const parsedHistory = parseConversationHistory(session.conversationHistory);
|
||||
setConversationHistory(parsedHistory);
|
||||
setResponseHistory(
|
||||
@@ -325,6 +337,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
hasAutoStartedRef.current = false;
|
||||
setIsReconnecting(false);
|
||||
setIsRetrying(false);
|
||||
setLockSessionId(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
@@ -378,6 +391,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
setPlanningModelProvider(undefined);
|
||||
setPlanningModelId(undefined);
|
||||
currentSessionIdRef.current = null;
|
||||
setLockSessionId(null);
|
||||
onClose();
|
||||
}, [initialPlan, onClose, projectId]);
|
||||
|
||||
@@ -428,14 +442,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
|
||||
try {
|
||||
// Submit response - AI will broadcast events via the already-connected stream
|
||||
await respondToPlanning(sessionId, responses, projectId);
|
||||
await respondToPlanning(sessionId, responses, projectId, sessionTabId);
|
||||
// Events (question/summary) will arrive via the existing SSE stream
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to submit response");
|
||||
setView({ type: "question", session });
|
||||
}
|
||||
},
|
||||
[projectId, view]
|
||||
[projectId, sessionTabId, view]
|
||||
);
|
||||
|
||||
const handleRetryFromError = useCallback(async () => {
|
||||
@@ -453,7 +467,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
|
||||
try {
|
||||
currentSessionIdRef.current = retryTarget.sessionId;
|
||||
await retryPlanningSession(retryTarget.sessionId, projectId);
|
||||
setLockSessionId(retryTarget.sessionId);
|
||||
await retryPlanningSession(retryTarget.sessionId, projectId, sessionTabId);
|
||||
} catch (err: any) {
|
||||
streamConnectionRef.current?.close();
|
||||
streamConnectionRef.current = null;
|
||||
@@ -466,7 +481,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
} finally {
|
||||
setIsRetrying(false);
|
||||
}
|
||||
}, [connectToPlanningStream, projectId, view]);
|
||||
}, [connectToPlanningStream, projectId, sessionTabId, view]);
|
||||
|
||||
const handleCreateTask = useCallback(async () => {
|
||||
if (view.type !== "summary") return;
|
||||
@@ -492,6 +507,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
|
||||
try {
|
||||
const result = await startPlanningBreakdown(view.session.sessionId, projectId);
|
||||
setLockSessionId(result.sessionId);
|
||||
setView({
|
||||
type: "breakdown",
|
||||
sessionId: result.sessionId,
|
||||
@@ -524,6 +540,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
setPlanningModelProvider(undefined);
|
||||
setPlanningModelId(undefined);
|
||||
currentSessionIdRef.current = null;
|
||||
setLockSessionId(null);
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to create tasks");
|
||||
@@ -826,6 +843,25 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLockedByOther && (
|
||||
<div className="session-lock-overlay" data-testid="session-lock-overlay">
|
||||
<div className="session-lock-banner">
|
||||
<Lock size={16} />
|
||||
<span>This session is active in another tab</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void takeControl();
|
||||
}}
|
||||
disabled={isLockLoading}
|
||||
className="btn btn-primary session-lock-take-control"
|
||||
>
|
||||
{isLockLoading ? "Taking control..." : "Take Control"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,9 @@ const mockRetrySubtaskSession = vi.fn();
|
||||
const mockConnectSubtaskStream = vi.fn();
|
||||
const mockCreateTasksFromBreakdown = vi.fn();
|
||||
const mockCancelSubtaskBreakdown = vi.fn();
|
||||
const mockAcquireSessionLock = vi.fn();
|
||||
const mockReleaseSessionLock = vi.fn();
|
||||
const mockForceAcquireSessionLock = vi.fn();
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
startSubtaskBreakdown: (...args: any[]) => mockStartSubtaskBreakdown(...args),
|
||||
@@ -14,6 +17,9 @@ vi.mock("../api", () => ({
|
||||
connectSubtaskStream: (...args: any[]) => mockConnectSubtaskStream(...args),
|
||||
createTasksFromBreakdown: (...args: any[]) => mockCreateTasksFromBreakdown(...args),
|
||||
cancelSubtaskBreakdown: (...args: any[]) => mockCancelSubtaskBreakdown(...args),
|
||||
acquireSessionLock: (...args: any[]) => mockAcquireSessionLock(...args),
|
||||
releaseSessionLock: (...args: any[]) => mockReleaseSessionLock(...args),
|
||||
forceAcquireSessionLock: (...args: any[]) => mockForceAcquireSessionLock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/modalPersistence", () => ({
|
||||
@@ -49,6 +55,9 @@ describe("SubtaskBreakdownModal", () => {
|
||||
});
|
||||
mockCreateTasksFromBreakdown.mockResolvedValue({ tasks: [{ id: "FN-101" }, { id: "FN-102" }] });
|
||||
mockCancelSubtaskBreakdown.mockResolvedValue(undefined);
|
||||
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
||||
mockReleaseSessionLock.mockResolvedValue(undefined);
|
||||
mockForceAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
||||
vi.stubGlobal("confirm", vi.fn(() => true));
|
||||
});
|
||||
|
||||
@@ -73,6 +82,27 @@ describe("SubtaskBreakdownModal", () => {
|
||||
expect(await screen.findByText("AI is generating subtasks...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows lock overlay and allows take-control", async () => {
|
||||
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
|
||||
mockAcquireSessionLock.mockResolvedValueOnce({ acquired: false, currentHolder: "tab-other" });
|
||||
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("session-lock-overlay")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Take Control"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockForceAcquireSessionLock).toHaveBeenCalledWith("session-123", "tab-self");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("session-lock-overlay")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides send to background button in initial state", () => {
|
||||
render(
|
||||
<SubtaskBreakdownModal
|
||||
@@ -495,7 +525,7 @@ describe("SubtaskBreakdownModal", () => {
|
||||
fireEvent.click(retryButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRetrySubtaskSession).toHaveBeenCalledWith("session-123", undefined);
|
||||
expect(mockRetrySubtaskSession).toHaveBeenCalledWith("session-123", undefined, expect.any(String));
|
||||
});
|
||||
expect(mockConnectSubtaskStream).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -16,8 +16,10 @@ import {
|
||||
getSubtaskDescription,
|
||||
clearSubtaskDescription,
|
||||
} from "../hooks/modalPersistence";
|
||||
import { CheckCircle, Loader2, ListTree, Plus, Trash2, X, GripVertical, ArrowUp, ArrowDown, Minimize2, RefreshCw } from "lucide-react";
|
||||
import { CheckCircle, Loader2, ListTree, Plus, Trash2, X, GripVertical, ArrowUp, ArrowDown, Minimize2, RefreshCw, Lock } from "lucide-react";
|
||||
import { ConversationHistory } from "./ConversationHistory";
|
||||
import { useSessionLock } from "../hooks/useSessionLock";
|
||||
import { getSessionTabId } from "../utils/getSessionTabId";
|
||||
|
||||
interface SubtaskBreakdownModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -91,6 +93,12 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
const sessionId = view.type === "generating" || view.type === "editing" || view.type === "creating" || view.type === "error"
|
||||
? view.sessionId
|
||||
: null;
|
||||
const sessionTabId = useMemo(() => getSessionTabId(), []);
|
||||
const {
|
||||
isLockedByOther,
|
||||
takeControl,
|
||||
isLoading: isLockLoading,
|
||||
} = useSessionLock(isOpen ? sessionId : null);
|
||||
|
||||
const isInvalid = useMemo(() => {
|
||||
if (subtasks.length === 0) return true;
|
||||
@@ -131,14 +139,14 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
}
|
||||
if (sessionId) {
|
||||
try {
|
||||
await cancelSubtaskBreakdown(sessionId, projectId);
|
||||
await cancelSubtaskBreakdown(sessionId, projectId, sessionTabId);
|
||||
} catch {
|
||||
// ignore cancel errors
|
||||
}
|
||||
}
|
||||
resetState();
|
||||
onClose();
|
||||
}, [dirty, onClose, resetState, sessionId, view.type, projectId]);
|
||||
}, [dirty, onClose, resetState, sessionId, sessionTabId, view.type, projectId]);
|
||||
|
||||
const connectToSubtaskStream = useCallback(
|
||||
(activeSessionId: string) => {
|
||||
@@ -389,7 +397,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
connectToSubtaskStream(retrySessionId);
|
||||
|
||||
try {
|
||||
await retrySubtaskSession(retrySessionId, projectId);
|
||||
await retrySubtaskSession(retrySessionId, projectId, sessionTabId);
|
||||
} catch (err: any) {
|
||||
streamRef.current?.close();
|
||||
streamRef.current = null;
|
||||
@@ -402,7 +410,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
} finally {
|
||||
setIsRetrying(false);
|
||||
}
|
||||
}, [connectToSubtaskStream, projectId, view]);
|
||||
}, [connectToSubtaskStream, projectId, sessionTabId, view]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -678,6 +686,25 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLockedByOther && (
|
||||
<div className="session-lock-overlay" data-testid="session-lock-overlay">
|
||||
<div className="session-lock-banner">
|
||||
<Lock size={16} />
|
||||
<span>This session is active in another tab</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void takeControl();
|
||||
}}
|
||||
disabled={isLockLoading}
|
||||
className="btn btn-primary session-lock-take-control"
|
||||
>
|
||||
{isLockLoading ? "Taking control..." : "Take Control"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,9 @@ vi.mock("../../api", () => ({
|
||||
connectMissionInterviewStream: vi.fn(),
|
||||
fetchAiSession: vi.fn(),
|
||||
parseConversationHistory: vi.fn(),
|
||||
acquireSessionLock: vi.fn(),
|
||||
releaseSessionLock: vi.fn(),
|
||||
forceAcquireSessionLock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/modalPersistence", () => ({
|
||||
@@ -29,6 +32,9 @@ const mockCreateMissionFromInterview = vi.mocked(api.createMissionFromInterview)
|
||||
const mockConnectMissionInterviewStream = vi.mocked(api.connectMissionInterviewStream);
|
||||
const mockFetchAiSession = vi.mocked(api.fetchAiSession);
|
||||
const mockParseConversationHistory = vi.mocked(api.parseConversationHistory);
|
||||
const mockAcquireSessionLock = vi.mocked(api.acquireSessionLock);
|
||||
const mockReleaseSessionLock = vi.mocked(api.releaseSessionLock);
|
||||
const mockForceAcquireSessionLock = vi.mocked(api.forceAcquireSessionLock);
|
||||
const mockGetMissionGoal = vi.mocked(modalPersistence.getMissionGoal);
|
||||
|
||||
const sampleQuestionSingle: PlanningQuestion = {
|
||||
@@ -96,6 +102,9 @@ describe("MissionInterviewModal", () => {
|
||||
}
|
||||
});
|
||||
mockGetMissionGoal.mockReturnValue("");
|
||||
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
||||
mockReleaseSessionLock.mockResolvedValue(undefined);
|
||||
mockForceAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
||||
|
||||
mockConnectMissionInterviewStream.mockImplementation((_sessionId, _projectId, handlers) => {
|
||||
streamHandlers = handlers;
|
||||
@@ -236,6 +245,7 @@ describe("MissionInterviewModal", () => {
|
||||
"mission-session-1",
|
||||
{ scope: "mvp" },
|
||||
undefined,
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -367,7 +377,7 @@ describe("MissionInterviewModal", () => {
|
||||
await user.click(await screen.findByLabelText("Close"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCancelMissionInterview).toHaveBeenCalledWith("mission-session-1", undefined);
|
||||
expect(mockCancelMissionInterview).toHaveBeenCalledWith("mission-session-1", undefined, expect.any(String));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -51,6 +51,9 @@ const {
|
||||
mockConnectMissionInterviewStream,
|
||||
mockCancelMissionInterview,
|
||||
mockCreateMissionFromInterview,
|
||||
mockAcquireSessionLock,
|
||||
mockReleaseSessionLock,
|
||||
mockForceAcquireSessionLock,
|
||||
} = vi.hoisted(() => ({
|
||||
mockStartPlanningStreaming: vi.fn(),
|
||||
mockConnectPlanningStream: vi.fn(),
|
||||
@@ -65,6 +68,9 @@ const {
|
||||
mockConnectMissionInterviewStream: vi.fn(),
|
||||
mockCancelMissionInterview: vi.fn(),
|
||||
mockCreateMissionFromInterview: vi.fn(),
|
||||
mockAcquireSessionLock: vi.fn(),
|
||||
mockReleaseSessionLock: vi.fn(),
|
||||
mockForceAcquireSessionLock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
@@ -81,6 +87,9 @@ vi.mock("../../api", () => ({
|
||||
connectMissionInterviewStream: (...args: any[]) => mockConnectMissionInterviewStream(...args),
|
||||
cancelMissionInterview: (...args: any[]) => mockCancelMissionInterview(...args),
|
||||
createMissionFromInterview: (...args: any[]) => mockCreateMissionFromInterview(...args),
|
||||
acquireSessionLock: (...args: any[]) => mockAcquireSessionLock(...args),
|
||||
releaseSessionLock: (...args: any[]) => mockReleaseSessionLock(...args),
|
||||
forceAcquireSessionLock: (...args: any[]) => mockForceAcquireSessionLock(...args),
|
||||
fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }),
|
||||
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }),
|
||||
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||
@@ -131,6 +140,9 @@ describe("ModalReentry", () => {
|
||||
slices: [],
|
||||
features: [],
|
||||
});
|
||||
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
||||
mockReleaseSessionLock.mockResolvedValue(undefined);
|
||||
mockForceAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
||||
|
||||
vi.stubGlobal("confirm", vi.fn(() => true));
|
||||
});
|
||||
|
||||
143
packages/dashboard/app/hooks/useSessionLock.ts
Normal file
143
packages/dashboard/app/hooks/useSessionLock.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
acquireSessionLock,
|
||||
forceAcquireSessionLock,
|
||||
releaseSessionLock,
|
||||
type AiSessionSummary,
|
||||
} from "../api";
|
||||
import { getSessionTabId } from "../utils/getSessionTabId";
|
||||
|
||||
interface SessionLockState {
|
||||
isLockedByOther: boolean;
|
||||
currentHolder: string | null;
|
||||
takeControl: () => Promise<void>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function useSessionLock(sessionId: string | null): SessionLockState {
|
||||
const tabId = useMemo(() => getSessionTabId(), []);
|
||||
const [isLockedByOther, setIsLockedByOther] = useState(false);
|
||||
const [currentHolder, setCurrentHolder] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
setIsLockedByOther(false);
|
||||
setCurrentHolder(null);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
setIsLoading(true);
|
||||
|
||||
void Promise.resolve(acquireSessionLock(sessionId, tabId))
|
||||
.then((result) => {
|
||||
if (!active) return;
|
||||
|
||||
if (result.acquired) {
|
||||
setIsLockedByOther(false);
|
||||
setCurrentHolder(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLockedByOther(true);
|
||||
setCurrentHolder(result.currentHolder);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!active) return;
|
||||
setIsLockedByOther(false);
|
||||
setCurrentHolder(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!active) return;
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
try {
|
||||
const releaseResult = releaseSessionLock(sessionId, tabId) as Promise<void> | void;
|
||||
if (releaseResult && typeof releaseResult.catch === "function") {
|
||||
void releaseResult.catch(() => {
|
||||
// best-effort on unmount
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// best-effort on unmount
|
||||
}
|
||||
};
|
||||
}, [sessionId, tabId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleBeforeUnload = () => {
|
||||
if (typeof navigator.sendBeacon !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `/api/ai-sessions/${encodeURIComponent(sessionId)}/lock/beacon?tabId=${encodeURIComponent(tabId)}`;
|
||||
navigator.sendBeacon(url);
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
};
|
||||
}, [sessionId, tabId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId || typeof EventSource === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventSource = new EventSource("/api/events");
|
||||
|
||||
const handleUpdated = (event: MessageEvent<string>) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as AiSessionSummary;
|
||||
if (payload.id !== sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const holder = payload.lockedByTab ?? null;
|
||||
setCurrentHolder(holder);
|
||||
setIsLockedByOther(Boolean(holder && holder !== tabId));
|
||||
} catch {
|
||||
// ignore malformed events
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.addEventListener("ai_session:updated", handleUpdated as EventListener);
|
||||
|
||||
return () => {
|
||||
eventSource.removeEventListener("ai_session:updated", handleUpdated as EventListener);
|
||||
eventSource.close();
|
||||
};
|
||||
}, [sessionId, tabId]);
|
||||
|
||||
const takeControl = useCallback(async () => {
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await Promise.resolve(forceAcquireSessionLock(sessionId, tabId));
|
||||
setIsLockedByOther(false);
|
||||
setCurrentHolder(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [sessionId, tabId]);
|
||||
|
||||
return {
|
||||
isLockedByOther,
|
||||
currentHolder,
|
||||
takeControl,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
@@ -13605,6 +13605,7 @@ body[data-color-theme="terminal"][data-theme="light"]::before {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.planning-error {
|
||||
@@ -13630,6 +13631,33 @@ body[data-color-theme="terminal"][data-theme="light"]::before {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-lock-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 20%;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.session-lock-banner {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.session-lock-take-control {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Initial View */
|
||||
.planning-initial {
|
||||
display: flex;
|
||||
|
||||
25
packages/dashboard/app/utils/getSessionTabId.ts
Normal file
25
packages/dashboard/app/utils/getSessionTabId.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
const SESSION_TAB_ID_KEY = "fusion-tab-id";
|
||||
|
||||
function createTabId(): string {
|
||||
const cryptoApi = globalThis.crypto;
|
||||
if (cryptoApi && typeof cryptoApi.randomUUID === "function") {
|
||||
return cryptoApi.randomUUID();
|
||||
}
|
||||
|
||||
return `tab-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export function getSessionTabId(): string {
|
||||
if (typeof window === "undefined") {
|
||||
return "server-tab";
|
||||
}
|
||||
|
||||
const existing = window.sessionStorage.getItem(SESSION_TAB_ID_KEY);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const next = createTabId();
|
||||
window.sessionStorage.setItem(SESSION_TAB_ID_KEY, next);
|
||||
return next;
|
||||
}
|
||||
@@ -32,6 +32,8 @@ export interface AiSessionRow {
|
||||
projectId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lockedByTab: string | null;
|
||||
lockedAt: string | null;
|
||||
}
|
||||
|
||||
/** Summary returned by listActive (omits large fields) */
|
||||
@@ -41,6 +43,7 @@ export interface AiSessionSummary {
|
||||
status: AiSessionStatus;
|
||||
title: string;
|
||||
projectId: string | null;
|
||||
lockedByTab: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -81,8 +84,8 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO ai_sessions (id, type, status, title, inputPayload, conversationHistory, currentQuestion, result, thinkingOutput, error, projectId, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`INSERT INTO ai_sessions (id, type, status, title, inputPayload, conversationHistory, currentQuestion, result, thinkingOutput, error, projectId, createdAt, updatedAt, lockedByTab, lockedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
title = excluded.title,
|
||||
@@ -112,7 +115,10 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
|
||||
// Cancel any pending thinking debounce for this session
|
||||
this.clearThinkingTimer(session.id);
|
||||
|
||||
this.emit("ai_session:updated", toSummary(session, now));
|
||||
const row = this.get(session.id);
|
||||
if (row) {
|
||||
this.emit("ai_session:updated", toSummary(row, row.updatedAt));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,7 +200,7 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
|
||||
if (projectId) {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT id, type, status, title, projectId, updatedAt FROM ai_sessions
|
||||
`SELECT id, type, status, title, projectId, lockedByTab, updatedAt FROM ai_sessions
|
||||
WHERE status IN ('generating', 'awaiting_input', 'error') AND projectId = ?
|
||||
ORDER BY updatedAt DESC`,
|
||||
)
|
||||
@@ -202,7 +208,7 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
|
||||
}
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT id, type, status, title, projectId, updatedAt FROM ai_sessions
|
||||
`SELECT id, type, status, title, projectId, lockedByTab, updatedAt FROM ai_sessions
|
||||
WHERE status IN ('generating', 'awaiting_input', 'error')
|
||||
ORDER BY updatedAt DESC`,
|
||||
)
|
||||
@@ -233,6 +239,121 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
|
||||
.all() as unknown as AiSessionRow[];
|
||||
}
|
||||
|
||||
acquireLock(sessionId: string, tabId: string): { acquired: boolean; currentHolder: string | null } {
|
||||
const now = new Date().toISOString();
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`UPDATE ai_sessions
|
||||
SET lockedByTab = ?, lockedAt = ?
|
||||
WHERE id = ? AND (lockedByTab IS NULL OR lockedByTab = ?)`,
|
||||
)
|
||||
.run(tabId, now, sessionId, tabId) as { changes?: number };
|
||||
|
||||
const acquired = Number(result.changes ?? 0) > 0;
|
||||
if (acquired) {
|
||||
const row = this.get(sessionId);
|
||||
if (row) {
|
||||
this.emit("ai_session:updated", toSummary(row, row.updatedAt));
|
||||
}
|
||||
return { acquired: true, currentHolder: null };
|
||||
}
|
||||
|
||||
const holder = this.db
|
||||
.prepare("SELECT lockedByTab FROM ai_sessions WHERE id = ?")
|
||||
.get(sessionId) as { lockedByTab: string | null } | undefined;
|
||||
|
||||
return {
|
||||
acquired: false,
|
||||
currentHolder: holder?.lockedByTab ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
releaseLock(sessionId: string, tabId: string): boolean {
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`UPDATE ai_sessions
|
||||
SET lockedByTab = NULL, lockedAt = NULL
|
||||
WHERE id = ? AND lockedByTab = ?`,
|
||||
)
|
||||
.run(sessionId, tabId) as { changes?: number };
|
||||
|
||||
const released = Number(result.changes ?? 0) > 0;
|
||||
if (!released) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const row = this.get(sessionId);
|
||||
if (row) {
|
||||
this.emit("ai_session:updated", toSummary(row, row.updatedAt));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
forceAcquireLock(sessionId: string, tabId: string): void {
|
||||
const now = new Date().toISOString();
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`UPDATE ai_sessions
|
||||
SET lockedByTab = ?, lockedAt = ?
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.run(tabId, now, sessionId) as { changes?: number };
|
||||
|
||||
if (Number(result.changes ?? 0) === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const row = this.get(sessionId);
|
||||
if (row) {
|
||||
this.emit("ai_session:updated", toSummary(row, row.updatedAt));
|
||||
}
|
||||
}
|
||||
|
||||
getLockHolder(sessionId: string): { tabId: string | null; lockedAt: string | null } {
|
||||
const row = this.db
|
||||
.prepare("SELECT lockedByTab, lockedAt FROM ai_sessions WHERE id = ?")
|
||||
.get(sessionId) as { lockedByTab: string | null; lockedAt: string | null } | undefined;
|
||||
|
||||
return {
|
||||
tabId: row?.lockedByTab ?? null,
|
||||
lockedAt: row?.lockedAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
releaseStaleLocks(maxAgeMs = 30 * 60 * 1000): number {
|
||||
const cutoff = new Date(Date.now() - maxAgeMs).toISOString();
|
||||
const staleRows = this.db
|
||||
.prepare(
|
||||
`SELECT id FROM ai_sessions
|
||||
WHERE lockedByTab IS NOT NULL
|
||||
AND lockedAt < ?`,
|
||||
)
|
||||
.all(cutoff) as Array<{ id: string }>;
|
||||
|
||||
if (staleRows.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`UPDATE ai_sessions
|
||||
SET lockedByTab = NULL, lockedAt = NULL
|
||||
WHERE lockedByTab IS NOT NULL
|
||||
AND lockedAt < ?`,
|
||||
)
|
||||
.run(cutoff) as { changes?: number };
|
||||
|
||||
for (const rowInfo of staleRows) {
|
||||
const row = this.get(rowInfo.id);
|
||||
if (row) {
|
||||
this.emit("ai_session:updated", toSummary(row, row.updatedAt));
|
||||
}
|
||||
}
|
||||
|
||||
return Number(result.changes ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session by ID. Emits `ai_session:deleted`.
|
||||
*/
|
||||
@@ -392,6 +513,7 @@ function toSummary(session: AiSessionRow, updatedAt: string): AiSessionSummary {
|
||||
status: session.status,
|
||||
title: session.title,
|
||||
projectId: session.projectId,
|
||||
lockedByTab: session.lockedByTab ?? null,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -447,11 +447,14 @@ function createMockMissionAutopilot() {
|
||||
function buildApp(options?: {
|
||||
missionAutopilot?: ReturnType<typeof createMockMissionAutopilot>;
|
||||
withErrorHandler?: boolean;
|
||||
aiSessionStore?: {
|
||||
acquireLock(sessionId: string, tabId: string): { acquired: boolean; currentHolder: string | null };
|
||||
};
|
||||
}) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const store = createMockStore();
|
||||
app.use("/api/missions", createMissionRouter(store, options?.missionAutopilot));
|
||||
app.use("/api/missions", createMissionRouter(store, options?.missionAutopilot, options?.aiSessionStore as any));
|
||||
|
||||
if (options?.withErrorHandler) {
|
||||
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
@@ -1796,6 +1799,131 @@ describe("Mission API", () => {
|
||||
expect(res.body.error).toContain("sessionId");
|
||||
});
|
||||
|
||||
it("returns 409 when interview respond is locked by another tab", async () => {
|
||||
const submitSpy = vi.spyOn(missionInterviewModule, "submitMissionInterviewResponse");
|
||||
|
||||
const { app } = buildApp({
|
||||
aiSessionStore: {
|
||||
acquireLock: () => ({ acquired: false, currentHolder: "tab-owner" }),
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/missions/interview/respond",
|
||||
JSON.stringify({
|
||||
sessionId: "session-locked",
|
||||
responses: { "q-1": "answer" },
|
||||
tabId: "tab-other",
|
||||
}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body).toEqual({
|
||||
error: "Session locked by another tab",
|
||||
lockedByTab: "tab-owner",
|
||||
});
|
||||
expect(submitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 409 when interview cancel is locked by another tab", async () => {
|
||||
const cancelSpy = vi.spyOn(missionInterviewModule, "cancelMissionInterviewSession");
|
||||
|
||||
const { app } = buildApp({
|
||||
aiSessionStore: {
|
||||
acquireLock: () => ({ acquired: false, currentHolder: "tab-owner" }),
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/missions/interview/cancel",
|
||||
JSON.stringify({
|
||||
sessionId: "session-locked",
|
||||
tabId: "tab-other",
|
||||
}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body).toEqual({
|
||||
error: "Session locked by another tab",
|
||||
lockedByTab: "tab-owner",
|
||||
});
|
||||
expect(cancelSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 409 when interview retry is locked by another tab", async () => {
|
||||
const retrySpy = vi.spyOn(missionInterviewModule, "retryMissionInterviewSession");
|
||||
|
||||
const { app } = buildApp({
|
||||
aiSessionStore: {
|
||||
acquireLock: () => ({ acquired: false, currentHolder: "tab-owner" }),
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/missions/interview/session-locked/retry",
|
||||
JSON.stringify({ tabId: "tab-other" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body).toEqual({
|
||||
error: "Session locked by another tab",
|
||||
lockedByTab: "tab-owner",
|
||||
});
|
||||
expect(retrySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows interview respond/cancel/retry when tabId is omitted", async () => {
|
||||
vi.spyOn(missionInterviewModule, "submitMissionInterviewResponse").mockResolvedValueOnce({
|
||||
type: "question",
|
||||
data: {
|
||||
id: "q-next",
|
||||
type: "text",
|
||||
question: "next",
|
||||
description: "next",
|
||||
},
|
||||
} as any);
|
||||
vi.spyOn(missionInterviewModule, "cancelMissionInterviewSession").mockResolvedValueOnce(undefined);
|
||||
vi.spyOn(missionInterviewModule, "retryMissionInterviewSession").mockResolvedValueOnce(undefined);
|
||||
|
||||
const { app } = buildApp({
|
||||
aiSessionStore: {
|
||||
acquireLock: () => ({ acquired: false, currentHolder: "tab-owner" }),
|
||||
},
|
||||
});
|
||||
|
||||
const respondRes = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/missions/interview/respond",
|
||||
JSON.stringify({ sessionId: "session-open", responses: { "q-1": "answer" } }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
expect(respondRes.status).toBe(200);
|
||||
|
||||
const cancelRes = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/missions/interview/cancel",
|
||||
JSON.stringify({ sessionId: "session-open" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
expect(cancelRes.status).toBe(200);
|
||||
expect(cancelRes.body).toEqual({ success: true });
|
||||
|
||||
const retryRes = await request(app, "POST", "/api/missions/interview/session-open/retry");
|
||||
expect(retryRes.status).toBe(200);
|
||||
expect(retryRes.body).toEqual({ success: true, sessionId: "session-open" });
|
||||
});
|
||||
|
||||
it("retries a failed interview session", async () => {
|
||||
const retrySpy = vi
|
||||
.spyOn(missionInterviewModule, "retryMissionInterviewSession")
|
||||
|
||||
@@ -262,6 +262,8 @@ function persistMissionSession(session: MissionInterviewSession, status: "genera
|
||||
projectId: null,
|
||||
createdAt: session.createdAt.toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
lockedByTab: null,
|
||||
lockedAt: null,
|
||||
};
|
||||
_aiSessionStore.upsert(row);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
notFound,
|
||||
rateLimited,
|
||||
} from "./api-error.js";
|
||||
import type { AiSessionStore } from "./ai-session-store.js";
|
||||
|
||||
// ── Validation Utilities ────────────────────────────────────────────────────
|
||||
|
||||
@@ -175,6 +176,23 @@ function replayBufferedSSE(
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkSessionLock(
|
||||
sessionId: string,
|
||||
tabId: string | undefined,
|
||||
store: AiSessionStore | undefined,
|
||||
): { allowed: true } | { allowed: false; currentHolder: string | null } {
|
||||
if (!tabId || !store) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
const result = store.acquireLock(sessionId, tabId);
|
||||
if (result.acquired) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
return { allowed: false, currentHolder: result.currentHolder };
|
||||
}
|
||||
|
||||
export function createMissionRouter(
|
||||
store: TaskStore,
|
||||
missionAutopilot?: {
|
||||
@@ -186,6 +204,7 @@ export function createMissionRouter(
|
||||
start(): void;
|
||||
stop(): void;
|
||||
},
|
||||
aiSessionStore?: AiSessionStore,
|
||||
): Router {
|
||||
const router = Router();
|
||||
const requestContext = new AsyncLocalStorage<ReturnType<TaskStore["getMissionStore"]>>();
|
||||
@@ -344,7 +363,7 @@ export function createMissionRouter(
|
||||
router.post(
|
||||
"/interview/respond",
|
||||
catchTypedHandler(async (req, res) => {
|
||||
const { sessionId, responses } = req.body;
|
||||
const { sessionId, responses, tabId } = req.body;
|
||||
|
||||
if (!sessionId || typeof sessionId !== "string") {
|
||||
throw badRequest("sessionId is required");
|
||||
@@ -354,6 +373,16 @@ export function createMissionRouter(
|
||||
throw badRequest("responses is required and must be an object");
|
||||
}
|
||||
|
||||
const normalizedTabId = typeof tabId === "string" && tabId.trim().length > 0 ? tabId.trim() : undefined;
|
||||
const lockCheck = checkSessionLock(sessionId, normalizedTabId, aiSessionStore);
|
||||
if (!lockCheck.allowed) {
|
||||
res.status(409).json({
|
||||
error: "Session locked by another tab",
|
||||
lockedByTab: lockCheck.currentHolder,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
submitMissionInterviewResponse,
|
||||
@@ -389,6 +418,18 @@ export function createMissionRouter(
|
||||
throw badRequest("sessionId is required");
|
||||
}
|
||||
|
||||
const tabId = typeof req.body?.tabId === "string" && req.body.tabId.trim().length > 0
|
||||
? req.body.tabId.trim()
|
||||
: undefined;
|
||||
const lockCheck = checkSessionLock(sessionId, tabId, aiSessionStore);
|
||||
if (!lockCheck.allowed) {
|
||||
res.status(409).json({
|
||||
error: "Session locked by another tab",
|
||||
lockedByTab: lockCheck.currentHolder,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
retryMissionInterviewSession,
|
||||
@@ -419,12 +460,22 @@ export function createMissionRouter(
|
||||
router.post(
|
||||
"/interview/cancel",
|
||||
catchTypedHandler(async (req, res) => {
|
||||
const { sessionId } = req.body;
|
||||
const { sessionId, tabId } = req.body;
|
||||
|
||||
if (!sessionId || typeof sessionId !== "string") {
|
||||
throw badRequest("sessionId is required");
|
||||
}
|
||||
|
||||
const normalizedTabId = typeof tabId === "string" && tabId.trim().length > 0 ? tabId.trim() : undefined;
|
||||
const lockCheck = checkSessionLock(sessionId, normalizedTabId, aiSessionStore);
|
||||
if (!lockCheck.allowed) {
|
||||
res.status(409).json({
|
||||
error: "Session locked by another tab",
|
||||
lockedByTab: lockCheck.currentHolder,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
cancelMissionInterviewSession,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import express from "express";
|
||||
import { Database, TaskStore } from "@fusion/core";
|
||||
import {
|
||||
createSession,
|
||||
createSessionWithAgent,
|
||||
@@ -25,8 +31,10 @@ import {
|
||||
formatInterviewQA,
|
||||
SESSION_TTL_MS,
|
||||
} from "./planning.js";
|
||||
import { createApiRoutes } from "./routes.js";
|
||||
import { request, get } from "./test-request.js";
|
||||
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
||||
import type { AiSessionRow } from "./ai-session-store.js";
|
||||
import { AiSessionStore, type AiSessionRow } from "./ai-session-store.js";
|
||||
|
||||
// ── Mock Agent Factory ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -1384,3 +1392,346 @@ describe("planning module", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AiSessionStore locking", () => {
|
||||
let tmpRoot: string;
|
||||
let db: Database;
|
||||
let store: AiSessionStore;
|
||||
|
||||
function makeSessionRow(
|
||||
id: string,
|
||||
status: AiSessionRow["status"] = "awaiting_input",
|
||||
): AiSessionRow {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id,
|
||||
type: "planning",
|
||||
status,
|
||||
title: `Session ${id}`,
|
||||
inputPayload: JSON.stringify({ initialPlan: "Locking test" }),
|
||||
conversationHistory: "[]",
|
||||
currentQuestion: null,
|
||||
result: null,
|
||||
thinkingOutput: "",
|
||||
error: null,
|
||||
projectId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lockedByTab: null,
|
||||
lockedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = mkdtempSync(join(tmpdir(), "kb-session-lock-"));
|
||||
db = new Database(join(tmpRoot, ".fusion"));
|
||||
db.init();
|
||||
store = new AiSessionStore(db);
|
||||
store.upsert(makeSessionRow("session-lock-1"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
store.stopScheduledCleanup();
|
||||
try {
|
||||
db.close();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
await rm(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("acquires lock, detects conflicts, and allows re-entrant acquire", () => {
|
||||
const firstAcquire = store.acquireLock("session-lock-1", "tab-a");
|
||||
expect(firstAcquire).toEqual({ acquired: true, currentHolder: null });
|
||||
|
||||
const holderAfterAcquire = store.getLockHolder("session-lock-1");
|
||||
expect(holderAfterAcquire.tabId).toBe("tab-a");
|
||||
expect(holderAfterAcquire.lockedAt).toBeTruthy();
|
||||
|
||||
const conflict = store.acquireLock("session-lock-1", "tab-b");
|
||||
expect(conflict).toEqual({ acquired: false, currentHolder: "tab-a" });
|
||||
|
||||
const reentrant = store.acquireLock("session-lock-1", "tab-a");
|
||||
expect(reentrant).toEqual({ acquired: true, currentHolder: null });
|
||||
expect(store.getLockHolder("session-lock-1").tabId).toBe("tab-a");
|
||||
});
|
||||
|
||||
it("releases locks only for the current owner", () => {
|
||||
store.acquireLock("session-lock-1", "tab-a");
|
||||
|
||||
const nonOwnerRelease = store.releaseLock("session-lock-1", "tab-b");
|
||||
expect(nonOwnerRelease).toBe(false);
|
||||
expect(store.getLockHolder("session-lock-1").tabId).toBe("tab-a");
|
||||
|
||||
const ownerRelease = store.releaseLock("session-lock-1", "tab-a");
|
||||
expect(ownerRelease).toBe(true);
|
||||
expect(store.getLockHolder("session-lock-1")).toEqual({ tabId: null, lockedAt: null });
|
||||
});
|
||||
|
||||
it("force acquires lock and clears stale locks", () => {
|
||||
store.acquireLock("session-lock-1", "tab-a");
|
||||
|
||||
store.forceAcquireLock("session-lock-1", "tab-b");
|
||||
expect(store.getLockHolder("session-lock-1").tabId).toBe("tab-b");
|
||||
|
||||
const staleTimestamp = new Date(Date.now() - 35 * 60 * 1000).toISOString();
|
||||
db.prepare("UPDATE ai_sessions SET lockedAt = ? WHERE id = ?").run(staleTimestamp, "session-lock-1");
|
||||
|
||||
const releasedCount = store.releaseStaleLocks();
|
||||
expect(releasedCount).toBe(1);
|
||||
expect(store.getLockHolder("session-lock-1")).toEqual({ tabId: null, lockedAt: null });
|
||||
});
|
||||
|
||||
it("emits ai_session:updated events on lock changes", () => {
|
||||
const onUpdated = vi.fn();
|
||||
store.on("ai_session:updated", onUpdated);
|
||||
|
||||
store.acquireLock("session-lock-1", "tab-a");
|
||||
store.releaseLock("session-lock-1", "tab-a");
|
||||
store.forceAcquireLock("session-lock-1", "tab-b");
|
||||
|
||||
const staleTimestamp = new Date(Date.now() - 35 * 60 * 1000).toISOString();
|
||||
db.prepare("UPDATE ai_sessions SET lockedAt = ? WHERE id = ?").run(staleTimestamp, "session-lock-1");
|
||||
store.releaseStaleLocks();
|
||||
|
||||
expect(onUpdated).toHaveBeenCalled();
|
||||
|
||||
const emittedLocks = onUpdated.mock.calls
|
||||
.map(([summary]) => summary.lockedByTab)
|
||||
.filter((value) => value !== undefined);
|
||||
|
||||
expect(emittedLocks).toContain("tab-a");
|
||||
expect(emittedLocks).toContain("tab-b");
|
||||
expect(emittedLocks).toContain(null);
|
||||
});
|
||||
|
||||
it("preserves lock state in upsert update events", () => {
|
||||
store.acquireLock("session-lock-1", "tab-a");
|
||||
|
||||
const onUpdated = vi.fn();
|
||||
store.on("ai_session:updated", onUpdated);
|
||||
|
||||
store.upsert({
|
||||
...makeSessionRow("session-lock-1", "generating"),
|
||||
lockedByTab: null,
|
||||
lockedAt: null,
|
||||
});
|
||||
|
||||
const latestSummary = onUpdated.mock.calls.at(-1)?.[0];
|
||||
expect(latestSummary?.lockedByTab).toBe("tab-a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("planning routes lock enforcement", () => {
|
||||
let tmpRoot: string;
|
||||
let taskStore: TaskStore;
|
||||
let db: Database;
|
||||
let aiSessionStore: AiSessionStore;
|
||||
let app: express.Express;
|
||||
|
||||
function makePersistedRow(id: string, type: AiSessionRow["type"] = "planning"): AiSessionRow {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
status: "awaiting_input",
|
||||
title: `Session ${id}`,
|
||||
inputPayload: JSON.stringify({ initialPlan: "Route lock test" }),
|
||||
conversationHistory: "[]",
|
||||
currentQuestion: null,
|
||||
result: null,
|
||||
thinkingOutput: "",
|
||||
error: null,
|
||||
projectId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lockedByTab: null,
|
||||
lockedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
__resetPlanningState();
|
||||
setupMockAgent();
|
||||
|
||||
tmpRoot = mkdtempSync(join(tmpdir(), "kb-planning-lock-routes-"));
|
||||
taskStore = new TaskStore(tmpRoot);
|
||||
await taskStore.init();
|
||||
|
||||
db = new Database(join(tmpRoot, ".fusion-locks"));
|
||||
db.init();
|
||||
aiSessionStore = new AiSessionStore(db);
|
||||
setAiSessionStore(aiSessionStore as any);
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(taskStore, { aiSessionStore }));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
__setCreateKbAgent(undefined as any);
|
||||
__resetPlanningState();
|
||||
|
||||
try {
|
||||
taskStore.close();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
|
||||
try {
|
||||
db.close();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
|
||||
await rm(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("acquires and releases locks via API routes", async () => {
|
||||
aiSessionStore.upsert(makePersistedRow("session-route-lock"));
|
||||
|
||||
const acquire = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/ai-sessions/session-route-lock/lock",
|
||||
JSON.stringify({ tabId: "tab-a" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
expect(acquire.status).toBe(200);
|
||||
expect(acquire.body).toEqual({ acquired: true });
|
||||
|
||||
const conflictAcquire = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/ai-sessions/session-route-lock/lock",
|
||||
JSON.stringify({ tabId: "tab-b" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
expect(conflictAcquire.status).toBe(200);
|
||||
expect(conflictAcquire.body).toEqual({ acquired: false, currentHolder: "tab-a" });
|
||||
|
||||
const release = await request(
|
||||
app,
|
||||
"DELETE",
|
||||
"/api/ai-sessions/session-route-lock/lock",
|
||||
JSON.stringify({ tabId: "tab-a" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
expect(release.status).toBe(200);
|
||||
expect(release.body).toEqual({ success: true });
|
||||
|
||||
const forceAcquire = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/ai-sessions/session-route-lock/lock/force",
|
||||
JSON.stringify({ tabId: "tab-c" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
expect(forceAcquire.status).toBe(200);
|
||||
expect(forceAcquire.body).toEqual({ success: true });
|
||||
|
||||
const beaconRelease = await request(
|
||||
app,
|
||||
"DELETE",
|
||||
"/api/ai-sessions/session-route-lock/lock/beacon?tabId=tab-c",
|
||||
);
|
||||
expect(beaconRelease.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 409 for planning/respond when another tab holds the lock and allows legacy requests without tabId", async () => {
|
||||
const { sessionId } = await createSession(getUniqueIp(), "Route lock planning", taskStore, tmpRoot);
|
||||
aiSessionStore.acquireLock(sessionId, "tab-owner");
|
||||
|
||||
const conflictResponse = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/planning/respond",
|
||||
JSON.stringify({ sessionId, responses: { "q-scope": "small" }, tabId: "tab-other" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(conflictResponse.status).toBe(409);
|
||||
expect(conflictResponse.body).toEqual({
|
||||
error: "Session locked by another tab",
|
||||
lockedByTab: "tab-owner",
|
||||
});
|
||||
|
||||
const legacyResponse = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/planning/respond",
|
||||
JSON.stringify({ sessionId, responses: { "q-scope": "small" } }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(legacyResponse.status).toBe(200);
|
||||
expect((legacyResponse.body as { type: string }).type).toBe("question");
|
||||
});
|
||||
|
||||
it("returns 409 for subtasks/cancel when lock is held by another tab", async () => {
|
||||
aiSessionStore.upsert(makePersistedRow("subtask-route-lock", "subtask"));
|
||||
aiSessionStore.acquireLock("subtask-route-lock", "tab-a");
|
||||
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/subtasks/cancel",
|
||||
JSON.stringify({ sessionId: "subtask-route-lock", tabId: "tab-b" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(response.body).toEqual({
|
||||
error: "Session locked by another tab",
|
||||
lockedByTab: "tab-a",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 409 for retry endpoints when lock is held by another tab", async () => {
|
||||
aiSessionStore.upsert(makePersistedRow("planning-route-retry", "planning"));
|
||||
aiSessionStore.acquireLock("planning-route-retry", "tab-a");
|
||||
|
||||
const planningRetry = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/planning/planning-route-retry/retry",
|
||||
JSON.stringify({ tabId: "tab-b" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
expect(planningRetry.status).toBe(409);
|
||||
expect(planningRetry.body).toEqual({
|
||||
error: "Session locked by another tab",
|
||||
lockedByTab: "tab-a",
|
||||
});
|
||||
|
||||
aiSessionStore.upsert(makePersistedRow("subtask-route-retry", "subtask"));
|
||||
aiSessionStore.acquireLock("subtask-route-retry", "tab-a");
|
||||
|
||||
const subtaskRetry = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/subtasks/subtask-route-retry/retry",
|
||||
JSON.stringify({ tabId: "tab-b" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
expect(subtaskRetry.status).toBe(409);
|
||||
expect(subtaskRetry.body).toEqual({
|
||||
error: "Session locked by another tab",
|
||||
lockedByTab: "tab-a",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps planning SSE stream read-only and unaffected by locks", async () => {
|
||||
const { sessionId } = await createSession(getUniqueIp(), "SSE lock check", taskStore, tmpRoot);
|
||||
await submitResponse(sessionId, { "q-scope": "small" }, tmpRoot);
|
||||
await submitResponse(sessionId, { "q-requirements": "Need auth" }, tmpRoot);
|
||||
await submitResponse(sessionId, { "q-confirm": true }, tmpRoot);
|
||||
|
||||
aiSessionStore.acquireLock(sessionId, "tab-owner");
|
||||
|
||||
const streamResponse = await get(app, `/api/planning/${sessionId}/stream`);
|
||||
expect(streamResponse.status).toBe(200);
|
||||
expect(String(streamResponse.body)).toContain("event: summary");
|
||||
expect(String(streamResponse.body)).toContain("event: complete");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -236,6 +236,8 @@ function persistSession(session: Session, status: "generating" | "awaiting_input
|
||||
projectId: projectId ?? null,
|
||||
createdAt: session.createdAt.toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
lockedByTab: null,
|
||||
lockedAt: null,
|
||||
};
|
||||
_aiSessionStore.upsert(row);
|
||||
}
|
||||
|
||||
@@ -1350,6 +1350,23 @@ function replayBufferedSSE(
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkSessionLock(
|
||||
sessionId: string,
|
||||
tabId: string | undefined,
|
||||
store: AiSessionStore | undefined,
|
||||
): { allowed: true } | { allowed: false; currentHolder: string | null } {
|
||||
if (!tabId || !store) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
const result = store.acquireLock(sessionId, tabId);
|
||||
if (result.acquired) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
return { allowed: false, currentHolder: result.currentHolder };
|
||||
}
|
||||
|
||||
export function createApiRoutes(store: TaskStore, options?: ServerOptions): Router {
|
||||
const router = Router();
|
||||
|
||||
@@ -1424,6 +1441,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
// HeartbeatMonitor for triggering agent execution runs
|
||||
const heartbeatMonitor = options?.heartbeatMonitor;
|
||||
const hasHeartbeatExecutor = Boolean(heartbeatMonitor);
|
||||
const aiSessionStore = options?.aiSessionStore;
|
||||
|
||||
// Scheduler config (includes persisted settings)
|
||||
router.get("/config", async (req, res) => {
|
||||
@@ -5843,11 +5861,21 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
|
||||
router.post("/subtasks/cancel", async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.body;
|
||||
const { sessionId, tabId } = req.body;
|
||||
if (!sessionId || typeof sessionId !== "string") {
|
||||
throw badRequest("sessionId is required");
|
||||
}
|
||||
|
||||
const normalizedTabId = typeof tabId === "string" && tabId.trim().length > 0 ? tabId.trim() : undefined;
|
||||
const lockCheck = checkSessionLock(sessionId, normalizedTabId, aiSessionStore);
|
||||
if (!lockCheck.allowed) {
|
||||
res.status(409).json({
|
||||
error: "Session locked by another tab",
|
||||
lockedByTab: lockCheck.currentHolder,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { cancelSubtaskSession } = await import("./subtask-breakdown.js");
|
||||
await cancelSubtaskSession(sessionId);
|
||||
res.json({ success: true });
|
||||
@@ -5870,6 +5898,18 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
throw badRequest("sessionId is required");
|
||||
}
|
||||
|
||||
const tabId = typeof req.body?.tabId === "string" && req.body.tabId.trim().length > 0
|
||||
? req.body.tabId.trim()
|
||||
: undefined;
|
||||
const lockCheck = checkSessionLock(sessionId, tabId, aiSessionStore);
|
||||
if (!lockCheck.allowed) {
|
||||
res.status(409).json({
|
||||
error: "Session locked by another tab",
|
||||
lockedByTab: lockCheck.currentHolder,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { retrySubtaskSession } = await import("./subtask-breakdown.js");
|
||||
await retrySubtaskSession(sessionId, scopedStore.getRootDir());
|
||||
@@ -5987,7 +6027,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
*/
|
||||
router.post("/planning/respond", async (req, res) => {
|
||||
try {
|
||||
const { sessionId, responses } = req.body;
|
||||
const { sessionId, responses, tabId } = req.body;
|
||||
|
||||
if (!sessionId || typeof sessionId !== "string") {
|
||||
throw badRequest("sessionId is required");
|
||||
@@ -5997,6 +6037,16 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
throw badRequest("responses is required and must be an object");
|
||||
}
|
||||
|
||||
const normalizedTabId = typeof tabId === "string" && tabId.trim().length > 0 ? tabId.trim() : undefined;
|
||||
const lockCheck = checkSessionLock(sessionId, normalizedTabId, aiSessionStore);
|
||||
if (!lockCheck.allowed) {
|
||||
res.status(409).json({
|
||||
error: "Session locked by another tab",
|
||||
lockedByTab: lockCheck.currentHolder,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { submitResponse, SessionNotFoundError, InvalidSessionStateError } = await import("./planning.js");
|
||||
const result = await submitResponse(sessionId, responses, store.getRootDir());
|
||||
res.json(result);
|
||||
@@ -6021,6 +6071,18 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
throw badRequest("sessionId is required");
|
||||
}
|
||||
|
||||
const tabId = typeof req.body?.tabId === "string" && req.body.tabId.trim().length > 0
|
||||
? req.body.tabId.trim()
|
||||
: undefined;
|
||||
const lockCheck = checkSessionLock(sessionId, tabId, aiSessionStore);
|
||||
if (!lockCheck.allowed) {
|
||||
res.status(409).json({
|
||||
error: "Session locked by another tab",
|
||||
lockedByTab: lockCheck.currentHolder,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { retrySession } = await import("./planning.js");
|
||||
await retrySession(sessionId, scopedStore.getRootDir());
|
||||
@@ -6046,12 +6108,22 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
*/
|
||||
router.post("/planning/cancel", async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.body;
|
||||
const { sessionId, tabId } = req.body;
|
||||
|
||||
if (!sessionId || typeof sessionId !== "string") {
|
||||
throw badRequest("sessionId is required");
|
||||
}
|
||||
|
||||
const normalizedTabId = typeof tabId === "string" && tabId.trim().length > 0 ? tabId.trim() : undefined;
|
||||
const lockCheck = checkSessionLock(sessionId, normalizedTabId, aiSessionStore);
|
||||
if (!lockCheck.allowed) {
|
||||
res.status(409).json({
|
||||
error: "Session locked by another tab",
|
||||
lockedByTab: lockCheck.currentHolder,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { cancelSession, SessionNotFoundError } = await import("./planning.js");
|
||||
await cancelSession(sessionId);
|
||||
res.json({ success: true });
|
||||
@@ -8942,12 +9014,10 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
|
||||
// ── Mission Routes ─────────────────────────────────────────────────────────
|
||||
// Mount mission routes at /api/missions
|
||||
router.use("/missions", createMissionRouter(store, options?.missionAutopilot));
|
||||
router.use("/missions", createMissionRouter(store, options?.missionAutopilot, aiSessionStore));
|
||||
|
||||
// ── AI Session Routes (Background Tasks) ─────────────────────────────────
|
||||
|
||||
const aiSessionStore = options?.aiSessionStore;
|
||||
|
||||
/**
|
||||
* GET /api/ai-sessions
|
||||
* List active background AI sessions (generating or awaiting_input).
|
||||
@@ -8978,6 +9048,80 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
res.json(session);
|
||||
});
|
||||
|
||||
router.post("/ai-sessions/:id/lock", (req, res) => {
|
||||
if (!aiSessionStore) {
|
||||
throw notFound("AI sessions not available");
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const session = aiSessionStore.get(id);
|
||||
if (!session) {
|
||||
throw notFound("Session not found");
|
||||
}
|
||||
|
||||
const tabId = typeof req.body?.tabId === "string" ? req.body.tabId.trim() : "";
|
||||
if (!tabId) {
|
||||
throw badRequest("tabId is required");
|
||||
}
|
||||
|
||||
const result = aiSessionStore.acquireLock(id, tabId);
|
||||
if (!result.acquired) {
|
||||
res.json({ acquired: false, currentHolder: result.currentHolder });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ acquired: true });
|
||||
});
|
||||
|
||||
router.delete("/ai-sessions/:id/lock", (req, res) => {
|
||||
if (!aiSessionStore) {
|
||||
throw notFound("AI sessions not available");
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const tabId = typeof req.body?.tabId === "string" ? req.body.tabId.trim() : "";
|
||||
if (!tabId) {
|
||||
throw badRequest("tabId is required");
|
||||
}
|
||||
|
||||
aiSessionStore.releaseLock(id, tabId);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.post("/ai-sessions/:id/lock/force", (req, res) => {
|
||||
if (!aiSessionStore) {
|
||||
throw notFound("AI sessions not available");
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const session = aiSessionStore.get(id);
|
||||
if (!session) {
|
||||
throw notFound("Session not found");
|
||||
}
|
||||
|
||||
const tabId = typeof req.body?.tabId === "string" ? req.body.tabId.trim() : "";
|
||||
if (!tabId) {
|
||||
throw badRequest("tabId is required");
|
||||
}
|
||||
|
||||
aiSessionStore.forceAcquireLock(id, tabId);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.delete("/ai-sessions/:id/lock/beacon", (req, res) => {
|
||||
if (!aiSessionStore) {
|
||||
throw notFound("AI sessions not available");
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const tabId = typeof req.query.tabId === "string" ? req.query.tabId.trim() : "";
|
||||
if (tabId) {
|
||||
aiSessionStore.releaseLock(id, tabId);
|
||||
}
|
||||
|
||||
res.status(200).end();
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/ai-sessions/:id/ping
|
||||
* Lightweight keep-alive touch for active AI sessions.
|
||||
|
||||
@@ -196,6 +196,8 @@ function persistSubtaskSession(session: SubtaskInternalSession, status: "generat
|
||||
projectId: null,
|
||||
createdAt: session.createdAt.toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
lockedByTab: null,
|
||||
lockedAt: null,
|
||||
};
|
||||
_aiSessionStore.upsert(row);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user