From 9aa285203307e718c37576a6998ea8ea35987094 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 13 Jul 2026 23:16:36 -0700 Subject: [PATCH] fix: resolve an explicit central-registry project id for all dashboard API requests Implements the explicit-project-identity directive at the route layer: a request's store is resolved from request projectId -> the daemon's registered launch project id -> only for unregistered launch directories, the raw launch-dir store (one-time warn). Resolution funnels through a single seam (routes/context.ts resolveRequestProjectId + resolveStoreForProjectId); the server.ts realtime resolveScopedStore delegates to the same function instead of mirroring it. Scattered 'projectId ? getOrCreateProjectStore : store' ternaries in todo/goals/mission/insights/research/evals routes now use the shared seam. Code-review fixes folded in (multi-agent ce-code-review, 10 reviewers): - mission interview drafts list/discard resolve the same project id the start endpoint stamps (write/read no longer split namespaces) - chat stream-attach guard treats legacy null-projectId sessions as launch-owned instead of 404ing; planner-chat dedup retries unscoped to reuse legacy sessions instead of duplicating them - getProjectIdFromRequest trims and rejects whitespace-only ids - evals/research middleware forwards store-resolution failures to Express (previously rethrew inside a detached promise chain and hung the request) - one-time launch-dir fallback warning routes through runtimeLogger - seam + delegation + whitespace + engine-fallthrough covered in routes-context-project-identity.test.ts (10 cases) Co-Authored-By: Claude Fable 5 --- .changeset/explicit-project-id-routes.md | 7 + .../routes-context-project-identity.test.ts | 192 ++++++++++++++++++ packages/dashboard/src/evals-routes.ts | 34 ++-- packages/dashboard/src/goals-routes.ts | 21 +- packages/dashboard/src/insights-routes.ts | 13 +- packages/dashboard/src/mission-routes.ts | 58 +++--- packages/dashboard/src/research-routes.ts | 35 ++-- packages/dashboard/src/routes/context.ts | 123 +++++++++-- .../src/routes/register-chat-routes.ts | 23 ++- .../src/routes/register-integrated-routers.ts | 17 +- packages/dashboard/src/server.ts | 43 ++-- packages/dashboard/src/todo-routes.ts | 12 +- 12 files changed, 453 insertions(+), 125 deletions(-) create mode 100644 .changeset/explicit-project-id-routes.md create mode 100644 packages/dashboard/src/__tests__/routes-context-project-identity.test.ts diff --git a/.changeset/explicit-project-id-routes.md b/.changeset/explicit-project-id-routes.md new file mode 100644 index 0000000000..326034111a --- /dev/null +++ b/.changeset/explicit-project-id-routes.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Dashboard API requests now resolve an explicit registered project instead of silently using the launch directory. +category: fix +dev: routes/context.ts gains a shared resolveRequestProjectId/getScopedStore/getProjectContext seam (request projectId → options.engine.getProjectId() → raw launch-dir store with a one-time warn, for unregistered dirs only). A resolved id always binds through the engine store or getOrCreateProjectStore; the launch project reuses the injected registry-bound store to avoid a duplicate pool. server.ts resolveScopedStore threads the launch project id, and the todo/goals/mission/insights/research/evals routers route their request middleware through the same seam. diff --git a/packages/dashboard/src/__tests__/routes-context-project-identity.test.ts b/packages/dashboard/src/__tests__/routes-context-project-identity.test.ts new file mode 100644 index 0000000000..9f7a1a5136 --- /dev/null +++ b/packages/dashboard/src/__tests__/routes-context-project-identity.test.ts @@ -0,0 +1,192 @@ +// @vitest-environment node + +/** + * FNXC:CentralProjectIdentity 2026-07-13-23:56: + * Directive coverage: dashboard route project resolution must operate on an + * EXPLICIT central-registry id — request projectId → registered launch project + * id (options.engine.getProjectId()) → raw launch-dir store only for an + * unregistered/legacy launch directory. A resolved id always binds through the + * engine store (live engine) or getOrCreateProjectStore; the launch project + * reuses the injected registry-bound store rather than booting a duplicate pool. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Request } from "express"; +import type { TaskStore } from "@fusion/core"; +import type { ServerOptions } from "../server.js"; +import * as projectStoreResolver from "../project-store-resolver.js"; +import { + resolveRequestProjectId, + resolveStoreForProjectId, + getScopedStore, + getProjectContext, +} from "../routes/context.js"; +import { resolveScopedStore } from "../server.js"; + +function tag(name: string): TaskStore { + return { __tag: name } as unknown as TaskStore; +} + +function makeReq(projectId?: string): Request { + return { query: projectId ? { projectId } : {}, body: {} } as unknown as Request; +} + +/** Fake registered launch engine with an explicit central-registry project id. */ +function makeEngine(projectId: string, taskStore: TaskStore) { + return { + getProjectId: () => projectId, + getTaskStore: () => taskStore, + } as unknown as NonNullable; +} + +describe("routes/context central project identity seam", () => { + const rawLaunchStore = tag("raw-launch-store"); + const boundStore = tag("bound-store"); + + beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockResolvedValue(boundStore); + }); + + it("(c) no request id and no engine → raw launch-dir store with a one-time warn (legacy)", async () => { + const warn = vi.fn(); + const options = { runtimeLogger: { warn, info: vi.fn(), error: vi.fn(), child: vi.fn(), scope: "t" } } as unknown as ServerOptions; + + expect(resolveRequestProjectId(makeReq(), options)).toBeUndefined(); + + const store = await getScopedStore(makeReq(), rawLaunchStore, options); + expect(store).toBe(rawLaunchStore); + expect(projectStoreResolver.getOrCreateProjectStore).not.toHaveBeenCalled(); + // One-time per process (this module instance): first no-id request warns. + expect(warn).toHaveBeenCalledTimes(1); + + // Warn does not re-fire on a subsequent no-id request. + await getScopedStore(makeReq(), rawLaunchStore, options); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it("(a) no request id but a registered launch engine → resolves the engine's projectId and its store, not the raw store", async () => { + const engineStore = tag("engine-store"); + const options = { engine: makeEngine("launch-proj", engineStore) } as unknown as ServerOptions; + + expect(resolveRequestProjectId(makeReq(), options)).toBe("launch-proj"); + + const ctx = await getProjectContext(makeReq(), rawLaunchStore, options); + expect(ctx.projectId).toBe("launch-proj"); + expect(ctx.store).toBe(engineStore); + expect(ctx.store).not.toBe(rawLaunchStore); + expect(ctx.engine).toBe(options.engine); + + // Launch project reuses the injected registry-bound store — no duplicate boot. + const scoped = await getScopedStore(makeReq(), rawLaunchStore, options); + expect(scoped).toBe(rawLaunchStore); + expect(projectStoreResolver.getOrCreateProjectStore).not.toHaveBeenCalled(); + }); + + it("(b) explicit request projectId (not the launch project) → binds via getOrCreateProjectStore", async () => { + const options = { engine: makeEngine("launch-proj", tag("engine-store")) } as unknown as ServerOptions; + + const scoped = await getScopedStore(makeReq("other-proj"), rawLaunchStore, options); + expect(scoped).toBe(boundStore); + expect(projectStoreResolver.getOrCreateProjectStore).toHaveBeenCalledWith("other-proj"); + + const ctx = await getProjectContext(makeReq("other-proj"), rawLaunchStore, options); + expect(ctx.projectId).toBe("other-proj"); + expect(ctx.store).toBe(boundStore); + }); + + it("(b') explicit request projectId with a live engine in engineManager → binds the engine store", async () => { + const engineStore = tag("mgr-engine-store"); + const engine = { getTaskStore: () => engineStore }; + const onProjectAccessed = vi.fn(); + const engineManager = { + getEngine: (id: string) => (id === "mgr-proj" ? engine : undefined), + onProjectAccessed, + } as unknown as NonNullable; + const options = { engineManager } as unknown as ServerOptions; + + const scoped = await getScopedStore(makeReq("mgr-proj"), rawLaunchStore, options); + expect(scoped).toBe(engineStore); + expect(projectStoreResolver.getOrCreateProjectStore).not.toHaveBeenCalled(); + + const ctx = await getProjectContext(makeReq("mgr-proj"), rawLaunchStore, options); + expect(ctx.store).toBe(engineStore); + expect(ctx.engine).toBe(engine); + expect(ctx.projectId).toBe("mgr-proj"); + }); + + /* + * FNXC:CentralProjectIdentity 2026-07-14-00:15: + * F4 dedup coverage — server.ts resolveScopedStore now delegates to the single + * shared resolveStoreForProjectId core. These assert both entry points share + * identical id→store semantics. + */ + describe("(F4) shared resolveStoreForProjectId / server.ts resolveScopedStore delegation", () => { + it("no id + no launch id → raw injected store", async () => { + const options = {} as unknown as ServerOptions; + const store = await resolveScopedStore(undefined, rawLaunchStore, undefined, undefined, options); + expect(store).toBe(rawLaunchStore); + expect(projectStoreResolver.getOrCreateProjectStore).not.toHaveBeenCalled(); + + // Same result from the shared core directly. + expect(await resolveStoreForProjectId(undefined, rawLaunchStore, options)).toBe(rawLaunchStore); + }); + + it("no id + launch id → injected (registry-bound) store, no duplicate boot", async () => { + const engineStore = tag("engine-store"); + const options = { engine: makeEngine("launch-proj", engineStore) } as unknown as ServerOptions; + const store = await resolveScopedStore(undefined, rawLaunchStore, undefined, "launch-proj", options); + expect(store).toBe(rawLaunchStore); + expect(projectStoreResolver.getOrCreateProjectStore).not.toHaveBeenCalled(); + }); + + it("explicit id with a live engineManager engine → engine store", async () => { + const engineStore = tag("mgr-engine-store"); + const engine = { getTaskStore: () => engineStore }; + const engineManager = { + getEngine: (id: string) => (id === "mgr-proj" ? engine : undefined), + onProjectAccessed: vi.fn(), + } as unknown as NonNullable; + const options = { engineManager } as unknown as ServerOptions; + + const store = await resolveScopedStore("mgr-proj", rawLaunchStore, engineManager, undefined, options); + expect(store).toBe(engineStore); + expect(projectStoreResolver.getOrCreateProjectStore).not.toHaveBeenCalled(); + }); + + it("explicit id ≠ launch, getEngine undefined → getOrCreateProjectStore", async () => { + const options = { engine: makeEngine("launch-proj", tag("engine-store")) } as unknown as ServerOptions; + const store = await resolveScopedStore("other-proj", rawLaunchStore, undefined, "launch-proj", options); + expect(store).toBe(boundStore); + expect(projectStoreResolver.getOrCreateProjectStore).toHaveBeenCalledWith("other-proj"); + }); + }); + + it("(F8-b) getScopedStore with engineManager present but getEngine undefined for an explicit non-launch id → getOrCreateProjectStore", async () => { + const engineManager = { + getEngine: () => undefined, + onProjectAccessed: vi.fn(), + } as unknown as NonNullable; + const options = { engineManager } as unknown as ServerOptions; + + const scoped = await getScopedStore(makeReq("other-proj"), rawLaunchStore, options); + expect(scoped).toBe(boundStore); + expect(projectStoreResolver.getOrCreateProjectStore).toHaveBeenCalledWith("other-proj"); + }); + + it("(F9) whitespace-only projectId in query is treated as absent → launch fallback", async () => { + // No engine: whitespace id resolves to no id at all. + const noEngineOptions = {} as unknown as ServerOptions; + expect(resolveRequestProjectId(makeReq(" "), noEngineOptions)).toBeUndefined(); + + // With a registered launch engine: whitespace id falls through to the launch id, + // and the launch project reuses the injected store (no getOrCreateProjectStore). + const engineStore = tag("engine-store"); + const options = { engine: makeEngine("launch-proj", engineStore) } as unknown as ServerOptions; + expect(resolveRequestProjectId(makeReq(" "), options)).toBe("launch-proj"); + + const scoped = await getScopedStore(makeReq(" "), rawLaunchStore, options); + expect(scoped).toBe(rawLaunchStore); + expect(projectStoreResolver.getOrCreateProjectStore).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/dashboard/src/evals-routes.ts b/packages/dashboard/src/evals-routes.ts index fb30168c28..2d864cb788 100644 --- a/packages/dashboard/src/evals-routes.ts +++ b/packages/dashboard/src/evals-routes.ts @@ -3,6 +3,8 @@ import type { NextFunction, Request, Response } from "express"; import { AsyncLocalStorage } from "node:async_hooks"; import type { EvalRun, EvalTaskResult, TaskStore } from "@fusion/core"; import { ApiError, badRequest, notFound } from "./api-error.js"; +import { getScopedStore as resolveScopedRequestStore } from "./routes/context.js"; +import type { ServerOptions } from "./server.js"; function rethrowAsApiError(error: unknown, fallbackMessage = "Internal server error"): never { if (error instanceof ApiError) throw error; @@ -10,11 +12,6 @@ function rethrowAsApiError(error: unknown, fallbackMessage = "Internal server er throw new ApiError(500, fallbackMessage); } -function getProjectId(req: Request): string | undefined { - if (typeof req.query.projectId === "string" && req.query.projectId.trim()) return req.query.projectId; - return undefined; -} - function parseOptionalInt(value: unknown, key: string): number | undefined { if (value === undefined) return undefined; const parsed = Number(value); @@ -36,21 +33,28 @@ function normalizeEvalText(result: EvalTaskResult): string { ].filter((value): value is string => typeof value === "string" && value.trim().length > 0).join(" ").toLowerCase(); } -export function createEvalsRouter(store: TaskStore): Router { +export function createEvalsRouter(store: TaskStore, options?: ServerOptions): Router { const router = Router(); const requestContext = new AsyncLocalStorage(); router.use((req: Request, _res: Response, next: NextFunction) => { - const projectId = getProjectId(req); - if (projectId) { - import("./project-store-resolver.js").then(({ getOrCreateProjectStore }) => { - getOrCreateProjectStore(projectId).then((scopedStore) => { - requestContext.run(scopedStore, () => next()); - }).catch((err) => rethrowAsApiError(err, "Failed to get project store")); + // FNXC:CentralProjectIdentity 2026-07-13-23:54: + // Resolve an explicit central-registry project id via the shared seam + // (request id → registered launch project id → raw launch store last resort). + // FNXC:CentralProjectIdentity 2026-07-14-00:15: + // Catch-and-FORWARD via next(): rethrowAsApiError throws, and a throw inside this + // detached promise chain escapes Express (it is not the request's synchronous + // call stack), so a store-resolution failure would hang the request instead of + // returning an error. Mirror the insights/goals routers' pattern. + resolveScopedRequestStore(req, store, options) + .then((scopedStore) => requestContext.run(scopedStore, () => next())) + .catch((err) => { + try { + rethrowAsApiError(err, "Failed to get project store"); + } catch (apiError) { + next(apiError); + } }); - return; - } - requestContext.run(store, () => next()); }); function getEvalStore() { diff --git a/packages/dashboard/src/goals-routes.ts b/packages/dashboard/src/goals-routes.ts index 1c857b5ff5..78d8016f62 100644 --- a/packages/dashboard/src/goals-routes.ts +++ b/packages/dashboard/src/goals-routes.ts @@ -16,7 +16,8 @@ import { Router, type Request, type Response } from "express"; import { AsyncLocalStorage } from "node:async_hooks"; import type { Goal, GoalStatus, GoalUpdateInput, Mission, TaskStore } from "@fusion/core"; import { ApiError, badRequest, catchHandler, conflict, internalError, notFound } from "./api-error.js"; -import { getOrCreateProjectStore } from "./project-store-resolver.js"; +import { getScopedStore as resolveScopedRequestStore } from "./routes/context.js"; +import type { ServerOptions } from "./server.js"; // FNXC:GoalStore 2026-06-27-18:10: // getGoalStore() returns GoalStore | AsyncGoalStore (sync SQLite vs PG-backed). @@ -36,16 +37,6 @@ type GoalStoreLike = { const GOAL_ID_RE = /^G-[A-Z0-9]+(?:-[A-Z0-9]+)*$/i; const GOAL_STATUSES: GoalStatus[] = ["active", "archived"]; -function getProjectIdFromRequest(req: Request): string | undefined { - if (typeof req.query.projectId === "string" && req.query.projectId.trim()) { - return req.query.projectId; - } - if (req.body && typeof req.body === "object" && typeof req.body.projectId === "string" && req.body.projectId.trim()) { - return req.body.projectId; - } - return undefined; -} - function getGoalStore(store: TaskStore): GoalStoreLike { // FNXC:GoalStore 2026-06-27-18:10: // GoalStore is now ported (AsyncGoalStore in PG backend mode); the interim PG @@ -115,7 +106,7 @@ function rethrowGoalCapError(error: unknown): never { throw internalError("Internal server error"); } -export function createGoalsRouter(store: TaskStore): Router { +export function createGoalsRouter(store: TaskStore, options?: ServerOptions): Router { const router = Router(); const requestContext = new AsyncLocalStorage(); @@ -125,8 +116,10 @@ export function createGoalsRouter(store: TaskStore): Router { router.use(async (req: Request, _res: Response, next) => { try { - const projectId = getProjectIdFromRequest(req); - const scopedStore = projectId ? await getOrCreateProjectStore(projectId) : store; + // FNXC:CentralProjectIdentity 2026-07-13-23:54: + // Resolve an explicit central-registry project id via the shared seam + // (request id → registered launch project id → raw launch store last resort). + const scopedStore = await resolveScopedRequestStore(req, store, options); requestContext.run(scopedStore, next); } catch (error) { next(error); diff --git a/packages/dashboard/src/insights-routes.ts b/packages/dashboard/src/insights-routes.ts index b77081e3f7..73d4d3b9bd 100644 --- a/packages/dashboard/src/insights-routes.ts +++ b/packages/dashboard/src/insights-routes.ts @@ -46,6 +46,8 @@ import { sweepStaleInsightRuns, } from "./insight-run-sweeper.js"; import { createFnAgent, promptWithFallback, resolveMcpServersForStore, resolvePlanningThinkingLevel } from "@fusion/engine"; +import { getScopedStore as resolveScopedRequestStore } from "./routes/context.js"; +import type { ServerOptions } from "./server.js"; /** * Re-throws an error as an ApiError, converting unknown errors to internal errors. @@ -273,7 +275,7 @@ function toInsightTitle(content: string): string { /** * Create the insights router. */ -export function createInsightsRouter(store: TaskStore): Router { +export function createInsightsRouter(store: TaskStore, options?: ServerOptions): Router { const router = Router(); const requestContext = new AsyncLocalStorage(); @@ -325,11 +327,10 @@ export function createInsightsRouter(store: TaskStore): Router { */ router.use(async (req: Request, res: Response, next: NextFunction) => { try { - const projectId = getProjectId(req); - const scopedStore = projectId - // Import here to avoid circular dependency issues - ? await import("./project-store-resolver.js").then(({ getOrCreateProjectStore }) => getOrCreateProjectStore(projectId)) - : store; + // FNXC:CentralProjectIdentity 2026-07-13-23:54: + // Resolve an explicit central-registry project id via the shared seam + // (request id → registered launch project id → raw launch store last resort). + const scopedStore = await resolveScopedRequestStore(req, store, options); requestContext.run(scopedStore, () => { next(); }); diff --git a/packages/dashboard/src/mission-routes.ts b/packages/dashboard/src/mission-routes.ts index d9f9cf10a3..f62a379a9c 100644 --- a/packages/dashboard/src/mission-routes.ts +++ b/packages/dashboard/src/mission-routes.ts @@ -17,7 +17,11 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { TaskStore, resolvePlanningSettingsModel, AgentStore, THINKING_LEVELS } from "@fusion/core"; import type { Goal, ThinkingLevel } from "@fusion/core"; import { listEligibleExecutorAgents } from "@fusion/engine"; -import { getOrCreateProjectStore } from "./project-store-resolver.js"; +import { + getScopedStore as resolveScopedRequestStore, + getProjectContext as resolveSharedProjectContext, +} from "./routes/context.js"; +import type { ServerOptions } from "./server.js"; import type { Mission, MissionBranchStrategy, @@ -290,20 +294,11 @@ export function createMissionRouter( }, engineManager?: import("@fusion/engine").ProjectEngineManager, pluginRunner?: Parameters[3], + options?: ServerOptions, ): Router { const router = Router(); const requestContext = new AsyncLocalStorage(); - function getProjectIdFromRequest(req: Request): string | undefined { - if (typeof req.query.projectId === "string" && req.query.projectId.trim()) { - return req.query.projectId; - } - if (req.body && typeof req.body === "object" && typeof req.body.projectId === "string" && req.body.projectId.trim()) { - return req.body.projectId; - } - return undefined; - } - function getScopedStore(): TaskStore { return requestContext.getStore() ?? store; } @@ -414,8 +409,10 @@ export function createMissionRouter( router.use(async (req, _res, next) => { try { - const projectId = getProjectIdFromRequest(req); - const scopedStore = projectId ? await getOrCreateProjectStore(projectId) : store; + // FNXC:CentralProjectIdentity 2026-07-13-23:54: + // Resolve an explicit central-registry project id via the shared seam + // (request id → registered launch project id → raw launch store last resort). + const scopedStore = await resolveScopedRequestStore(req, store, options); requestContext.run(scopedStore, next); } catch (error) { next(error); @@ -508,26 +505,18 @@ export function createMissionRouter( /** * Helper to resolve scoped store for the current request's project scope. */ - async function getScopedStoreForRequest(req: Request) { - const projectId = getProjectIdFromRequest(req); - return projectId ? await getOrCreateProjectStore(projectId) : store; - } - /** * Helper to resolve project context for the current request. * When engineManager is available and the request targets a known project, * returns the engine's TaskStore so callers share the same in-memory state. + * + * FNXC:CentralProjectIdentity 2026-07-13-23:54: + * Delegates to the shared seam so identity always resolves an explicit + * central-registry id (request id → registered launch project id) instead of + * the implicit raw-store fallback. */ async function getProjectContext(req: Request) { - const projectId = getProjectIdFromRequest(req); - if (projectId && engineManager) { - const engine = engineManager.getEngine(projectId); - if (engine) { - return { store: engine.getTaskStore(), engine, projectId }; - } - } - const scopedStore = await getScopedStoreForRequest(req); - return { store: scopedStore, engine: undefined, projectId }; + return resolveSharedProjectContext(req, store, options); } /** @@ -762,9 +751,11 @@ export function createMissionRouter( router.get( "/interview/drafts", catchTypedHandler(async (req, res) => { - const projectId = typeof req.query.projectId === "string" && req.query.projectId.trim().length > 0 - ? req.query.projectId.trim() - : undefined; + // FNXC:CentralProjectIdentity 2026-07-14-00:15: + // Read drafts under the SAME resolved id that POST /interview/start stamped on + // write (request id → registered launch project id). Filtering by the raw + // request projectId (undefined on a launch-dir request) hid launch-owned drafts. + const { projectId } = await getProjectContext(req); const { listMissionInterviewDrafts } = await import("./mission-interview.js"); res.json({ drafts: await listMissionInterviewDrafts(projectId) }); }) @@ -777,9 +768,10 @@ export function createMissionRouter( const tabId = typeof req.body?.tabId === "string" && req.body.tabId.trim().length > 0 ? req.body.tabId.trim() : undefined; - const projectId = typeof req.query.projectId === "string" && req.query.projectId.trim().length > 0 - ? req.query.projectId.trim() - : undefined; + // FNXC:CentralProjectIdentity 2026-07-14-00:15: + // Discard against the SAME resolved id writes stamped (request id → launch id), + // matching GET /interview/drafts, so a launch-dir discard finds the session. + const { projectId } = await getProjectContext(req); if (!sessionId || typeof sessionId !== "string") { throw badRequest("sessionId is required"); diff --git a/packages/dashboard/src/research-routes.ts b/packages/dashboard/src/research-routes.ts index a587293502..f18eb76dd0 100644 --- a/packages/dashboard/src/research-routes.ts +++ b/packages/dashboard/src/research-routes.ts @@ -13,6 +13,8 @@ import { type ResearchRunStatus, } from "@fusion/core"; import { ApiError, badRequest, notFound } from "./api-error.js"; +import { getScopedStore as resolveScopedRequestStore } from "./routes/context.js"; +import type { ServerOptions } from "./server.js"; const DEFAULT_AVAILABILITY = { available: true, @@ -36,12 +38,6 @@ function rethrowAsApiError(error: unknown, fallback = "Internal server error"): throw new ApiError(500, fallback, { code: "INTERNAL_ERROR" }); } -function getProjectId(req: Request): string | undefined { - if (typeof req.query.projectId === "string" && req.query.projectId.trim()) return req.query.projectId; - if (req.body && typeof req.body === "object" && typeof req.body.projectId === "string" && req.body.projectId.trim()) return req.body.projectId; - return undefined; -} - function toRunListItem(run: ResearchRun) { return { id: run.id, @@ -134,21 +130,28 @@ async function addFindingAttachment( } } -export function createResearchRouter(store: TaskStore): Router { +export function createResearchRouter(store: TaskStore, options?: ServerOptions): Router { const router = Router(); const requestContext = new AsyncLocalStorage(); router.use((req: Request, _res: Response, next: NextFunction) => { - const projectId = getProjectId(req); - if (!projectId) { - requestContext.run(store, () => next()); - return; - } - - import("./project-store-resolver.js") - .then(({ getOrCreateProjectStore }) => getOrCreateProjectStore(projectId)) + // FNXC:CentralProjectIdentity 2026-07-13-23:54: + // Resolve an explicit central-registry project id via the shared seam + // (request id → registered launch project id → raw launch store last resort). + // FNXC:CentralProjectIdentity 2026-07-14-00:15: + // Catch-and-FORWARD via next(): rethrowAsApiError throws, and a throw inside this + // detached promise chain escapes Express (not the request's synchronous call + // stack), so a store-resolution failure would hang the request. Mirror the + // insights/goals routers' pattern. + resolveScopedRequestStore(req, store, options) .then((scopedStore) => requestContext.run(scopedStore, () => next())) - .catch((error) => rethrowAsApiError(error, "Failed to resolve project store")); + .catch((error) => { + try { + rethrowAsApiError(error, "Failed to resolve project store"); + } catch (apiError) { + next(apiError); + } + }); }); // FNXC:ResearchStore 2026-06-27-12:20: diff --git a/packages/dashboard/src/routes/context.ts b/packages/dashboard/src/routes/context.ts index b57721e91d..cd1e39755d 100644 --- a/packages/dashboard/src/routes/context.ts +++ b/packages/dashboard/src/routes/context.ts @@ -56,20 +56,106 @@ export function classifyRemoteRouteError(error: unknown): RemoteRouteErrorClassi }; } +// FNXC:CentralProjectIdentity 2026-07-14-00:15: normalize with .trim() and treat a +// whitespace-only projectId as absent so it falls through to the launch-id resolution +// instead of binding a bogus id (restores strictness the seam replaced). export function getProjectIdFromRequest(req: Request): string | undefined { - if (req.query && typeof req.query.projectId === "string" && req.query.projectId.length > 0) { - return req.query.projectId; + if (req.query && typeof req.query.projectId === "string" && req.query.projectId.trim().length > 0) { + return req.query.projectId.trim(); } - if (req.body && typeof req.body.projectId === "string" && req.body.projectId.length > 0) { - return req.body.projectId; + if (req.body && typeof req.body.projectId === "string" && req.body.projectId.trim().length > 0) { + return req.body.projectId.trim(); } return undefined; } -export async function getScopedStore(req: Request, store: TaskStore): Promise { - const projectId = getProjectIdFromRequest(req); - if (!projectId) return store; - return getOrCreateProjectStore(projectId); +/* +FNXC:CentralProjectIdentity 2026-07-13-23:50: +Directive: dashboard API requests operate on an EXPLICIT central-registry project id, never a silent bind to the raw launch-directory TaskStore. +Resolution order for a request's project identity: + 1. request `projectId` (query/body), + 2. else the daemon's registered launch project id (`options.engine.getProjectId()`), + 3. else undefined — the caller is an unregistered/legacy launch directory and the raw injected store is a last resort (see warnLaunchDirFallbackOnce). +Once an id resolves it ALWAYS flows through the same bound-store path used for explicit ids (engine store when a live engine exists, else getOrCreateProjectStore), so store identity always comes from the central registry — with one dedup: the launch project reuses the injected registry-bound store instead of booting a second connection pool for the same id. +*/ +export function resolveRequestProjectId(req: Request, options?: ServerOptions): string | undefined { + return getProjectIdFromRequest(req) ?? options?.engine?.getProjectId?.() ?? undefined; +} + +/* +FNXC:CentralProjectIdentity 2026-07-13-23:51: +The launch-dir raw-store fallback is legacy behavior preserved ONLY for unregistered directories (no request id AND no registered launch engine). Warn once per process (not per request) so the signal is visible without flooding logs; a single flag is shared with server.ts's resolveScopedStore via this exported helper. +*/ +let warnedLaunchDirFallback = false; +export function warnLaunchDirFallbackOnce(options?: ServerOptions): void { + if (warnedLaunchDirFallback) return; + warnedLaunchDirFallback = true; + const message = + "project-scope fallback: request without projectId on an unregistered launch directory — using launch-dir store"; + const logger = options?.runtimeLogger; + if (logger?.warn) { + logger.warn(message); + } else { + console.warn(message); + } +} + +/* +FNXC:CentralProjectIdentity 2026-07-14-00:15: +Single id-based store-resolution core shared by both routes/context.ts getScopedStore +and server.ts resolveScopedStore (the realtime path). Deduplicates the previously +mirrored resolution so identity semantics can never drift between the two entry points. +Given an ALREADY-resolved id (request id → launch id folded in by the caller): + 1. no id → one-time launch-dir fallback warn + return the raw injected store, + 2. live engineManager engine for the id → its TaskStore, + 3. id === launch project id → reuse the injected registry-bound store (no duplicate pool), + 4. else → getOrCreateProjectStore(id). + +FNXC:CentralProjectIdentity 2026-07-14-00:15 (F6 caveat): +Launch-store reuse (step 3) assumes the injected store belongs to a live launch engine. +When an engineManager is present but getEngine(launchId) is undefined, this cannot +distinguish a launch engine that was never started (store still valid) from one that was +explicitly stopped/paused (store may be closed). ProjectEngineManager exposes no synchronous +engine-liveness/paused introspection (getEngine returns undefined for a stopped engine since +it is deleted from the engines map, and paused status lives async in CentralCore), so no +correct fall-through to getOrCreateProjectStore(launchId) is implementable here without +inventing an API. Behavior is preserved; a live engine store (step 2) always wins first. +*/ +export async function resolveStoreForProjectId( + resolvedId: string | undefined, + store: TaskStore, + options?: ServerOptions, +): Promise { + if (!resolvedId) { + warnLaunchDirFallbackOnce(options); + return store; + } + + const engineManager = options?.engineManager; + if (engineManager) { + const engine = engineManager.getEngine(resolvedId); + if (engine) { + return engine.getTaskStore(); + } + } + + // Launch project: the injected store is already registry-bound to this id, so + // reuse it instead of booting a duplicate connection pool via + // getOrCreateProjectStore. See the F6 caveat above. + if (options?.engine?.getProjectId?.() === resolvedId) { + return store; + } + + return getOrCreateProjectStore(resolvedId); +} + +export async function getScopedStore( + req: Request, + store: TaskStore, + options?: ServerOptions, +): Promise { + const projectId = resolveRequestProjectId(req, options); + return resolveStoreForProjectId(projectId, store, options); } export async function getProjectContext( @@ -77,10 +163,17 @@ export async function getProjectContext( store: TaskStore, options?: ServerOptions, ): Promise { - const projectId = getProjectIdFromRequest(req); + const projectId = resolveRequestProjectId(req, options); const engineManager = options?.engineManager; - if (projectId && engineManager) { + if (!projectId) { + // No request id and no registered launch engine: unregistered/legacy launch + // directory. Preserve the raw-store last resort with a one-time warn. + warnLaunchDirFallbackOnce(options); + return { store, engine: undefined, projectId: undefined }; + } + + if (engineManager) { const engine = engineManager.getEngine(projectId); if (!engine) { // Trigger lazy engine start as fire-and-forget so this request is not @@ -94,7 +187,11 @@ export async function getProjectContext( } } - if (!projectId && options?.engine) { + // Launch project: reuse the live launch engine + its registry-bound store + // rather than a duplicate boot. The resolved projectId is returned explicitly + // (never undefined) so downstream context is always attributable to a + // central-registry id. + if (options?.engine && options.engine.getProjectId?.() === projectId) { try { return { store: options.engine.getTaskStore(), engine: options.engine, projectId }; } catch { @@ -102,7 +199,7 @@ export async function getProjectContext( } } - const scopedStore = await getScopedStore(req, store); + const scopedStore = await getScopedStore(req, store, options); return { store: scopedStore, engine: undefined, projectId }; } @@ -170,7 +267,7 @@ export function createApiRoutesContext(store: TaskStore, options?: ServerOptions return [...projects].sort((a, b) => rankProject(b.path) - rankProject(a.path)); } - const resolveScopedStore = (req: Request): Promise => getScopedStore(req, store); + const resolveScopedStore = (req: Request): Promise => getScopedStore(req, store, options); const resolveProjectContext = (req: Request): Promise => getProjectContext(req, store, options); const disposeCallbacks: Array<() => void> = []; diff --git a/packages/dashboard/src/routes/register-chat-routes.ts b/packages/dashboard/src/routes/register-chat-routes.ts index 8a1ee161a4..ce17589d14 100644 --- a/packages/dashboard/src/routes/register-chat-routes.ts +++ b/packages/dashboard/src/routes/register-chat-routes.ts @@ -193,11 +193,25 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): } const agentId = `${TASK_PLANNER_CHAT_AGENT_ID_PREFIX}${task.id}`; - const existing = await chatStore.findLatestActiveSessionForTarget({ + let existing = await chatStore.findLatestActiveSessionForTarget({ agentId, ...(projectId ? { projectId } : {}), }); + // FNXC:CentralProjectIdentity 2026-07-14-00:15: + // ctx projectId now resolves to the launch id, so a projectId-filtered lookup + // misses legacy active planner sessions created with a null projectId → we'd + // create a duplicate. On a scoped miss, retry unscoped and reuse a matched + // legacy (null-projectId) session for this task-specific agent. The projectId + // is not stamped onto it: ChatSessionUpdateInput has no projectId field, so no + // clean update path exists — reusing it is enough to prevent the duplicate. + if (!existing && projectId) { + const legacy = await chatStore.findLatestActiveSessionForTarget({ agentId }); + if (legacy && legacy.projectId == null) { + existing = legacy; + } + } + if (existing) { const session = modelProvider && modelId ? await chatStore.updateSession(existing.id, { modelProvider, modelId }) @@ -736,8 +750,13 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): throw notFound(`Chat session ${sessionId} not found`); } + // FNXC:CentralProjectIdentity 2026-07-14-00:15: + // ctx projectId now resolves to the launch id, but legacy sessions stored a + // null/undefined projectId before scoping existed. Treat those as launch-owned + // so attaching to their in-flight stream is not spuriously 404'd; only reject a + // session that is explicitly stamped with a DIFFERENT project id. const { projectId } = await getProjectContext(req); - if (projectId !== undefined && session.projectId !== projectId) { + if (projectId !== undefined && session.projectId != null && session.projectId !== projectId) { throw notFound(`Chat session ${sessionId} not found`); } diff --git a/packages/dashboard/src/routes/register-integrated-routers.ts b/packages/dashboard/src/routes/register-integrated-routers.ts index acf87e5dfa..cfff90b2a3 100644 --- a/packages/dashboard/src/routes/register-integrated-routers.ts +++ b/packages/dashboard/src/routes/register-integrated-routers.ts @@ -38,15 +38,20 @@ export function registerIntegratedRouters({ }: IntegratedRoutersOptions): void { router.use( "/missions", - createMissionRouter(store, options?.missionAutopilot, aiSessionStore, options?.missionExecutionLoop, options?.engineManager, options?.pluginRunner as Parameters[3]), + createMissionRouter(store, options?.missionAutopilot, aiSessionStore, options?.missionExecutionLoop, options?.engineManager, options?.pluginRunner as Parameters[3], options), ); - router.use("/insights", createInsightsRouter(store)); - router.use("/evals", createEvalsRouter(store)); - router.use("/research", createResearchRouter(store)); + // FNXC:CentralProjectIdentity 2026-07-13-23:55: + // Thread ServerOptions into the project-scoped routers so their request + // middleware resolves an explicit central-registry project id (request id → + // registered launch project id) via the shared seam instead of the implicit + // raw launch-dir store fallback. + router.use("/insights", createInsightsRouter(store, options)); + router.use("/evals", createEvalsRouter(store, options)); + router.use("/research", createResearchRouter(store, options)); router.use("/experiments", createExperimentRouter(store)); - router.use("/todos", createTodoRouter(store)); - router.use("/goals", createGoalsRouter(store)); + router.use("/todos", createTodoRouter(store, options)); + router.use("/goals", createGoalsRouter(store, options)); router.use("/roadmaps", createRoadmapCompatibilityRouter(store)); router.use("/stash-recovery", createStashRecoveryRouter(store)); // T7: resolve the per-project working directory so the group-PR helpers (which diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts index b25b9fc56b..c343d40847 100644 --- a/packages/dashboard/src/server.ts +++ b/packages/dashboard/src/server.ts @@ -69,7 +69,7 @@ import type { SkillsAdapter } from "./skills-adapter.js"; import { createAuthMiddleware, authenticateUpgradeRequest, getDaemonToken } from "./auth-middleware.js"; import { setupCliSessionWebSocket } from "./cli-session-ws.js"; import { createCliSessionsRouter } from "./routes/cli-sessions.js"; -import { getProjectIdFromRequest } from "./routes/context.js"; +import { getProjectIdFromRequest, resolveStoreForProjectId } from "./routes/context.js"; import type { CliRelaunchRegistry } from "./cli-session-transport.js"; import { validateRemoteAuthToken } from "./remote-auth.js"; import { getCliPackageVersion, isUnresolvedCliPackageVersion } from "./cli-package-version.js"; @@ -202,19 +202,26 @@ export async function resolveScopedStore( projectId: string | undefined, store: TaskStore, engineManager?: import("@fusion/engine").ProjectEngineManager, + launchProjectId?: string, + // FNXC:CentralProjectIdentity 2026-07-13-23:55: thread the caller's ServerOptions so + // the shared one-time launch-dir fallback warning routes through runtimeLogger + // instead of console.warn and isn't consumed on the unstructured sink first. + options?: ServerOptions, ): Promise { - if (!projectId) { - return store; - } - - if (engineManager) { - const engine = engineManager.getEngine(projectId); - if (engine) { - return engine.getTaskStore(); - } - } - - return await getOrCreateProjectStore(projectId); + /* + FNXC:CentralProjectIdentity 2026-07-14-00:15: + Realtime scoped-store resolution delegates to the SINGLE shared id-based core + (routes/context.ts resolveStoreForProjectId) so the realtime path and route path + can never drift. This exported signature (separate engineManager/launchProjectId + params) is retained for caller compatibility; callers derive both from `options` + (engineManager = options.engineManager, launchProjectId = options.engine.getProjectId()), + so folding `projectId ?? launchProjectId` into the shared core is equivalent. + Note: engineManager/launchProjectId params below are consumed via `options` inside + the shared core; they remain in the signature only for backwards compatibility. + */ + void engineManager; + void launchProjectId; + return resolveStoreForProjectId(projectId ?? launchProjectId, store, options); } export interface ServerOptions { @@ -1223,7 +1230,11 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT * Uses module-level resolveScopedStore with current closure context. */ async function resolveProjectScopedStore(projectId: string | undefined): Promise { - return resolveScopedStore(projectId, store, options?.engineManager); + // FNXC:CentralProjectIdentity 2026-07-13-23:53: + // Thread the daemon's registered launch project id so a realtime request + // without projectId resolves the explicit launch project (registry-bound + // injected store) rather than the implicit raw-store fallback. + return resolveScopedStore(projectId, store, options?.engineManager, options?.engine?.getProjectId?.(), options); } // Per-task SSE endpoint for live agent log streaming @@ -2326,7 +2337,7 @@ export function setupTerminalWebSocket( try { if (projectId) { // When projectId is provided, resolve the scoped store and get its root dir - const scopedStore = await resolveScopedStore(projectId, store, options?.engineManager); + const scopedStore = await resolveScopedStore(projectId, store, options?.engineManager, options?.engine?.getProjectId?.(), options); scopedRootDir = scopedStore.getRootDir(); terminalService = getTerminalService(scopedRootDir); } else { @@ -2560,7 +2571,7 @@ export function setupBadgeWebSocket( } // Create scoped store - scopedStore = await resolveScopedStore(projectId, store, options?.engineManager); + scopedStore = await resolveScopedStore(projectId, store, options?.engineManager, options?.engine?.getProjectId?.(), options); scopedStores.set(projectId, scopedStore); return scopedStore; }; diff --git a/packages/dashboard/src/todo-routes.ts b/packages/dashboard/src/todo-routes.ts index 59bcaf632f..2f958ad9e4 100644 --- a/packages/dashboard/src/todo-routes.ts +++ b/packages/dashboard/src/todo-routes.ts @@ -7,7 +7,8 @@ import { notFound, internalError, } from "./api-error.js"; -import { getOrCreateProjectStore } from "./project-store-resolver.js"; +import { getScopedStore as resolveScopedRequestStore } from "./routes/context.js"; +import type { ServerOptions } from "./server.js"; function rethrowAsApiError(error: unknown, fallbackMessage = "Internal server error"): never { if (error instanceof ApiError) { @@ -56,7 +57,7 @@ function validateStringArray(arr: unknown, fieldName: string): string[] { return arr; } -export function createTodoRouter(store: TaskStore): Router { +export function createTodoRouter(store: TaskStore, options?: ServerOptions): Router { const router = Router(); const requestContext = new AsyncLocalStorage(); @@ -77,8 +78,11 @@ export function createTodoRouter(store: TaskStore): Router { router.use(async (req: Request, _res: Response, next) => { try { - const projectId = getProjectIdFromRequest(req); - const scopedStore = projectId ? await getOrCreateProjectStore(projectId) : store; + // FNXC:CentralProjectIdentity 2026-07-13-23:54: + // Resolve an explicit central-registry project id (request id → registered + // launch project id → raw launch store as last resort) via the shared seam, + // replacing the implicit projectId?getOrCreate:store fallback. + const scopedStore = await resolveScopedRequestStore(req, store, options); requestContext.run(scopedStore, next); } catch (error) { next(error);