From 03966ecb7991057f5b8acf472c5c79b42fabd918 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 14 Jul 2026 00:12:22 -0700 Subject: [PATCH] Fix multi-project branch-group route store scoping (#2085) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Conflict resolution for closed [#2074](https://github.com/Runfusion/Fusion/pull/2074) (FN-001 multi-project branch-group store scoping), rebased onto current `main`. #2074 closed when its fork head was briefly reset to `main` during a ref update; maintainer write access to the fork head only works while the PR is open, so that PR could not be reopened without new fork commits. This branch carries the same fix: - Request-scoped `TaskStore` for branch-group list/read/assign/promote/abandon - Integrated reconcile/close uses the request store for cwd + persistence - Compatible with async branch-group store APIs and main’s CentralProjectIdentity (`projectId` trim) - Postgres durable FN-7438 tests + padded `projectId` regression ## Verification - `FUSION_DASHBOARD_DEEP=1 pnpm --filter @fusion/dashboard exec vitest run --project dashboard-api src/__tests__/routes-branch-groups.test.ts src/__tests__/integrated-routers-group-pr-token.test.ts src/__tests__/routes-context-project-identity.test.ts --silent=passed-only --reporter=dot` — 3 files, 41 tests passed. --------- Co-authored-by: Tchorizo <295840812+Tchorizo@users.noreply.github.com> Co-authored-by: Fusion --- .../fn-001-multi-project-branch-groups.md | 7 + docs/dashboard-guide.md | 3 + .../integrated-routers-group-pr-token.test.ts | 125 +++++- .../__tests__/routes-branch-groups.test.ts | 372 +++++++++++++++++- packages/dashboard/src/insights-routes.ts | 11 +- packages/dashboard/src/plugin-routes.ts | 17 +- .../routes/register-branch-groups-routes.ts | 71 ++-- .../src/routes/register-integrated-routers.ts | 18 +- packages/dashboard/src/todo-routes.ts | 11 +- 9 files changed, 564 insertions(+), 71 deletions(-) create mode 100644 .changeset/fn-001-multi-project-branch-groups.md diff --git a/.changeset/fn-001-multi-project-branch-groups.md b/.changeset/fn-001-multi-project-branch-groups.md new file mode 100644 index 0000000000..b486e56a0d --- /dev/null +++ b/.changeset/fn-001-multi-project-branch-groups.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix branch-group controls for tasks in non-default dashboard projects. +category: fix +dev: Resolves branch-group route stores from each request's projectId. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index d500be7782..af8a7a96bb 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1966,6 +1966,9 @@ The dashboard now exposes branch-group visibility and controls for shared planni - `GET /api/branch-groups/:id` returns group details (shared branch, members, per-member landed state, completion, PR state). - `POST /api/branch-groups/assign` is the supported online grouping path to attach/detach tasks (`{ taskId, groupId|null, branchName? }`). Passing `groupId: null` clears only that task's branch-group context and preserves unrelated task source metadata. - `POST /api/branch-groups/:id/promote` triggers the engine promotion flow (`promoteBranchGroup`) and returns promotion/PR status. +- `POST /api/branch-groups/:id/abandon` marks an open group abandoned and best-effort closes its managed PR. + +Every branch-group endpoint is project-scoped per request. Pass `projectId` in the query string for any endpoint or in a POST JSON body; reads, writes, member serialization, promotion, and PR reconciliation all use that selected project's store. Omitting `projectId` preserves compatibility by using the dashboard's mounted default store. UI surfaces: diff --git a/packages/dashboard/src/__tests__/integrated-routers-group-pr-token.test.ts b/packages/dashboard/src/__tests__/integrated-routers-group-pr-token.test.ts index 92e29293a2..386ef78ff2 100644 --- a/packages/dashboard/src/__tests__/integrated-routers-group-pr-token.test.ts +++ b/packages/dashboard/src/__tests__/integrated-routers-group-pr-token.test.ts @@ -5,6 +5,21 @@ import express from "express"; import type { BranchGroup, Task, TaskStore } from "@fusion/core"; import { request as REQUEST } from "../test-request.js"; +const integratedRouterMocks = vi.hoisted(() => ({ + getOrCreateProjectStore: vi.fn(), + reconcileBranchGroupPr: vi.fn(async () => ({ + reconciled: false, + prState: "open", + prNumber: null, + prUrl: null, + })), +})); + +vi.mock("../project-store-resolver.js", async () => { + const actual = await vi.importActual("../project-store-resolver.js"); + return { ...actual, getOrCreateProjectStore: integratedRouterMocks.getOrCreateProjectStore }; +}); + // Capture how GitHubClient is constructed so we can assert the configured token // is forwarded (Fix #1) into the abandon/reconcile close path. const ctorCalls: Array = []; @@ -29,9 +44,11 @@ vi.mock("../github.js", () => { // reconcileBranchGroupPr is real-ish but harmless here; stub to avoid GitHub. vi.mock("@fusion/engine", async () => { const actual = await vi.importActual("@fusion/engine"); - return { ...actual, reconcileBranchGroupPr: vi.fn(async () => ({ reconciled: false, prState: "open", prNumber: null, prUrl: null })) }; + return { ...actual, reconcileBranchGroupPr: integratedRouterMocks.reconcileBranchGroupPr }; }); +import { reconcileBranchGroupPr } from "@fusion/engine"; +import { closeGroupPullRequest } from "../github.js"; import { registerIntegratedRouters } from "../routes/register-integrated-routers.js"; function buildGroup(): BranchGroup { @@ -50,14 +67,14 @@ function buildGroup(): BranchGroup { }; } -function buildStore(group: BranchGroup): TaskStore { +function buildStore(group: BranchGroup, rootDir = "/tmp/project", tasks: Task[] = []): TaskStore { let current = { ...group }; return { - getRootDir: vi.fn(() => "/tmp/project"), - getBranchGroup: vi.fn(() => current), + getRootDir: vi.fn(() => rootDir), + getBranchGroup: vi.fn((id: string) => (id === current.id ? current : null)), listBranchGroups: vi.fn(() => [current]), - listTasks: vi.fn(async () => [] as Task[]), - listTasksByBranchGroup: vi.fn(async () => [] as Task[]), + listTasks: vi.fn(async () => tasks), + listTasksByBranchGroup: vi.fn(async () => tasks), updateBranchGroup: vi.fn((_id: string, patch: Partial) => { current = { ...current, ...patch }; return current; @@ -68,6 +85,9 @@ function buildStore(group: BranchGroup): TaskStore { describe("integrated branch-groups router — GitHub token wiring (Fix #1)", () => { beforeEach(() => { ctorCalls.length = 0; + integratedRouterMocks.getOrCreateProjectStore.mockReset(); + integratedRouterMocks.reconcileBranchGroupPr.mockClear(); + vi.mocked(closeGroupPullRequest).mockClear(); }); it("forwards options.githubToken into GitHubClient for the abandon close path", async () => { @@ -84,4 +104,97 @@ describe("integrated branch-groups router — GitHub token wiring (Fix #1)", () // The closeGroupPr callback constructed a GitHubClient with the configured token. expect(ctorCalls).toContain("ghp_test_secret"); }); + + it("persists reconciliation and resolves GitHub cwd through the selected project store", async () => { + const defaultStore = buildStore({ ...buildGroup(), branchName: "feature/default", prNumber: 10 }, "/projects/default"); + const secondaryStore = buildStore({ ...buildGroup(), branchName: "feature/secondary", prNumber: 20 }, "/projects/secondary"); + integratedRouterMocks.getOrCreateProjectStore.mockResolvedValue(secondaryStore); + const router = express.Router(); + registerIntegratedRouters({ router, store: defaultStore, options: { githubToken: "ghp_scoped" } as any }); + + const app = express(); + app.use(express.json()); + app.use("/api", router); + + const read = await REQUEST(app, "GET", "/api/branch-groups/BG-TOK?projectId=secondary"); + expect(read.status).toBe(200); + expect(read.body.group.branchName).toBe("feature/secondary"); + expect(vi.mocked(reconcileBranchGroupPr)).toHaveBeenCalledWith(expect.objectContaining({ + store: secondaryStore, + group: expect.objectContaining({ prNumber: 20 }), + cwd: "/projects/secondary", + })); + expect(defaultStore.getBranchGroup).not.toHaveBeenCalled(); + + const abandon = await REQUEST( + app, + "POST", + "/api/branch-groups/BG-TOK/abandon?projectId=secondary", + JSON.stringify({}), + { "content-type": "application/json" }, + ); + expect(abandon.status).toBe(200); + expect(vi.mocked(closeGroupPullRequest)).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ prNumber: 20 }), + "/projects/secondary", + ); + expect(secondaryStore.updateBranchGroup).toHaveBeenCalledWith( + "BG-TOK", + expect.objectContaining({ status: "abandoned", prState: "closed" }), + ); + expect(defaultStore.updateBranchGroup).not.toHaveBeenCalled(); + }); + + it("selects the matching project engine for promotion while gating on its store", async () => { + const group = buildGroup(); + const completeTask = { + id: "FN-SCOPED", + description: "secondary complete task", + column: "done", + dependencies: [], + steps: [], + currentStep: 1, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + branchContext: { groupId: group.id, source: "planning", assignmentMode: "shared" }, + mergeDetails: { + mergeConfirmed: true, + mergeTargetSource: "branch-group-integration", + mergeTargetBranch: group.branchName, + }, + } as Task; + const defaultStore = buildStore({ ...group, branchName: "feature/default" }, "/projects/default", []); + const secondaryStore = buildStore(group, "/projects/secondary", [completeTask]); + integratedRouterMocks.getOrCreateProjectStore.mockResolvedValue(secondaryStore); + const promoteBranchGroup = vi.fn(async () => ({ promoted: true })); + const getEngine = vi.fn(() => ({ + getWorkingDirectory: () => "/projects/secondary-engine", + promoteBranchGroup, + })); + const router = express.Router(); + registerIntegratedRouters({ + router, + store: defaultStore, + options: { engineManager: { getEngine } } as any, + }); + + const app = express(); + app.use(express.json()); + app.use("/api", router); + const response = await REQUEST( + app, + "POST", + "/api/branch-groups/BG-TOK/promote", + JSON.stringify({ projectId: "secondary" }), + { "content-type": "application/json" }, + ); + + expect(response.status).toBe(200); + expect(getEngine).toHaveBeenCalledWith("secondary"); + expect(promoteBranchGroup).toHaveBeenCalledWith("BG-TOK"); + expect(secondaryStore.listTasksByBranchGroup).toHaveBeenCalledWith("BG-TOK"); + expect(defaultStore.getBranchGroup).not.toHaveBeenCalled(); + expect(defaultStore.listTasksByBranchGroup).not.toHaveBeenCalled(); + }); }); diff --git a/packages/dashboard/src/__tests__/routes-branch-groups.test.ts b/packages/dashboard/src/__tests__/routes-branch-groups.test.ts index e6b888d577..775b0c7d66 100644 --- a/packages/dashboard/src/__tests__/routes-branch-groups.test.ts +++ b/packages/dashboard/src/__tests__/routes-branch-groups.test.ts @@ -2,18 +2,29 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import express from "express"; -import { mkdtempSync } from "node:fs"; -import { rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { TaskStore } from "@fusion/core"; import type { BranchGroup, Task } from "@fusion/core"; +import { + createTaskStoreForTest, + PG_AVAILABLE, +} from "../../../core/src/__test-utils__/pg-test-harness.js"; +import { createConnectionSetFromUrl } from "../../../core/src/postgres/connection.js"; +import { createAsyncDataLayer } from "../../../core/src/postgres/data-layer.js"; import { evaluateBranchGroupCompletion, ProjectEngine } from "@fusion/engine"; import { createApiRoutes } from "../routes.js"; import { createBranchGroupsRouter } from "../routes/register-branch-groups-routes.js"; import { ApiError, sendErrorResponse } from "../api-error.js"; import { request as REQUEST } from "../test-request.js"; +const projectStoreResolverMocks = vi.hoisted(() => ({ + getOrCreateProjectStore: vi.fn(), +})); + +vi.mock("../project-store-resolver.js", async () => { + const actual = await vi.importActual("../project-store-resolver.js"); + return { ...actual, getOrCreateProjectStore: projectStoreResolverMocks.getOrCreateProjectStore }; +}); + // Standalone routers (mounted without createApiRoutes) need the same error // middleware createApiRoutes provides, so thrown ApiErrors become HTTP responses // instead of hanging the request. @@ -221,39 +232,362 @@ describe("branch group routes", () => { }); }); -describe("branch group routes with durable TaskStore", () => { +describe("branch group routes project-store scoping", () => { + function scopedGroup(id: string, project: string, overrides: Partial = {}): BranchGroup { + return { + id, + sourceType: "planning", + sourceId: `PS-${project}`, + branchName: `feature/${project}`, + autoMerge: false, + prState: "none", + status: "open", + createdAt: Date.now(), + updatedAt: Date.now(), + ...overrides, + }; + } + + function scopedTask(id: string, groupId: string, project: string, landed = true): Task { + return { + ...buildTask(id, groupId, landed), + description: `${project} task`, + title: `${project} task`, + mergeDetails: landed + ? { + mergeConfirmed: true, + mergeTargetSource: "branch-group-integration", + mergeTargetBranch: `feature/${project}`, + } + : undefined, + } as Task; + } + + function scopedStore(rootDir: string, initialGroups: BranchGroup[], initialTasks: Task[]): TaskStore { + const groups = new Map(initialGroups.map((entry) => [entry.id, { ...entry }])); + const tasks = new Map(initialTasks.map((entry) => [entry.id, { ...entry }])); + return { + getRootDir: vi.fn(() => rootDir), + listBranchGroups: vi.fn((filter?: { status?: BranchGroup["status"] }) => + [...groups.values()].filter((entry) => !filter?.status || entry.status === filter.status), + ), + getBranchGroup: vi.fn((id: string) => groups.get(id) ?? null), + listTasks: vi.fn(async () => [...tasks.values()]), + listTasksByBranchGroup: vi.fn(async (groupId: string) => + [...tasks.values()].filter((task) => task.branchContext?.groupId === groupId), + ), + getTask: vi.fn(async (id: string) => { + const task = tasks.get(id); + if (!task) throw new Error(`Task ${id} not found`); + return task; + }), + setTaskBranchGroup: vi.fn(async (taskId: string, groupId: string | null) => { + const task = tasks.get(taskId); + if (!task) throw new Error(`Task ${taskId} not found`); + task.branchContext = groupId + ? { groupId, source: "planning", assignmentMode: "shared" } + : undefined; + }), + ensureBranchGroupForSource: vi.fn((_sourceType, _sourceId, input: { branchName: string; autoMerge: boolean }) => { + const created = scopedGroup(`BG-CREATED-${rootDir}`, rootDir, { + branchName: input.branchName, + autoMerge: input.autoMerge, + }); + groups.set(created.id, created); + return created; + }), + updateBranchGroup: vi.fn((id: string, patch: Partial) => { + const current = groups.get(id); + if (!current) throw new Error(`Branch group ${id} not found`); + const updated = { ...current, ...patch, updatedAt: Date.now() }; + groups.set(id, updated); + return updated; + }), + } as unknown as TaskStore; + } + + function mountScopedRouter( + defaultStore: TaskStore, + options: Parameters[1] = {}, + ): express.Express { + const app = express(); + app.use(express.json()); + app.use("/branch-groups", createBranchGroupsRouter(defaultStore, options)); + attachErrorHandler(app); + return app; + } + + beforeEach(() => { + projectStoreResolverMocks.getOrCreateProjectStore.mockReset(); + }); + + it("canonicalizes padded projectId so store and callback keys match the bare id", async () => { + // FNXC:BranchGroupProjectScoping 2026-07-14-06:15: + // `?projectId=secondary%20` must resolve the same store key as `secondary`, not a distinct padded key. + const secondaryGroup = scopedGroup("BG-PAD", "secondary"); + const defaultStore = scopedStore("/projects/default", [], []); + const secondaryStore = scopedStore("/projects/secondary", [secondaryGroup], [ + scopedTask("FN-PAD", secondaryGroup.id, "secondary"), + ]); + projectStoreResolverMocks.getOrCreateProjectStore.mockResolvedValue(secondaryStore); + const promoteBranchGroup = vi.fn(async () => ({ promoted: true })); + const app = mountScopedRouter(defaultStore, { promoteBranchGroup }); + + const read = await REQUEST(app, "GET", "/branch-groups/BG-PAD?projectId=secondary%20"); + expect(read.status).toBe(200); + expect(read.body.group.branchName).toBe("feature/secondary"); + expect(projectStoreResolverMocks.getOrCreateProjectStore).toHaveBeenCalledWith("secondary"); + + const promote = await REQUEST( + app, + "POST", + "/branch-groups/BG-PAD/promote?projectId=%20secondary%20", + JSON.stringify({}), + { "content-type": "application/json" }, + ); + expect(promote.status).toBe(200); + expect(promoteBranchGroup).toHaveBeenCalledWith({ + groupId: "BG-PAD", + projectId: "secondary", + store: secondaryStore, + }); + }); + + it("replays non-default GET and reset requests without consulting the mounted default store", async () => { + const duplicateDefault = scopedGroup("BG-DUPLICATE", "default"); + const duplicateSecondary = scopedGroup("BG-DUPLICATE", "secondary"); + const secondaryOnly = scopedGroup("BG-SECONDARY-ONLY", "secondary-only"); + const defaultStore = scopedStore("/projects/default", [duplicateDefault], [ + scopedTask("FN-DUPLICATE", duplicateDefault.id, "default"), + ]); + const secondaryStore = scopedStore("/projects/secondary", [duplicateSecondary, secondaryOnly], [ + scopedTask("FN-DUPLICATE", duplicateSecondary.id, "secondary"), + scopedTask("FN-SECONDARY-ONLY", secondaryOnly.id, "secondary-only"), + ]); + projectStoreResolverMocks.getOrCreateProjectStore.mockResolvedValue(secondaryStore); + const app = mountScopedRouter(defaultStore); + + const duplicate = await REQUEST(app, "GET", "/branch-groups/BG-DUPLICATE?projectId=secondary"); + expect(duplicate.status).toBe(200); + expect(duplicate.body.group.branchName).toBe("feature/secondary"); + expect(duplicate.body.group.members[0].title).toBe("secondary task"); + + const secondaryOnlyResponse = await REQUEST(app, "GET", "/branch-groups/BG-SECONDARY-ONLY?projectId=secondary"); + expect(secondaryOnlyResponse.status).toBe(200); + expect(secondaryOnlyResponse.body.group.branchName).toBe("feature/secondary-only"); + + const reset = await REQUEST( + app, + "POST", + "/branch-groups/assign?projectId=secondary", + JSON.stringify({ taskId: "FN-SECONDARY-ONLY", groupId: null }), + { "content-type": "application/json" }, + ); + expect(reset.status).toBe(200); + expect(reset.body).toEqual({ taskId: "FN-SECONDARY-ONLY", groupId: null }); + expect(secondaryStore.setTaskBranchGroup).toHaveBeenCalledWith("FN-SECONDARY-ONLY", null); + expect(defaultStore.getBranchGroup).not.toHaveBeenCalled(); + expect(defaultStore.getTask).not.toHaveBeenCalled(); + expect(defaultStore.setTaskBranchGroup).not.toHaveBeenCalled(); + }); + + it("uses the selected store across list, body assignment, promotion, abandon, and callbacks", async () => { + const defaultStore = scopedStore("/projects/default", [], []); + const secondaryGroup = scopedGroup("BG-SECONDARY", "secondary", { + prState: "open", + prNumber: 42, + prUrl: "https://example/pr/42", + }); + const secondaryStore = scopedStore("/projects/secondary", [secondaryGroup], [ + scopedTask("FN-SECONDARY", secondaryGroup.id, "secondary"), + ]); + projectStoreResolverMocks.getOrCreateProjectStore.mockResolvedValue(secondaryStore); + const promoteBranchGroup = vi.fn(async () => ({ promoted: true })); + const closeGroupPr = vi.fn(async () => ({ + prNumber: 42, + prUrl: "https://example/pr/42", + prState: "closed" as const, + })); + const app = mountScopedRouter(defaultStore, { promoteBranchGroup, closeGroupPr }); + + const list = await REQUEST(app, "GET", "/branch-groups?projectId=secondary"); + expect(list.status).toBe(200); + expect(list.body.groups.map((entry: BranchGroup) => entry.id)).toEqual(["BG-SECONDARY"]); + + const assign = await REQUEST( + app, + "POST", + "/branch-groups/assign", + JSON.stringify({ projectId: "secondary", taskId: "FN-SECONDARY", groupId: "BG-SECONDARY" }), + { "content-type": "application/json" }, + ); + expect(assign.status).toBe(200); + + const promote = await REQUEST( + app, + "POST", + "/branch-groups/BG-SECONDARY/promote", + JSON.stringify({ projectId: "secondary" }), + { "content-type": "application/json" }, + ); + expect(promote.status).toBe(200); + expect(promoteBranchGroup).toHaveBeenCalledWith({ + groupId: "BG-SECONDARY", + projectId: "secondary", + store: secondaryStore, + }); + + const abandon = await REQUEST( + app, + "POST", + "/branch-groups/BG-SECONDARY/abandon?projectId=secondary", + JSON.stringify({}), + { "content-type": "application/json" }, + ); + expect(abandon.status).toBe(200); + expect(closeGroupPr).toHaveBeenCalledWith({ + group: expect.objectContaining({ id: "BG-SECONDARY" }), + projectId: "secondary", + store: secondaryStore, + }); + expect(secondaryStore.updateBranchGroup).toHaveBeenCalledWith( + "BG-SECONDARY", + expect.objectContaining({ status: "abandoned", prState: "closed" }), + ); + expect(defaultStore.listBranchGroups).not.toHaveBeenCalled(); + expect(defaultStore.listTasks).not.toHaveBeenCalled(); + expect(defaultStore.getBranchGroup).not.toHaveBeenCalled(); + expect(defaultStore.getTask).not.toHaveBeenCalled(); + expect(defaultStore.updateBranchGroup).not.toHaveBeenCalled(); + }); + + it("persists and re-reads reconciliation through the query-selected store", async () => { + const duplicateDefault = scopedGroup("BG-RECONCILE", "default", { + prState: "open", + prNumber: 11, + prUrl: "https://example/pr/11", + }); + const duplicateSecondary = scopedGroup("BG-RECONCILE", "secondary", { + prState: "open", + prNumber: 22, + prUrl: "https://example/pr/22", + }); + const defaultStore = scopedStore("/projects/default", [duplicateDefault], []); + const secondaryStore = scopedStore("/projects/secondary", [duplicateSecondary], []); + projectStoreResolverMocks.getOrCreateProjectStore.mockResolvedValue(secondaryStore); + const reconcileGroupPr = vi.fn(async ({ group, store: requestStore }: { group: BranchGroup; store: TaskStore }) => { + // FNXC:BranchGroupProjectScoping 2026-07-13-12:00: await async TaskStore branch-group methods after Postgres cutover on main. + await requestStore.updateBranchGroup(group.id, { prState: "merged" }); + return (await requestStore.getBranchGroup(group.id)) ?? group; + }); + const app = mountScopedRouter(defaultStore, { reconcileGroupPr }); + + const response = await REQUEST(app, "GET", "/branch-groups/BG-RECONCILE?projectId=secondary"); + expect(response.status).toBe(200); + expect(response.body.group.prState).toBe("merged"); + expect(reconcileGroupPr).toHaveBeenCalledWith({ + group: expect.objectContaining({ prNumber: 22 }), + projectId: "secondary", + store: secondaryStore, + }); + expect(secondaryStore.updateBranchGroup).toHaveBeenCalledWith("BG-RECONCILE", { prState: "merged" }); + expect(defaultStore.getBranchGroup).not.toHaveBeenCalled(); + expect(defaultStore.updateBranchGroup).not.toHaveBeenCalled(); + }); + + it("falls back only when projectId is absent and keeps unknown groups project-local", async () => { + const defaultGroup = scopedGroup("BG-DEFAULT", "default"); + const secondaryGroup = scopedGroup("BG-SECONDARY", "secondary"); + const defaultStore = scopedStore("/projects/default", [defaultGroup], [ + scopedTask("FN-DEFAULT", defaultGroup.id, "default"), + ]); + const secondaryStore = scopedStore("/projects/secondary", [secondaryGroup], [ + scopedTask("FN-SECONDARY", secondaryGroup.id, "secondary"), + ]); + projectStoreResolverMocks.getOrCreateProjectStore.mockResolvedValue(secondaryStore); + const app = mountScopedRouter(defaultStore); + + const fallbackGet = await REQUEST(app, "GET", "/branch-groups/BG-DEFAULT"); + expect(fallbackGet.status).toBe(200); + expect(fallbackGet.body.group.branchName).toBe("feature/default"); + const fallbackAssign = await REQUEST( + app, + "POST", + "/branch-groups/assign", + JSON.stringify({ taskId: "FN-DEFAULT", groupId: null }), + { "content-type": "application/json" }, + ); + expect(fallbackAssign.status).toBe(200); + expect(projectStoreResolverMocks.getOrCreateProjectStore).not.toHaveBeenCalled(); + expect(defaultStore.setTaskBranchGroup).toHaveBeenCalledWith("FN-DEFAULT", null); + expect(secondaryStore.getBranchGroup).not.toHaveBeenCalled(); + expect(secondaryStore.getTask).not.toHaveBeenCalled(); + expect(secondaryStore.setTaskBranchGroup).not.toHaveBeenCalled(); + + vi.clearAllMocks(); + projectStoreResolverMocks.getOrCreateProjectStore.mockResolvedValue(secondaryStore); + const missing = await REQUEST(app, "GET", "/branch-groups/BG-DEFAULT?projectId=secondary"); + expect(missing.status).toBe(404); + expect(secondaryStore.getBranchGroup).toHaveBeenCalledWith("BG-DEFAULT"); + expect(defaultStore.getBranchGroup).not.toHaveBeenCalled(); + }); +}); + +/* +FNXC:BranchGroupProjectScoping 2026-07-13-12:05: +FN-7438 durable-route coverage used bare `new TaskStore` (SQLite). Main removed that path for Postgres backend mode. +Rebind the restart fixture to createTaskStoreForTest, reopening a second TaskStore on the same AsyncDataLayer so group/member rows still prove request routing against durable storage. +*/ +const durableDescribe = PG_AVAILABLE ? describe : describe.skip; + +durableDescribe("branch group routes with durable TaskStore", () => { async function withRestartedStore(callback: (store: TaskStore, app: express.Express) => Promise): Promise { - const rootDir = mkdtempSync(join(tmpdir(), "fusion-branch-group-route-")); - const globalDir = join(rootDir, ".fusion-global"); - let store = new TaskStore(rootDir, globalDir); - await store.init(); + const harness = await createTaskStoreForTest({ prefix: "fusion_bg_route" }); + let restartedStore: TaskStore | null = null; try { - const group = store.ensureBranchGroupForSource("planning", "PS-route-restart", { + const group = await harness.store.ensureBranchGroupForSource("planning", "PS-route-restart", { branchName: "feature/route-restart", autoMerge: true, }); - await store.createTask({ + await harness.store.createTask({ description: "route member after restart", branchContext: { groupId: group.id, source: "planning", assignmentMode: "shared" }, }); - store.close(); - store = new TaskStore(rootDir, globalDir); - await store.init(); + // TaskStore.close() also closes the AsyncDataLayer pool. Rebuild a fresh + // connection set to the same database URL so we prove durability across a + // real store/process restart rather than reusing a closed pool. + await harness.store.close(); + const connections = await createConnectionSetFromUrl( + { + mode: "external", + runtimeUrl: harness.testUrl, + migrationUrl: harness.testUrl, + migrationUrlOverridden: false, + }, + { poolMax: 5, connectTimeoutSeconds: 5 }, + ); + const layer = createAsyncDataLayer(connections); + restartedStore = new TaskStore(harness.rootDir, undefined, { asyncLayer: layer }); + await restartedStore.init(); const app = express(); app.use(express.json()); - app.use("/api/branch-groups", createBranchGroupsRouter(store)); + app.use("/api/branch-groups", createBranchGroupsRouter(restartedStore)); attachErrorHandler(app); - return await callback(store, app); + return await callback(restartedStore, app); } finally { - store.close(); - await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + try { + await restartedStore?.close(); + } catch { + // best-effort; harness.teardown drops the database either way + } + await harness.teardown(); } } it("FN-7438: lists and shows persisted branch groups after a server/store restart", async () => { await withRestartedStore(async (store, app) => { - const group = store.getBranchGroupBySource("planning", "PS-route-restart"); + const group = await store.getBranchGroupBySource("planning", "PS-route-restart"); expect(group?.id).toMatch(/^BG-/); const listRes = await REQUEST(app, "GET", "/api/branch-groups"); diff --git a/packages/dashboard/src/insights-routes.ts b/packages/dashboard/src/insights-routes.ts index 73d4d3b9bd..b90fdef42f 100644 --- a/packages/dashboard/src/insights-routes.ts +++ b/packages/dashboard/src/insights-routes.ts @@ -62,11 +62,14 @@ function rethrowAsApiError(error: unknown, fallbackMessage = "Internal server er * Extract projectId from query params or body. */ function getProjectId(req: Request): string | undefined { - if (typeof req.query.projectId === "string" && req.query.projectId.trim()) { - return req.query.projectId; + // FNXC:BranchGroupProjectScoping 2026-07-14-06:15: return the trimmed id, not the raw padded string. + if (typeof req.query.projectId === "string") { + const projectId = req.query.projectId.trim(); + if (projectId) return projectId; } - if (req.body && typeof req.body === "object" && typeof req.body.projectId === "string" && req.body.projectId.trim()) { - return req.body.projectId; + if (req.body && typeof req.body === "object" && typeof req.body.projectId === "string") { + const projectId = req.body.projectId.trim(); + if (projectId) return projectId; } return undefined; } diff --git a/packages/dashboard/src/plugin-routes.ts b/packages/dashboard/src/plugin-routes.ts index eb0027650b..a66d4ed35d 100644 --- a/packages/dashboard/src/plugin-routes.ts +++ b/packages/dashboard/src/plugin-routes.ts @@ -349,8 +349,9 @@ export function createPluginRouter( router.get("/registry", catchHandler(async (req: Request, res: Response) => { const q = typeof req.query.q === "string" ? req.query.q : undefined; const category = typeof req.query.category === "string" ? req.query.category : undefined; - const projectId = typeof req.query.projectId === "string" && req.query.projectId.trim() - ? req.query.projectId + // FNXC:BranchGroupProjectScoping 2026-07-14-06:15: return the trimmed id, not the raw padded string. + const projectId = typeof req.query.projectId === "string" + ? (req.query.projectId.trim() || undefined) : undefined; const scopedStore = projectId ? await getOrCreateProjectStore(projectId) : null; const store = scopedStore?.getPluginStore?.() ?? pluginStore; @@ -706,11 +707,13 @@ export function createPluginRouter( throw notFound(`Plugin "${pluginId}" not loaded`); } - const projectId = typeof req.query.projectId === "string" && req.query.projectId.trim() - ? req.query.projectId - : (req.body && typeof req.body === "object" && typeof (req.body as { projectId?: unknown }).projectId === "string" && (req.body as { projectId: string }).projectId.trim() - ? (req.body as { projectId: string }).projectId - : undefined); + // FNXC:BranchGroupProjectScoping 2026-07-14-06:15: return the trimmed id, not the raw padded string. + const queryProjectId = typeof req.query.projectId === "string" ? req.query.projectId.trim() : ""; + const bodyProjectId = + req.body && typeof req.body === "object" && typeof (req.body as { projectId?: unknown }).projectId === "string" + ? (req.body as { projectId: string }).projectId.trim() + : ""; + const projectId = queryProjectId || bodyProjectId || undefined; const scopedStore = projectId ? await getOrCreateProjectStore(projectId) : null; const taskStore = scopedStore ?? defaultTaskStore ?? ({} as import("@fusion/core").TaskStore); diff --git a/packages/dashboard/src/routes/register-branch-groups-routes.ts b/packages/dashboard/src/routes/register-branch-groups-routes.ts index 2c1ac64658..ad7a15ce48 100644 --- a/packages/dashboard/src/routes/register-branch-groups-routes.ts +++ b/packages/dashboard/src/routes/register-branch-groups-routes.ts @@ -2,9 +2,14 @@ import { Router, type Request } from "express"; import type { BranchGroup, Task, TaskStore } from "@fusion/core"; import { isBranchGroupComplete, isBranchGroupMemberLanded, filterTasksByBranchGroup } from "@fusion/core"; import { badRequest, notFound } from "../api-error.js"; +import { getProjectIdFromRequest, getScopedStore } from "./context.js"; export interface BranchGroupsRouterOptions { - promoteBranchGroup?: (input: { groupId: string; projectId?: string }) => Promise>; + promoteBranchGroup?: (input: { + groupId: string; + projectId?: string; + store: TaskStore; + }) => Promise>; /** * Terminal reconciliation when a group is abandoned (U6, R7): best-effort * close the single managed GitHub PR. Returns the reconciled prState so the @@ -15,6 +20,7 @@ export interface BranchGroupsRouterOptions { closeGroupPr?: (input: { group: BranchGroup; projectId?: string; + store: TaskStore; }) => Promise<{ prNumber: number; prUrl: string; prState: BranchGroup["prState"] } | null>; /** * Out-of-band PR reconciliation on single-group read (Fix #3): when a group has @@ -27,12 +33,26 @@ export interface BranchGroupsRouterOptions { reconcileGroupPr?: (input: { group: BranchGroup; projectId?: string; + store: TaskStore; }) => Promise; } -function parseProjectId(req: Request): string | undefined { - const value = req.query.projectId ?? req.body?.projectId; - return typeof value === "string" && value.trim() ? value.trim() : undefined; +interface BranchGroupRequestContext { + projectId?: string; + store: TaskStore; +} + +/* +FNXC:BranchGroupProjectScoping 2026-07-13-00:00: +Every branch-group request must resolve one TaskStore from its query/body projectId and carry that store through reads, writes, serialization, and injected callbacks. Only requests without projectId may fall back to the store mounted with the router. + +FNXC:BranchGroupProjectScoping 2026-07-13-12:00: +Main made branch-group TaskStore methods async for the Postgres cutover. Keep request-scoped store selection from FN-001 and await those methods so multi-project routes stay correct after merge with main. +*/ +async function resolveRequestContext(req: Request, mountedStore: TaskStore): Promise { + const projectId = getProjectIdFromRequest(req); + const store = await getScopedStore(req, mountedStore); + return { projectId, store }; } /** @@ -69,25 +89,27 @@ export function createBranchGroupsRouter(store: TaskStore, options?: BranchGroup const router = Router(); router.get("/", async (req, res) => { + const { store: requestStore } = await resolveRequestContext(req, store); const statusRaw = req.query.status; const status = typeof statusRaw === "string" && statusRaw.trim() ? statusRaw.trim() : undefined; if (status && status !== "open" && status !== "finalized" && status !== "abandoned") { throw badRequest("status must be one of: open, finalized, abandoned"); } - const groups = await store.listBranchGroups(status ? { status: status as BranchGroup["status"] } : undefined); + const groups = await requestStore.listBranchGroups(status ? { status: status as BranchGroup["status"] } : undefined); // Fix #8/#9: fetch tasks ONCE and filter per group in memory rather than one // full scan per group (the old N+1). Membership semantics (incl. legacy // synthetic-groupId fallback) come from the shared `filterTasksByBranchGroup`. - const allTasks = await store.listTasks({ includeArchived: false, slim: true }); - const data = await Promise.all(groups.map((group) => serializeGroup(store, group, allTasks))); + const allTasks = await requestStore.listTasks({ includeArchived: false, slim: true }); + const data = await Promise.all(groups.map((group) => serializeGroup(requestStore, group, allTasks))); res.json({ groups: data }); }); router.get("/:id", async (req, res) => { + const { projectId, store: requestStore } = await resolveRequestContext(req, store); const id = String(req.params.id ?? "").trim(); if (!id) throw badRequest("id is required"); - let group = await store.getBranchGroup(id); + let group = await requestStore.getBranchGroup(id); if (!group) throw notFound("Branch group not found"); // Fix #3: reconcile an out-of-band merged/closed PR before serializing so the @@ -95,26 +117,27 @@ export function createBranchGroupsRouter(store: TaskStore, options?: BranchGroup // must not break the read; we serialize the (possibly stale) persisted state. if (group.prNumber != null && group.prState === "open" && options?.reconcileGroupPr) { try { - group = await options.reconcileGroupPr({ group, projectId: parseProjectId(req) }); + group = await options.reconcileGroupPr({ group, projectId, store: requestStore }); } catch { - group = (await store.getBranchGroup(id)) ?? group; + group = (await requestStore.getBranchGroup(id)) ?? group; } } - res.json({ group: await serializeGroup(store, group) }); + res.json({ group: await serializeGroup(requestStore, group) }); }); router.post("/assign", async (req, res) => { + const { store: requestStore } = await resolveRequestContext(req, store); const taskId = typeof req.body?.taskId === "string" ? req.body.taskId.trim() : ""; if (!taskId) throw badRequest("taskId is required"); - const task = await store.getTask(taskId); + const task = await requestStore.getTask(taskId); const groupIdBody = req.body?.groupId; const branchNameRaw = req.body?.branchName; const branchName = typeof branchNameRaw === "string" && branchNameRaw.trim() ? branchNameRaw.trim() : undefined; if (groupIdBody === null) { - await store.setTaskBranchGroup(taskId, null); + await requestStore.setTaskBranchGroup(taskId, null); res.json({ taskId, groupId: null }); return; } @@ -124,26 +147,27 @@ export function createBranchGroupsRouter(store: TaskStore, options?: BranchGroup if (!branchName) throw badRequest("branchName is required when groupId is not provided"); const sourceType = task.branchContext?.source ?? "planning"; const sourceId = `task:${task.id}`; - const created = await store.ensureBranchGroupForSource(sourceType, sourceId, { + const created = await requestStore.ensureBranchGroupForSource(sourceType, sourceId, { branchName, autoMerge: task.autoMerge ?? false, }); groupId = created.id; - } else if (!(await store.getBranchGroup(groupId))) { + } else if (!(await requestStore.getBranchGroup(groupId))) { throw notFound("Branch group not found"); } - await store.setTaskBranchGroup(taskId, groupId); + await requestStore.setTaskBranchGroup(taskId, groupId); res.json({ taskId, groupId }); }); router.post("/:id/promote", async (req, res) => { + const { projectId, store: requestStore } = await resolveRequestContext(req, store); const id = String(req.params.id ?? "").trim(); if (!id) throw badRequest("id is required"); - const group = await store.getBranchGroup(id); + const group = await requestStore.getBranchGroup(id); if (!group) throw notFound("Branch group not found"); - const members = await store.listTasksByBranchGroup(group.id); + const members = await requestStore.listTasksByBranchGroup(group.id); if (!isBranchGroupComplete(members, group)) { throw badRequest("Branch group completion gate not satisfied"); } @@ -153,7 +177,7 @@ export function createBranchGroupsRouter(store: TaskStore, options?: BranchGroup throw badRequest("Branch-group promotion is unavailable"); } - const result = await promote({ groupId: id, projectId: parseProjectId(req) }); + const result = await promote({ groupId: id, projectId, store: requestStore }); res.json({ groupId: id, ...result }); }); @@ -163,9 +187,10 @@ export function createBranchGroupsRouter(store: TaskStore, options?: BranchGroup // wired, the row is still marked abandoned/closed (the GitHub PR is left for // out-of-band reconciliation on the next read/sync). router.post("/:id/abandon", async (req, res) => { + const { projectId, store: requestStore } = await resolveRequestContext(req, store); const id = String(req.params.id ?? "").trim(); if (!id) throw badRequest("id is required"); - const group = await store.getBranchGroup(id); + const group = await requestStore.getBranchGroup(id); if (!group) throw notFound("Branch group not found"); // Fix #2: a finalized, already-abandoned, or already-merged group is terminal @@ -186,7 +211,7 @@ export function createBranchGroupsRouter(store: TaskStore, options?: BranchGroup if (group.prNumber != null && group.prState === "open" && options?.closeGroupPr) { try { - const reconciled = await options.closeGroupPr({ group, projectId: parseProjectId(req) }); + const reconciled = await options.closeGroupPr({ group, projectId, store: requestStore }); if (reconciled) { prState = reconciled.prState; prNumber = reconciled.prNumber; @@ -197,13 +222,13 @@ export function createBranchGroupsRouter(store: TaskStore, options?: BranchGroup } } - const updated = await store.updateBranchGroup(id, { + const updated = await requestStore.updateBranchGroup(id, { status: "abandoned", prState, prNumber: prNumber ?? null, prUrl: prUrl ?? null, }); - res.json({ groupId: id, group: await serializeGroup(store, updated) }); + res.json({ groupId: id, group: await serializeGroup(requestStore, updated) }); }); return router; diff --git a/packages/dashboard/src/routes/register-integrated-routers.ts b/packages/dashboard/src/routes/register-integrated-routers.ts index cfff90b2a3..1df918897d 100644 --- a/packages/dashboard/src/routes/register-integrated-routers.ts +++ b/packages/dashboard/src/routes/register-integrated-routers.ts @@ -58,7 +58,7 @@ export function registerIntegratedRouters({ // otherwise resolve owner/repo from the PROCESS cwd) target the right repo in // multi-project servers. Prefer the per-project engine's working directory; // fall back to the single engine, then the store's root dir. - const resolveProjectCwd = (projectId?: string): string | undefined => { + const resolveProjectCwd = (projectId: string | undefined, requestStore: TaskStore): string | undefined => { const engine = projectId && options?.engineManager ? options.engineManager.getEngine(projectId) : options?.engine; @@ -71,7 +71,7 @@ export function registerIntegratedRouters({ } } try { - return store.getRootDir(); + return requestStore.getRootDir(); } catch { return undefined; } @@ -88,7 +88,7 @@ export function registerIntegratedRouters({ } return await promote(groupId); }, - closeGroupPr: async ({ group, projectId }) => { + closeGroupPr: async ({ group, projectId, store: requestStore }) => { // Best-effort terminal reconciliation: close the single managed GitHub PR // (U6, R7). The route still marks the row abandoned/closed if this returns // null or throws. @@ -98,18 +98,18 @@ export function registerIntegratedRouters({ // Fix #1: forward the configured token so token-only environments (no gh // CLI) can still close the PR. const client = new GitHubClient(options?.githubToken); - const result = await closeGroupPullRequest(client, group, resolveProjectCwd(projectId)); + const result = await closeGroupPullRequest(client, group, resolveProjectCwd(projectId, requestStore)); return { prNumber: result.prNumber, prUrl: result.prUrl, prState: result.prState }; }, - reconcileGroupPr: async ({ group, projectId }) => { + reconcileGroupPr: async ({ group, projectId, store: requestStore }) => { // Fix #3: flip prState when the managed PR was merged/closed out-of-band. // Build a read-only SyncGroupPrFn over the GitHub client (mirrors the CLI's // syncGroupPrCallback shape) and delegate persistence to the engine's // reconcileBranchGroupPr primitive. const client = new GitHubClient(options?.githubToken); - const cwd = resolveProjectCwd(projectId); + const cwd = resolveProjectCwd(projectId, requestStore); await reconcileBranchGroupPr({ - store, + store: requestStore, group, // T7: forward the per-project cwd so reconcileGroupPullRequest resolves // the repo identity per-project (not from the process cwd). @@ -119,7 +119,9 @@ export function registerIntegratedRouters({ fetchMembers: false, syncGroupPr: async ({ cwd: projectCwd, group: g }) => reconcileGroupPullRequest(client, g, projectCwd || undefined), }); - return (await store.getBranchGroup(group.id)) ?? group; + // FNXC:BranchGroupProjectScoping 2026-07-13-12:00: + // Re-read from the request-scoped store after reconcile, and await the async Postgres-era TaskStore API from main. + return (await requestStore.getBranchGroup(group.id)) ?? group; }, })); diff --git a/packages/dashboard/src/todo-routes.ts b/packages/dashboard/src/todo-routes.ts index 2f958ad9e4..afcaa3e0d1 100644 --- a/packages/dashboard/src/todo-routes.ts +++ b/packages/dashboard/src/todo-routes.ts @@ -62,11 +62,14 @@ export function createTodoRouter(store: TaskStore, options?: ServerOptions): Rou const requestContext = new AsyncLocalStorage(); function getProjectIdFromRequest(req: Request): string | undefined { - if (typeof req.query.projectId === "string" && req.query.projectId.trim()) { - return req.query.projectId; + // FNXC:BranchGroupProjectScoping 2026-07-14-06:15: return the trimmed id, not the raw padded string. + if (typeof req.query.projectId === "string") { + const projectId = req.query.projectId.trim(); + if (projectId) return projectId; } - if (req.body && typeof req.body === "object" && typeof req.body.projectId === "string" && req.body.projectId.trim()) { - return req.body.projectId; + if (req.body && typeof req.body === "object" && typeof req.body.projectId === "string") { + const projectId = req.body.projectId.trim(); + if (projectId) return projectId; } return undefined; }