feat(FN-2540): extract shared route context and integrated router registrar
- Refactor dashboard routing to use a shared route context scaffold for provider-safe registration - Add register-integrated-routers helper and wire routes.ts to compose integrated routers cleanly - Update route documentation and route types to reflect the new context/registrar structure - Stabilize quick-entry clear assertion and add routing tests covering integrated router registration behavior
This commit is contained in:
@@ -6,9 +6,12 @@
|
||||
|
||||
All registrars receive `ApiRoutesContext` from `./types.ts`, built by `createApiRoutesContext()` in `./context.ts`.
|
||||
|
||||
Registrars should be typed as `ApiRouteRegistrar` so modules share one explicit registration contract.
|
||||
|
||||
The context centralizes cross-cutting dependencies so registrars preserve behavior without re-implementing plumbing:
|
||||
|
||||
- Request/project scoping: `getProjectIdFromRequest`, `getScopedStore`, `getProjectContext`
|
||||
- These are also exported from `context.ts` as canonical helpers for future extraction tasks.
|
||||
- Engine-aware fallback behavior for project-bound and root-store APIs
|
||||
- Runtime loggers and diagnostics emitters (`runtimeLogger`, `planningLogger`, `proxyLogger`, `chatLogger`)
|
||||
- Proxy/auth/audit helpers (`proxyToRemoteNode`, `emitRemoteRouteDiagnostic`, `emitAuthSyncAuditLog`)
|
||||
@@ -37,13 +40,15 @@ Express matches in registration order. Keep registrar and in-registrar route ord
|
||||
|
||||
If adding a new endpoint, place it in the domain registrar and verify it does not shadow existing handlers.
|
||||
|
||||
## Integration mounts that stay in `routes.ts`
|
||||
## Integrated routers
|
||||
|
||||
These routers remain mounted directly by the orchestrator and must keep their current prefixes/options wiring:
|
||||
Integrated routers are mounted through `register-integrated-routers.ts` and intentionally called from `routes.ts` at precedence-sensitive points:
|
||||
|
||||
- `createMissionRouter` → `/api/missions`
|
||||
- `createRoadmapRouter` → `/api/roadmaps`
|
||||
- `createInsightsRouter` → `/api/insights`
|
||||
- `createDevServerRouter` → `/api/dev-server`
|
||||
- `registerIntegratedRouters(...)` mounts:
|
||||
- `createMissionRouter` → `/api/missions`
|
||||
- `createRoadmapRouter` → `/api/roadmaps`
|
||||
- `createInsightsRouter` → `/api/insights`
|
||||
- `registerIntegratedDevServerRouter(...)` mounts:
|
||||
- `createDevServerRouter` → `/api/dev-server`
|
||||
|
||||
Do not re-home these mounts without explicit migration and regression coverage.
|
||||
Keep these calls in their current positions inside `createApiRoutes()` unless an explicit route-ordering migration is planned and regression-tested.
|
||||
|
||||
@@ -58,6 +58,48 @@ function classifyRemoteRouteError(error: unknown): {
|
||||
};
|
||||
}
|
||||
|
||||
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.body && typeof req.body.projectId === "string" && req.body.projectId.length > 0) {
|
||||
return req.body.projectId;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function getScopedStore(req: Request, store: TaskStore): Promise<TaskStore> {
|
||||
const projectId = getProjectIdFromRequest(req);
|
||||
if (!projectId) return store;
|
||||
return getOrCreateProjectStore(projectId);
|
||||
}
|
||||
|
||||
export async function getProjectContext(
|
||||
req: Request,
|
||||
store: TaskStore,
|
||||
options?: ServerOptions,
|
||||
): Promise<ProjectContext> {
|
||||
const projectId = getProjectIdFromRequest(req);
|
||||
const engineManager = options?.engineManager;
|
||||
|
||||
if (projectId && engineManager) {
|
||||
let engine = engineManager.getEngine(projectId);
|
||||
if (!engine) {
|
||||
try {
|
||||
engine = await engineManager.ensureEngine(projectId);
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
if (engine) {
|
||||
return { store: engine.getTaskStore(), engine, projectId };
|
||||
}
|
||||
}
|
||||
|
||||
const scopedStore = await getScopedStore(req, store);
|
||||
return { store: scopedStore, engine: undefined, projectId };
|
||||
}
|
||||
|
||||
export function createApiRoutesContext(store: TaskStore, options?: ServerOptions): ApiRoutesContext {
|
||||
const router = Router();
|
||||
const runtimeLogger = options?.runtimeLogger?.child("routes") ?? createRuntimeLogger("routes");
|
||||
@@ -88,43 +130,8 @@ export function createApiRoutesContext(store: TaskStore, options?: ServerOptions
|
||||
return [...projects].sort((a, b) => rankProject(b.path) - rankProject(a.path));
|
||||
}
|
||||
|
||||
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.body && typeof req.body.projectId === "string" && req.body.projectId.length > 0) {
|
||||
return req.body.projectId;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function getScopedStore(req: Request): Promise<TaskStore> {
|
||||
const projectId = getProjectIdFromRequest(req);
|
||||
if (!projectId) return store;
|
||||
return getOrCreateProjectStore(projectId);
|
||||
}
|
||||
|
||||
async function getProjectContext(req: Request): Promise<ProjectContext> {
|
||||
const projectId = getProjectIdFromRequest(req);
|
||||
const engineManager = options?.engineManager;
|
||||
|
||||
if (projectId && engineManager) {
|
||||
let engine = engineManager.getEngine(projectId);
|
||||
if (!engine) {
|
||||
try {
|
||||
engine = await engineManager.ensureEngine(projectId);
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
if (engine) {
|
||||
return { store: engine.getTaskStore(), engine, projectId };
|
||||
}
|
||||
}
|
||||
|
||||
const scopedStore = await getScopedStore(req);
|
||||
return { store: scopedStore, engine: undefined, projectId };
|
||||
}
|
||||
const resolveScopedStore = (req: Request): Promise<TaskStore> => getScopedStore(req, store);
|
||||
const resolveProjectContext = (req: Request): Promise<ProjectContext> => getProjectContext(req, store, options);
|
||||
|
||||
function emitRemoteRouteDiagnostic(input: RemoteRouteDiagnosticInput): void {
|
||||
const logger = runtimeLogger.child("remote-route").child(input.route);
|
||||
@@ -391,8 +398,8 @@ export function createApiRoutesContext(store: TaskStore, options?: ServerOptions
|
||||
chatLogger,
|
||||
prioritizeProjectsForCurrentDirectory,
|
||||
getProjectIdFromRequest,
|
||||
getScopedStore,
|
||||
getProjectContext,
|
||||
getScopedStore: resolveScopedStore,
|
||||
getProjectContext: resolveProjectContext,
|
||||
emitRemoteRouteDiagnostic,
|
||||
emitAuthSyncAuditLog,
|
||||
proxyToRemoteNode,
|
||||
|
||||
42
packages/dashboard/src/routes/register-integrated-routers.ts
Normal file
42
packages/dashboard/src/routes/register-integrated-routers.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Router } from "express";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import type { ServerOptions } from "../server.js";
|
||||
import { createMissionRouter } from "../mission-routes.js";
|
||||
import { createRoadmapRouter } from "../roadmap-routes.js";
|
||||
import { createInsightsRouter } from "../insights-routes.js";
|
||||
import { createDevServerRouter } from "../dev-server-routes.js";
|
||||
import type { AiSessionStore } from "../ai-session-store.js";
|
||||
|
||||
interface IntegratedRoutersOptions {
|
||||
router: Router;
|
||||
store: TaskStore;
|
||||
options?: ServerOptions;
|
||||
aiSessionStore?: AiSessionStore;
|
||||
}
|
||||
|
||||
interface DevServerRouterOptions {
|
||||
router: Router;
|
||||
store: TaskStore;
|
||||
}
|
||||
|
||||
export function registerIntegratedRouters({
|
||||
router,
|
||||
store,
|
||||
options,
|
||||
aiSessionStore,
|
||||
}: IntegratedRoutersOptions): void {
|
||||
router.use(
|
||||
"/missions",
|
||||
createMissionRouter(store, options?.missionAutopilot, aiSessionStore, options?.missionExecutionLoop, options?.engineManager),
|
||||
);
|
||||
|
||||
router.use("/roadmaps", createRoadmapRouter(store));
|
||||
router.use("/insights", createInsightsRouter(store));
|
||||
}
|
||||
|
||||
export function registerIntegratedDevServerRouter({ router, store }: DevServerRouterOptions): void {
|
||||
const devServerRouter = createDevServerRouter({
|
||||
projectRoot: store.getRootDir(),
|
||||
});
|
||||
router.use("/dev-server", devServerRouter);
|
||||
}
|
||||
@@ -54,3 +54,5 @@ export interface ApiRoutesContext {
|
||||
resolveRoutineRunner(req: Request, scope: ScopeValue | undefined): NonNullable<ServerOptions["routineRunner"]>;
|
||||
rethrowAsApiError(error: unknown, fallbackMessage?: string): never;
|
||||
}
|
||||
|
||||
export type ApiRouteRegistrar = (context: ApiRoutesContext) => void;
|
||||
|
||||
Reference in New Issue
Block a user