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:
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) ─────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user