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; }