fix(FN-1250): route task-creation endpoints through scoped project stores

- Resolve projectId-scoped TaskStore instances for GitHub issue/PR import and batch import mutations
- Use scoped stores for planning and subtask breakdown create flows, including rootDir lookups, dependency updates, and parent cleanup
- Add regression tests to verify projectId requests mutate only the scoped store across import, planning, and subtask routes
This commit is contained in:
gsxdsm
2026-04-08 11:19:43 -07:00
parent 2ee3b1d55c
commit 2ef1fb0f72
2 changed files with 310 additions and 26 deletions

View File

@@ -16,7 +16,10 @@ import type { TaskDetail } from "@fusion/core";
import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
import { __resetBatchImportRateLimiter, __setCreateKbAgentForRefine } from "./routes.js";
import { __resetPlanningState, __setCreateKbAgent, planningStreamManager } from "./planning.js";
import * as planningModule from "./planning.js";
import { __resetSubtaskBreakdownState, subtaskStreamManager } from "./subtask-breakdown.js";
import * as subtaskBreakdownModule from "./subtask-breakdown.js";
import * as projectStoreResolver from "./project-store-resolver.js";
import * as terminalServiceModule from "./terminal-service.js";
import { get as performGet, request as performRequest } from "./test-request.js";
@@ -4233,6 +4236,278 @@ describe("POST /github/issues/batch-import", () => {
});
});
describe("projectId store scoping regressions", () => {
const projectId = "proj-scoped";
let defaultStore: TaskStore;
let scopedStore: TaskStore;
beforeEach(() => {
__resetBatchImportRateLimiter();
__resetPlanningState();
__resetSubtaskBreakdownState();
mockIsGhAuthenticated.mockReturnValue(true);
defaultStore = createMockStore({
listTasks: vi.fn().mockResolvedValue([]),
createTask: vi.fn(),
updateTask: vi.fn(),
logEntry: vi.fn().mockResolvedValue(undefined),
getTask: vi.fn(),
deleteTask: vi.fn(),
getRootDir: vi.fn().mockReturnValue("/fake/default"),
});
scopedStore = createMockStore({
listTasks: vi.fn().mockResolvedValue([]),
createTask: vi.fn(),
updateTask: vi.fn().mockImplementation(async (id: string, patch: Record<string, unknown>) => ({
...FAKE_TASK_DETAIL,
id,
column: "triage",
...patch,
})),
logEntry: vi.fn().mockResolvedValue(undefined),
getTask: vi.fn().mockResolvedValue({
...FAKE_TASK_DETAIL,
id: "FN-PARENT",
column: "triage",
}),
deleteTask: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/fake/scoped"),
});
vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockResolvedValue(scopedStore);
});
afterEach(() => {
__setCreateKbAgent(undefined as any);
vi.restoreAllMocks();
});
function buildApp(options?: Parameters<typeof createApiRoutes>[1]) {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(defaultStore, options));
return app;
}
it("routes github issue import mutations to scoped store when projectId is provided", async () => {
vi.spyOn(GitHubClient.prototype, "getIssue").mockResolvedValue({
number: 1,
title: "Scoped issue",
body: "Body",
html_url: "https://github.com/owner/repo/issues/1",
state: "open",
});
(scopedStore.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
id: "FN-SCOPE-1",
column: "triage",
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/github/issues/import",
JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 1, projectId }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(projectStoreResolver.getOrCreateProjectStore).toHaveBeenCalledWith(projectId);
expect(scopedStore.createTask).toHaveBeenCalledTimes(1);
expect(defaultStore.createTask).not.toHaveBeenCalled();
});
it("routes github batch-import mutations to scoped store when projectId is provided", async () => {
vi.spyOn(GitHubClient.prototype, "fetchThrottled")
.mockResolvedValueOnce({
success: true,
data: {
number: 1,
title: "One",
body: "Body one",
html_url: "https://github.com/owner/repo/issues/1",
},
} as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>)
.mockResolvedValueOnce({
success: true,
data: {
number: 2,
title: "Two",
body: "Body two",
html_url: "https://github.com/owner/repo/issues/2",
},
} as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>);
(scopedStore.createTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-SCOPE-2", column: "triage" })
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-SCOPE-3", column: "triage" });
const res = await REQUEST(
buildApp(),
"POST",
"/api/github/issues/batch-import",
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, 2], delayMs: 1, projectId }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(scopedStore.createTask).toHaveBeenCalledTimes(2);
expect(defaultStore.createTask).not.toHaveBeenCalled();
});
it("routes github pull import mutations to scoped store when projectId is provided", async () => {
vi.spyOn(GitHubClient.prototype, "getPullRequest").mockResolvedValue({
number: 9,
title: "Scoped pull",
body: "PR body",
html_url: "https://github.com/owner/repo/pull/9",
headBranch: "feature/branch",
baseBranch: "main",
state: "open",
});
(scopedStore.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
id: "FN-SCOPE-4",
column: "triage",
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/github/pulls/import",
JSON.stringify({ owner: "owner", repo: "repo", prNumber: 9, projectId }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(scopedStore.createTask).toHaveBeenCalledTimes(1);
expect(defaultStore.createTask).not.toHaveBeenCalled();
});
it("routes planning create-task mutations to scoped store when projectId is provided", async () => {
vi.spyOn(planningModule, "getSession").mockReturnValue({
id: "plan-session-1",
initialPlan: "Scoped initial plan",
history: [],
thinkingOutput: "",
createdAt: new Date(),
updatedAt: new Date(),
} as any);
vi.spyOn(planningModule, "getSummary").mockReturnValue({
title: "Scoped planned task",
description: "Create task in scoped project",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: [],
});
vi.spyOn(planningModule, "cleanupSession").mockImplementation(() => {});
(scopedStore.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
id: "FN-SCOPE-5",
column: "triage",
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/planning/create-task",
JSON.stringify({ sessionId: "plan-session-1", projectId }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(scopedStore.createTask).toHaveBeenCalledTimes(1);
expect(defaultStore.createTask).not.toHaveBeenCalled();
});
it("routes planning create-tasks mutations to scoped store when projectId is provided", async () => {
vi.spyOn(planningModule, "getSession").mockReturnValue({
id: "plan-session-2",
initialPlan: "Scoped multi task plan",
history: [],
summary: {
title: "Plan",
description: "Plan description",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["Deliverable 1", "Deliverable 2"],
},
createdAt: new Date(),
updatedAt: new Date(),
} as any);
vi.spyOn(planningModule, "formatInterviewQA").mockReturnValue("Q: Scope\nA: Medium");
vi.spyOn(planningModule, "cleanupSession").mockImplementation(() => {});
(scopedStore.createTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-SCOPE-6", column: "triage" })
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-SCOPE-7", column: "triage" });
const res = await REQUEST(
buildApp(),
"POST",
"/api/planning/create-tasks",
JSON.stringify({
planningSessionId: "plan-session-2",
projectId,
subtasks: [
{ id: "sub-1", title: "First scoped task", description: "First", suggestedSize: "S", dependsOn: [] },
{ id: "sub-2", title: "Second scoped task", description: "Second", suggestedSize: "M", dependsOn: ["sub-1"] },
],
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(scopedStore.createTask).toHaveBeenCalledTimes(2);
expect(defaultStore.createTask).not.toHaveBeenCalled();
});
it("routes subtask create-tasks mutations to scoped store when projectId is provided", async () => {
vi.spyOn(subtaskBreakdownModule, "getSubtaskSession").mockReturnValue({
sessionId: "subtask-session-1",
initialDescription: "Break down scoped work",
subtasks: [],
status: "complete",
createdAt: new Date(),
updatedAt: new Date(),
thinkingOutput: "",
} as any);
vi.spyOn(subtaskBreakdownModule, "cleanupSubtaskSession").mockImplementation(() => {});
(scopedStore.createTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-SCOPE-8", column: "triage" })
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-SCOPE-9", column: "triage" });
const res = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/create-tasks",
JSON.stringify({
sessionId: "subtask-session-1",
projectId,
parentTaskId: "FN-PARENT",
subtasks: [
{ tempId: "temp-1", title: "Scoped subtask one", description: "One", size: "S", dependsOn: [] },
{ tempId: "temp-2", title: "Scoped subtask two", description: "Two", size: "M", dependsOn: ["temp-1"] },
],
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(scopedStore.getTask).toHaveBeenCalledWith("FN-PARENT");
expect(defaultStore.getTask).not.toHaveBeenCalled();
expect(scopedStore.createTask).toHaveBeenCalledTimes(2);
expect(defaultStore.createTask).not.toHaveBeenCalled();
expect(scopedStore.deleteTask).toHaveBeenCalledWith("FN-PARENT");
expect(defaultStore.deleteTask).not.toHaveBeenCalled();
});
});
// --- Spec Revision route tests ---
describe("POST /tasks/:id/spec/revise", () => {

View File

@@ -3628,6 +3628,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
const client = new GitHubClient();
const scopedStore = await getScopedStore(req);
let issue: {
number: number;
@@ -3665,7 +3666,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
// Check if already imported
const existingTasks = await store.listTasks();
const existingTasks = await scopedStore.listTasks();
const sourceUrl = issue.html_url;
for (const existingTask of existingTasks) {
if (existingTask.description.includes(sourceUrl)) {
@@ -3682,7 +3683,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const body = issue.body?.trim() || "(no description)";
const description = `${body}\n\nSource: ${sourceUrl}`;
const task = await store.createTask({
const task = await scopedStore.createTask({
title: title || undefined,
description,
column: "triage",
@@ -3690,7 +3691,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
});
// Log the import action
await store.logEntry(task.id, "Imported from GitHub", sourceUrl);
await scopedStore.logEntry(task.id, "Imported from GitHub", sourceUrl);
res.status(201).json(task);
} catch (err: any) {
@@ -3778,9 +3779,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const token = process.env.GITHUB_TOKEN;
const githubClient = new GitHubClient(token);
const scopedStore = await getScopedStore(req);
// Get existing tasks to check for duplicates
const existingTasks = await store.listTasks();
const existingTasks = await scopedStore.listTasks();
// Process issues sequentially with throttling
const results: Array<{
@@ -3845,7 +3847,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const description = `${body}\n\nSource: ${sourceUrl}`;
try {
const task = await store.createTask({
const task = await scopedStore.createTask({
title: title || undefined,
description,
column: "triage",
@@ -3853,7 +3855,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
});
// Log the import action
await store.logEntry(task.id, "Imported from GitHub", sourceUrl);
await scopedStore.logEntry(task.id, "Imported from GitHub", sourceUrl);
results.push({
issueNumber,
@@ -3964,6 +3966,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
const client = new GitHubClient();
const scopedStore = await getScopedStore(req);
let pr: {
number: number;
@@ -4001,7 +4004,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
// Check if already imported
const existingTasks = await store.listTasks();
const existingTasks = await scopedStore.listTasks();
const sourceUrl = pr.html_url;
for (const existingTask of existingTasks) {
if (existingTask.description.includes(sourceUrl)) {
@@ -4018,7 +4021,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const body = pr.body?.trim() || "(no description)";
const description = `Review and address any issues in this pull request.\n\nPR: ${sourceUrl}\nBranch: ${pr.headBranch}${pr.baseBranch}\n\n${body}`;
const task = await store.createTask({
const task = await scopedStore.createTask({
title: title || undefined,
description,
column: "triage",
@@ -4026,7 +4029,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
});
// Log the import action
await store.logEntry(task.id, "Imported PR from GitHub", sourceUrl);
await scopedStore.logEntry(task.id, "Imported PR from GitHub", sourceUrl);
res.status(201).json(task);
} catch (err: any) {
@@ -5403,8 +5406,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return;
}
const scopedStore = await getScopedStore(req);
const { createSubtaskSession } = await import("./subtask-breakdown.js");
const session = await createSubtaskSession(description, store, store.getRootDir());
const session = await createSubtaskSession(description, scopedStore, scopedStore.getRootDir());
res.status(201).json({ sessionId: session.sessionId });
} catch (err: any) {
res.status(500).json({ error: err.message || "Failed to start subtask breakdown" });
@@ -5535,6 +5539,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return;
}
const scopedStore = await getScopedStore(req);
const { getSubtaskSession, cleanupSubtaskSession } = await import("./subtask-breakdown.js");
const session = getSubtaskSession(sessionId);
if (!session) {
@@ -5546,7 +5551,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
let parentTask: Awaited<ReturnType<typeof store.getTask>> | undefined;
if (typeof parentTaskId === "string" && parentTaskId.trim()) {
try {
parentTask = await store.getTask(parentTaskId);
parentTask = await scopedStore.getTask(parentTaskId);
} catch {
// Parent task not found or error - proceed without inheritance
parentTask = undefined;
@@ -5562,7 +5567,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return;
}
const task = await store.createTask({
const task = await scopedStore.createTask({
title: item.title.trim(),
description: typeof item.description === "string" ? item.description.trim() : item.title.trim(),
column: "triage",
@@ -5578,7 +5583,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
createdTasks.push(task);
if (item.size === "S" || item.size === "M" || item.size === "L") {
await store.updateTask(task.id, { size: item.size });
await scopedStore.updateTask(task.id, { size: item.size });
}
}
@@ -5590,17 +5595,17 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
: [];
if (resolvedDependencies.length > 0) {
const updated = await store.updateTask(created.id, { dependencies: resolvedDependencies });
const updated = await scopedStore.updateTask(created.id, { dependencies: resolvedDependencies });
createdTasks[index] = updated;
}
await store.logEntry(created.id, "Created via subtask breakdown", `Source: ${session.initialDescription.slice(0, 200)}`);
await scopedStore.logEntry(created.id, "Created via subtask breakdown", `Source: ${session.initialDescription.slice(0, 200)}`);
}
let parentTaskClosed = false;
if (typeof parentTaskId === "string" && parentTaskId.trim()) {
try {
await store.deleteTask(parentTaskId);
await scopedStore.deleteTask(parentTaskId);
parentTaskClosed = true;
} catch {
parentTaskClosed = false;
@@ -5654,11 +5659,12 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return;
}
const scopedStore = await getScopedStore(req);
const ip = req.ip || req.socket.remoteAddress || "unknown";
const rootDir = store.getRootDir();
const rootDir = scopedStore.getRootDir();
const { createSession, RateLimitError } = await import("./planning.js");
const result = await createSession(ip, initialPlan, store, rootDir);
const result = await createSession(ip, initialPlan, scopedStore, rootDir);
res.status(201).json(result);
} catch (err: any) {
if (err.name === "RateLimitError") {
@@ -5702,8 +5708,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return;
}
const scopedStore = await getScopedStore(req);
const ip = req.ip || req.socket.remoteAddress || "unknown";
const rootDir = store.getRootDir();
const rootDir = scopedStore.getRootDir();
const { createSessionWithAgent, RateLimitError } = await import("./planning.js");
const sessionId = await createSessionWithAgent(
@@ -5798,6 +5805,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return;
}
const scopedStore = await getScopedStore(req);
const { getSession, getSummary, cleanupSession } = await import("./planning.js");
const session = getSession(sessionId);
@@ -5885,7 +5893,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
// Create the task
const task = await store.createTask({
const task = await scopedStore.createTask({
title: summary.title,
description: summary.description,
column: "triage",
@@ -5894,11 +5902,11 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// Update task with suggested size if provided
if (summary.suggestedSize) {
await store.updateTask(task.id, { size: summary.suggestedSize });
await scopedStore.updateTask(task.id, { size: summary.suggestedSize });
}
// Log the planning mode creation
await store.logEntry(task.id, "Created via Planning Mode", `Initial plan: ${(initialPlan ?? "").slice(0, 200)}`);
await scopedStore.logEntry(task.id, "Created via Planning Mode", `Initial plan: ${(initialPlan ?? "").slice(0, 200)}`);
// Cleanup the session
if (usedPersistedFallback) {
@@ -5984,6 +5992,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return;
}
const scopedStore = await getScopedStore(req);
const { getSession, cleanupSession, formatInterviewQA } = await import("./planning.js");
const session = getSession(planningSessionId);
@@ -6015,7 +6024,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// Create tasks
for (const item of subtasks) {
const task = await store.createTask({
const task = await scopedStore.createTask({
title: item.title.trim(),
description: typeof item.description === "string" ? item.description.trim() : item.title.trim(),
column: "triage",
@@ -6026,7 +6035,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
createdTasks.push(task);
if (item.suggestedSize === "S" || item.suggestedSize === "M" || item.suggestedSize === "L") {
await store.updateTask(task.id, { size: item.suggestedSize });
await scopedStore.updateTask(task.id, { size: item.suggestedSize });
}
}
@@ -6039,11 +6048,11 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
: [];
if (resolvedDependencies.length > 0) {
const updated = await store.updateTask(created.id, { dependencies: resolvedDependencies });
const updated = await scopedStore.updateTask(created.id, { dependencies: resolvedDependencies });
createdTasks[index] = updated;
}
await store.logEntry(created.id, "Created via Planning Mode (multi-task)", logDetails);
await scopedStore.logEntry(created.id, "Created via Planning Mode (multi-task)", logDetails);
}
// Cleanup the planning session