dashboard: mount plugin-defined routes with project-scoped TaskStore

Phase-1 telemetry-watcher's grafana-webhook handler 401'd because the
dashboard never mounted plugin-supplied routes — getPluginRoutes()
exists on PluginLoader but no caller consumed it. The smoke test was
working around this by injecting incident tasks directly through
/api/tasks; we want the real path to work end-to-end.

Two changes:

1. PluginLoader gains createContextFor(pluginId, { taskStore? }).
   Lifecycle hooks still see the loader's bound store (the cwd
   project), but REST handlers receive a project-scoped store derived
   from the request's projectId so a Grafana webhook addressed to
   sase opens tasks in sase even though fusion's loader is bound
   to its own cwd. Settings still come from the loader's store at
   load time, which is the right thing — settings don't follow the
   request.

2. routes.ts iterates pluginLoader.getPluginRoutes() once at server
   startup and binds /api/plugins/:pluginId/:routePath to a handler
   that resolves project context per request, builds the context via
   createContextFor, and forwards to the plugin's route. ApiError +
   rethrowAsApiError preserve the dashboard's standard error envelope.

Plugins added after server start still need a restart for routes to
bind; reloadPlugin doesn't currently re-mount Express handlers. That
limitation matches the existing constraint and is out of scope here.
This commit is contained in:
Semih
2026-05-10 08:56:48 +00:00
parent 3947528fec
commit 2d43ac7bd2
2 changed files with 93 additions and 6 deletions

View File

@@ -100,15 +100,49 @@ export class PluginLoader extends EventEmitter<{
// ── Context Creation ───────────────────────────────────────────────
private async createContext(plugin: FusionPlugin): Promise<PluginContext> {
return this.buildContext(plugin.manifest.id);
}
/**
* Build a PluginContext for a loaded plugin, optionally substituting a
* different TaskStore. The dashboard's plugin REST handler uses this to
* inject a project-scoped TaskStore at request time so plugin routes
* created outside the loader's primary project still write to the right
* project's task data.
*
* Loader is bound to a single TaskStore at construction (the cwd project).
* Without an override, hooks and tools see that store, which is the right
* thing for lifecycle events (onTaskCreated/onTaskMoved) since those
* always fire from the loader's bound project. REST route handlers are
* the exception — they are servers receiving external traffic that may
* be addressing a different project.
*
* Throws when the plugin is not loaded.
*/
async createContextFor(
pluginId: string,
opts?: { taskStore?: TaskStore },
): Promise<PluginContext> {
const plugin = this.plugins.get(pluginId);
if (!plugin) {
throw new Error(`Plugin "${pluginId}" is not loaded`);
}
return this.buildContext(pluginId, opts?.taskStore);
}
private async buildContext(
pluginId: string,
taskStoreOverride?: TaskStore,
): Promise<PluginContext> {
return {
pluginId: plugin.manifest.id,
taskStore: this.options.taskStore,
settings: await this.getPluginSettings(plugin.manifest.id),
logger: this.createLogger(plugin.manifest.id),
pluginId,
taskStore: taskStoreOverride ?? this.options.taskStore,
settings: await this.getPluginSettings(pluginId),
logger: this.createLogger(pluginId),
emitEvent: (event: string, data: unknown) => {
this.emit("plugin:error", { pluginId: plugin.manifest.id, error: new Error(`Custom event: ${event}`) });
this.emit("plugin:error", { pluginId, error: new Error(`Custom event: ${event}`) });
// Custom events are logged but not surfaced as errors
log.log(`[plugin:${plugin.manifest.id}] Custom event: ${event}`, data);
log.log(`[plugin:${pluginId}] Custom event: ${event}`, data);
},
};
}

View File

@@ -3187,6 +3187,59 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
res.status(204).send();
});
// ── Plugin-Defined Routes ──────────────────────────────────────
//
// Each loaded plugin can register HTTP routes via FusionPlugin.routes; we
// mount them under /api/plugins/:pluginId/:routePath. The handler bridges
// the plugin's PluginContext to the request's project: when ?projectId=
// is supplied, we resolve the scoped TaskStore through getProjectContext
// and inject it as ctx.taskStore, so a plugin route can create tasks in
// the project the request addresses (not just the loader's bound cwd
// project). Plugin settings still come from the loader (which reads the
// cwd pluginStore at load time). Routes are mounted at server startup;
// adding a new plugin requires a server restart for its routes to bind.
//
if (options?.pluginLoader) {
const pluginLoader = options.pluginLoader;
const pluginRoutes = pluginLoader.getPluginRoutes();
for (const { pluginId, route } of pluginRoutes) {
const fullPath = `/plugins/${pluginId}${route.path.startsWith("/") ? route.path : `/${route.path}`}`;
const handler = async (req: Request, res: Response) => {
try {
let projectScopedTaskStore: import("@fusion/core").TaskStore | undefined;
try {
const { store: scopedStore } = await getProjectContext(req);
projectScopedTaskStore = scopedStore;
} catch {
// Falls back to the loader's bound TaskStore.
}
const ctx = await pluginLoader.createContextFor(pluginId, {
taskStore: projectScopedTaskStore,
});
const result = await route.handler(req as unknown, ctx);
res.json(result);
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
};
switch (route.method) {
case "GET":
router.get(fullPath, handler);
break;
case "POST":
router.post(fullPath, handler);
break;
case "PUT":
router.put(fullPath, handler);
break;
case "DELETE":
router.delete(fullPath, handler);
break;
}
}
}
// ── AI Session Routes (Background Tasks) ─────────────────────────────────
/**