diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 7dabb9f94d..9d0eac1d8e 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -8,146 +8,51 @@ declare module "express" { } import multer from "multer"; import { resolve, sep, join, isAbsolute } from "node:path"; -import { fileURLToPath } from "node:url"; import * as nodeFs from "node:fs"; import os from "node:os"; import v8 from "node:v8"; -import type { AnthropicProviderRegistration, TaskStore, ScheduleType, ActivityEventType, ModelPreset, RoutineTriggerType, McpServerDefinition, ThinkingLevel } from "@fusion/core"; +import type { AnthropicProviderRegistration, TaskStore, ActivityEventType, ModelPreset, McpServerDefinition, ThinkingLevel } from "@fusion/core"; import { type Task, type PiExtensionEntry, type PiExtensionSettings, - AutomationStore, - AUTOMATION_SELECTABLE_TOOLS, THINKING_LEVELS, MemoryBackendError, - RoutineStore, discoverPiExtensions, findVitestProcessIds, getAvailableMemoryBytes, getFusionAgentDir, getLegacyPiAgentDir, - isWebhookTrigger, listAgentMemoryFiles, readAgentMemoryFile, - resolvePluginEntryPath, - resolveExecutionSettingsModel, resolveTitleSummarizerSettingsModel, resolveImportTranslateSettingsModel, writeAgentMemoryFile, validateMcpServerDefinitionDetailed, } from "@fusion/core"; import type { ServerOptions } from "./server.js"; -import { verifyWebhookSignature } from "./github-webhooks.js"; import { SESSION_CLEANUP_DEFAULT_MAX_AGE_MS, type AiSessionType } from "./ai-session-store.js"; import { getSession as getPlanningSession, cleanupSession as cleanupPlanningSession, normalizePlanningSummaryPayload } from "./planning.js"; import { getSubtaskSession, cleanupSubtaskSession } from "./subtask-breakdown.js"; import { getMissionInterviewSession, cleanupMissionInterviewSession } from "./mission-interview.js"; import { getTargetInterviewSession, cleanupTargetInterviewSession } from "./milestone-slice-interview.js"; -import { SessionEventBuffer, writeSSEEvent } from "./sse-buffer.js"; +import { writeSSEEvent } from "./sse-buffer.js"; import { ApiError, badRequest, - conflict, - internalError, notFound, rateLimited, rethrowAsApiError, sendErrorResponse, unauthorized, } from "./api-error.js"; -import { createPluginRouter, resolvePluginManifest } from "./plugin-routes.js"; +import { createPluginRouter } from "./plugin-routes.js"; import { fetchFromRemoteNode } from "./routes/register-settings-sync-helpers.js"; -import { hermesRuntimeMetadata } from "@fusion-plugin-examples/hermes-runtime"; -import { openclawRuntimeMetadata } from "@fusion-plugin-examples/openclaw-runtime"; - -// Bundled runtime metadata exposed in /api/plugins/runtimes even when the -// corresponding plugin has not been explicitly installed. Installed plugins -// override these entries by runtimeId. -const BUNDLED_PLUGIN_RUNTIMES: Array<{ - pluginId: string; - runtimeId: string; - name: string; - description?: string; - version: string; -}> = [ - { - pluginId: "fusion-plugin-hermes-runtime", - runtimeId: hermesRuntimeMetadata.runtimeId, - name: hermesRuntimeMetadata.name, - ...(hermesRuntimeMetadata.description ? { description: hermesRuntimeMetadata.description } : {}), - version: hermesRuntimeMetadata.version ?? "0.0.0", - }, - { - pluginId: "fusion-plugin-openclaw-runtime", - runtimeId: openclawRuntimeMetadata.runtimeId, - name: openclawRuntimeMetadata.name, - ...(openclawRuntimeMetadata.description ? { description: openclawRuntimeMetadata.description } : {}), - version: openclawRuntimeMetadata.version ?? "0.0.0", - }, - { - pluginId: "fusion-plugin-paperclip-runtime", - runtimeId: "paperclip", - name: "Paperclip Runtime", - description: "Drives a Paperclip agent via the wakeup + heartbeat-run REST API", - version: "1.0.0", - }, -]; -const BUNDLED_PLUGIN_IDS = new Set([ - "fusion-plugin-dependency-graph", - "fusion-plugin-reports", - "fusion-plugin-whatsapp-chat", - "fusion-plugin-roadmap", - "fusion-plugin-hermes-runtime", - "fusion-plugin-openclaw-runtime", - "fusion-plugin-paperclip-runtime", - "fusion-plugin-cursor-runtime", - "fusion-plugin-grok-runtime", - "fusion-plugin-claude-runtime", - "fusion-plugin-omp-runtime", - "fusion-plugin-cli-printing-press", - "fusion-plugin-compound-engineering", - "fusion-plugin-quality", -]); - -function extractBundledPluginId(pathInput: string): string | null { - const normalized = pathInput.replace(/\\/gu, "/").replace(/\/+$/u, "").trim(); - if (BUNDLED_PLUGIN_IDS.has(normalized)) { - return normalized; - } - - for (const pluginId of BUNDLED_PLUGIN_IDS) { - if (normalized.endsWith(`/plugins/${pluginId}`)) { - return pluginId; - } - } - - return null; -} - -function resolveBundledPluginDirInDashboard(pluginId: string): string | null { - const moduleDir = resolve(fileURLToPath(import.meta.url), ".."); - const dashboardPackageRoot = resolve(moduleDir, ".."); - const candidates = [ - join(dashboardPackageRoot, "dist", "plugins", pluginId), - join(dashboardPackageRoot, "plugins", pluginId), - join(dashboardPackageRoot, "..", "..", "plugins", pluginId), - ]; - - for (const candidate of candidates) { - if (nodeFs.existsSync(join(candidate, "manifest.json"))) { - return candidate; - } - } - - return null; -} import { createSessionDiagnostics } from "./ai-session-diagnostics.js"; import { createApiRoutesContext } from "./routes/context.js"; import { createRegistrarMounter } from "./routes/create-api-routes-mount-sequence.js"; -import type { ScopeValue } from "./routes/types.js"; import { registerTaskWorkflowRoutes } from "./routes/register-task-workflow-routes.js"; import { registerWorkflowRoutes } from "./routes/register-workflow-routes.js"; import { registerPlanningSubtaskRoutes } from "./routes/register-planning-subtask-routes.js"; @@ -384,16 +289,12 @@ async function discoverDashboardPiExtensions(cwd: string): Promise registerGitLabRoutes(routeContext)); registrarMounter.mount("registerFilesTerminalWorkspaceRoutes", () => registerFilesTerminalWorkspaceRoutes(routeContext)); registrarMounter.mount("registerAgentsProjectsNodesRoutes", () => registerAgentsProjectsNodesRoutes(routeContext)); - registrarMounter.mount("registerPluginsAutomationRoutes", () => registerPluginsAutomationRoutes(routeContext)); + registrarMounter.mount("registerPluginsAutomationRoutes", () => registerPluginsAutomationRoutes(routeContext, { parseLastEventId, replayBufferedSSE, getCreateFnAgent: () => createFnAgentForRefine })); registrarMounter.mount("registerApprovalRoutes", () => registerApprovalRoutes(routeContext)); registrarMounter.mount("registerWorktrunkRoutes", () => registerWorktrunkRoutes(routeContext)); @@ -2282,896 +2183,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout // authed like the rest of /api (the approving principal is the token holder). registrarMounter.mount("registerCliAgentSettingsRoutes", () => registerCliAgentSettingsRoutes(routeContext)); - // ── Automation / Scheduled Task Routes ──────────────────────────── - // - // Scope-aware endpoints: Accept `scope=global|project` query param or body field. - // - When scope=global: Operations target the global automation store - // - When scope=project: Operations target project-scoped automations (filtered by scope) - // - When scope is omitted: Legacy default behavior (global store, backward compatible) - // - // Error codes: - // - 400: Invalid scope value or validation failure - // - 404: Schedule not found - // - 503: Automation store unavailable - - // GET /automations — list all scheduled tasks (optionally filtered by scope) - router.get("/automations", async (req: Request, res: Response) => { - // Return empty array when no store available (legacy backward-compatible behavior) - if (!options?.automationStore) { - return res.json([]); - } - - try { - const scope = parseScopeParam(req); - const automationStore = resolveAutomationStore(req, scope); - - // Get all schedules and filter by scope if specified - // When scope is omitted, return all schedules (legacy behavior) - const allSchedules = await automationStore.listSchedules(); - if (scope) { - const filteredSchedules = allSchedules.filter((s) => s.scope === scope); - res.json(filteredSchedules); - } else { - res.json(allSchedules); - } - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - rethrowAsApiError(err); - } - }); - - // POST /automations — create a new schedule (with optional scope) - router.post("/automations", async (req: Request, res: Response) => { - const scope = parseScopeParam(req); - const automationStore = resolveAutomationStore(req, scope); - - try { - const { name, description, scheduleType, cronExpression, command, enabled, timeoutMs, steps } = req.body; - - // Validation - if (!name?.trim()) { - throw badRequest("Name is required"); - } - const hasSteps = Array.isArray(steps) && steps.length > 0; - if (!hasSteps && !command?.trim()) { - throw badRequest("Command is required when no steps are provided"); - } - const validTypes = ["hourly", "daily", "weekly", "monthly", "custom", "every15Minutes", "every30Minutes", "every2Hours", "every6Hours", "every12Hours", "weekdays"]; - if (!scheduleType || !validTypes.includes(scheduleType)) { - throw badRequest(`Invalid schedule type. Must be one of: ${validTypes.join(", ")}`); - } - if (scheduleType === "custom") { - if (!cronExpression?.trim()) { - throw badRequest("Cron expression is required for custom schedule type"); - } - if (!AutomationStore.isValidCron(cronExpression)) { - throw badRequest(`Invalid cron expression: "${cronExpression}"`); - } - } - // Validate steps if provided - if (hasSteps) { - const stepErr = validateAutomationSteps(steps); - if (stepErr) { - throw badRequest(stepErr); - } - } - - // Determine scope for the new schedule - // Default to "project" for backward compatibility when scope is omitted - const scheduleScope = scope ?? "project"; - - const schedule = await automationStore.createSchedule({ - name, - description, - scheduleType: scheduleType as ScheduleType, - cronExpression, - command: command ?? "", - enabled, - timeoutMs, - steps: hasSteps ? steps : undefined, - scope: scheduleScope, - }); - res.status(201).json(schedule); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - rethrowAsApiError(err); - } - }); - - // GET /automations/:id — get a single schedule - router.get("/automations/:id", async (req: Request, res: Response) => { - const scope = parseScopeParam(req); - const automationStore = resolveAutomationStore(req, scope); - - try { - const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; - const schedule = await automationStore.getSchedule(id); - - // Scope isolation: if scope is specified, verify the schedule belongs to that scope - if (scope && schedule.scope !== scope) { - throw notFound("Schedule not found"); - } - - res.json(schedule); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - throw notFound("Schedule not found"); - } - rethrowAsApiError(err); - } - }); - - // PATCH /automations/:id — update a schedule - router.patch("/automations/:id", async (req: Request, res: Response) => { - const scope = parseScopeParam(req); - const automationStore = resolveAutomationStore(req, scope); - - try { - const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; - - // Scope isolation: if scope is specified, verify the schedule belongs to that scope - // by fetching it first (can't filter in update without scope support in store) - if (scope) { - const existing = await automationStore.getSchedule(id); - if (existing.scope !== scope) { - throw notFound("Schedule not found"); - } - } - - const { name, description, scheduleType, cronExpression, command, enabled, timeoutMs, steps } = req.body; - - // Validate cron if switching to custom - if (scheduleType === "custom" && cronExpression) { - if (!AutomationStore.isValidCron(cronExpression)) { - throw badRequest(`Invalid cron expression: "${cronExpression}"`); - } - } - - // Validate steps if provided - if (Array.isArray(steps) && steps.length > 0) { - const stepErr = validateAutomationSteps(steps); - if (stepErr) { - throw badRequest(stepErr); - } - } - - const schedule = await automationStore.updateSchedule(id, { - name, - description, - scheduleType, - cronExpression, - command, - enabled, - timeoutMs, - steps: steps !== undefined ? steps : undefined, - }); - res.json(schedule); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - throw notFound("Schedule not found"); - } - if ((err instanceof Error ? err.message : String(err)).includes("cannot be empty") || (err instanceof Error ? err.message : String(err)).includes("Invalid cron")) { - throw badRequest(err instanceof Error ? err.message : String(err)); - } - rethrowAsApiError(err); - } - }); - - // DELETE /automations/:id — delete a schedule - router.delete("/automations/:id", async (req: Request, res: Response) => { - const scope = parseScopeParam(req); - const automationStore = resolveAutomationStore(req, scope); - - try { - const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; - - // Scope isolation: if scope is specified, verify the schedule belongs to that scope - if (scope) { - const existing = await automationStore.getSchedule(id); - if (existing.scope !== scope) { - throw notFound("Schedule not found"); - } - } - - const deleted = await automationStore.deleteSchedule(id); - res.json(deleted); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - throw notFound("Schedule not found"); - } - rethrowAsApiError(err); - } - }); - - // POST /automations/:id/run — trigger a manual run - router.post("/automations/:id/run", async (req: Request, res: Response) => { - const scope = parseScopeParam(req); - const automationStore = resolveAutomationStore(req, scope); - let liveRunId: string | undefined; - - try { - const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; - const schedule = await automationStore.getSchedule(id); - - // Scope isolation: if scope is specified, verify the schedule belongs to that scope - if (scope && schedule.scope !== scope) { - throw notFound("Schedule not found"); - } - - const liveRun = automationLiveRuns.start(schedule.id); - liveRunId = liveRun.runId; - const liveCallbacks = createAutomationLiveRunCallbacks(liveRun.runId); - const startedAt = new Date().toISOString(); - const scopedStore = await getScopedStore(req); - let result: import("@fusion/core").AutomationRunResult; - - if (schedule.steps && schedule.steps.length > 0) { - // Multi-step execution - result = await executeScheduleSteps(schedule, startedAt, scopedStore, liveCallbacks); - } else { - // Legacy single-command execution - // FNXC:Automations 2026-07-04-00:00: - // FN-7537: command/backup runs (including the new in-process backup branch inside - // executeSingleCommand) stream through the same onStep/onText live-run callbacks as every other - // step type, so the live-output panel populates during the run (step-start immediately, output once - // available) rather than only at the terminal `complete` event. - liveCallbacks.onStep?.({ stepIndex: 0, stepId: "command", stepName: schedule.name, stepType: "command", status: "started" }); - result = await executeSingleCommand(schedule.command, schedule.timeoutMs, startedAt, scopedStore); - liveCallbacks.onStep?.({ stepIndex: 0, stepId: "command", stepName: schedule.name, stepType: "command", status: "completed", success: result.success, error: result.error }); - if (result.output) liveCallbacks.onText?.(result.output); - } - - // Record the result - const updated = await automationStore.recordRun(schedule.id, result); - automationLiveRuns.complete(liveRun.runId, result); - res.json({ schedule: updated, result, liveRunId: liveRun.runId }); - } catch (err: unknown) { - if (liveRunId) { - automationLiveRuns.fail(liveRunId, err instanceof Error ? err.message : String(err)); - } - if (err instanceof ApiError) { - throw err; - } - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - throw notFound("Schedule not found"); - } - rethrowAsApiError(err); - } - }); - - /** - * FNXC:AutomationLiveOutput 2026-07-07-08:30 (FN-7663, follow-up from FN-7652): - * `/automations/:id/run/stream` and `/routines/:id/run/stream` are otherwise-identical SSE - * endpoints layered over the same `AutomationLiveRunRegistry` (`automationLiveRuns`). Before - * this consolidation, each route carried its own copy of the header/replay/subscribe/teardown - * logic — the FN-7652 live-output fix had to be applied twice, and any future fix could drift - * between the two copies. This single generic factory is parameterized ONLY by what actually - * differs between the two routes (store resolver, entity getter, not-found message) so a fix - * to the streaming behavior is written once and applies to both endpoints. - */ - function makeRunStreamHandler(config: { - resolveStore: (req: Request, scope: ScopeValue | undefined) => TStore; - getEntity: (store: TStore, id: string) => Promise; - notFoundMessage: string; - }): (req: Request, res: Response) => Promise { - const { resolveStore, getEntity, notFoundMessage } = config; - return async (req: Request, res: Response) => { - const scope = parseScopeParam(req); - const store = resolveStore(req, scope); - const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; - - try { - const entity = await getEntity(store, id); - if (scope && entity.scope !== scope) { - throw notFound(notFoundMessage); - } - - res.setHeader("Content-Type", "text/event-stream"); - res.setHeader("Cache-Control", "no-cache"); - res.setHeader("Connection", "keep-alive"); - res.setHeader("X-Accel-Buffering", "no"); - res.flushHeaders(); - res.write(": connected\n\n"); - - const requestedRunId = typeof req.query.runId === "string" ? req.query.runId : undefined; - const lastEventId = parseLastEventId(req); - let unsubscribeRun: (() => void) | undefined; - let unsubscribeStart: (() => void) | undefined; - - const attachRun = (run: AutomationLiveRunRecord) => { - const buffered = automationLiveRuns.getBufferedEvents(run.runId, lastEventId ?? 0); - if (!replayBufferedSSE(res, buffered)) { - res.end(); - return; - } - if (run.status !== "running") { - res.end(); - return; - } - unsubscribeRun = automationLiveRuns.subscribe(run.runId, (event, eventId) => { - if (!writeSSEEvent(res, event.type, JSON.stringify(event.data ?? {}), eventId)) { - unsubscribeRun?.(); - return; - } - if (event.type === "complete" || event.type === "error") { - unsubscribeRun?.(); - res.end(); - } - }); - }; - - // FNXC:AutomationLiveOutput 2026-07-07-00:00 (FN-7652): no explicit runId means "attach me to - // this request's own run" — use getForAutoAttach so a stale finished run from before this - // trigger isn't mistaken for it (see AutomationLiveRunRegistry.getForAutoAttach). - const existingRun = requestedRunId - ? automationLiveRuns.get(requestedRunId, entity.id) - : automationLiveRuns.getForAutoAttach(entity.id); - if (existingRun) { - attachRun(existingRun); - } else if (requestedRunId) { - writeSSEEvent(res, "error", JSON.stringify({ message: "Live run not found or expired", runId: requestedRunId })); - res.end(); - } else { - unsubscribeStart = automationLiveRuns.subscribeToScheduleStart(entity.id, (run) => { - unsubscribeStart?.(); - attachRun(run); - }); - } - - req.on("close", () => { - unsubscribeRun?.(); - unsubscribeStart?.(); - }); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - throw notFound(notFoundMessage); - } - rethrowAsApiError(err); - } - }; - } - - // GET /automations/:id/run/stream — stream live manual-run output. - router.get( - "/automations/:id/run/stream", - makeRunStreamHandler({ - resolveStore: resolveAutomationStore, - getEntity: (store, id) => store.getSchedule(id), - notFoundMessage: "Schedule not found", - }), - ); - - // POST /automations/:id/toggle — toggle enabled/disabled - router.post("/automations/:id/toggle", async (req: Request, res: Response) => { - const scope = parseScopeParam(req); - const automationStore = resolveAutomationStore(req, scope); - - try { - const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; - const schedule = await automationStore.getSchedule(id); - - // Scope isolation: if scope is specified, verify the schedule belongs to that scope - if (scope && schedule.scope !== scope) { - throw notFound("Schedule not found"); - } - - const updated = await automationStore.updateSchedule(id, { - enabled: !schedule.enabled, - }); - res.json(updated); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - throw notFound("Schedule not found"); - } - rethrowAsApiError(err); - } - }); - - // POST /automations/:id/steps/reorder — reorder steps - router.post("/automations/:id/steps/reorder", async (req: Request, res: Response) => { - const scope = parseScopeParam(req); - const automationStore = resolveAutomationStore(req, scope); - - try { - const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; - - // Scope isolation: if scope is specified, verify the schedule belongs to that scope - if (scope) { - const existing = await automationStore.getSchedule(id); - if (existing.scope !== scope) { - throw notFound("Schedule not found"); - } - } - - const { stepIds } = req.body; - if (!Array.isArray(stepIds)) { - throw badRequest("stepIds must be an array"); - } - const schedule = await automationStore.reorderSteps(id, stepIds); - res.json(schedule); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - throw notFound("Schedule not found"); - } - if ((err instanceof Error ? err.message : String(err)).includes("mismatch") || (err instanceof Error ? err.message : String(err)).includes("Unknown step") || (err instanceof Error ? err.message : String(err)).includes("no steps")) { - throw badRequest(err instanceof Error ? err.message : String(err)); - } - rethrowAsApiError(err); - } - }); - - // ── Routine Routes ────────────────────────────────────────────────── - // - // Scope-aware endpoints: Accept `scope=global|project` query param or body field. - // - When scope=global: Operations target the global routine store - // - When scope=project: Operations target project-scoped routines (filtered by scope) - // - When scope is omitted: Legacy default behavior (global store, backward compatible) - // - // Error codes: - // - 400: Invalid scope value or validation failure - // - 401: Webhook signature verification failed - // - 403: Webhook disabled/forbidden - // - 404: Routine not found - // - 503: Routine store or runner unavailable - - // GET /routines — list all routines (optionally filtered by scope) - router.get("/routines", async (req: Request, res: Response) => { - // Return empty array when no store available (legacy backward-compatible behavior) - if (!options?.routineStore) { - return res.json([]); - } - - try { - const scope = parseScopeParam(req); - const routineStore = resolveRoutineStore(req, scope); - - // Get all routines and filter by scope if specified - // When scope is omitted, return all routines (legacy behavior) - const allRoutines = await routineStore.listRoutines(); - if (scope) { - const filteredRoutines = allRoutines.filter((r) => r.scope === scope); - res.json(filteredRoutines); - } else { - res.json(allRoutines); - } - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - rethrowAsApiError(err); - } - }); - - // POST /routines — create a new routine (with optional scope) - router.post("/routines", async (req: Request, res: Response) => { - const scope = parseScopeParam(req); - const routineStore = resolveRoutineStore(req, scope); - - try { - const { name, agentId, description, trigger, command, steps, timeoutMs, catchUpPolicy, executionPolicy, enabled } = req.body; - - // Validation - if (!name?.trim()) { - throw badRequest("Name is required"); - } - if (!trigger) { - throw badRequest("Trigger is required"); - } - if (!trigger.type) { - throw badRequest("Trigger must have a type field"); - } - const validTriggerTypes: RoutineTriggerType[] = ["cron", "webhook", "api", "manual"]; - if (!validTriggerTypes.includes(trigger.type)) { - throw badRequest(`Invalid trigger type. Must be one of: ${validTriggerTypes.join(", ")}`); - } - if (trigger.type === "cron") { - if (!trigger.cronExpression?.trim()) { - throw badRequest("Cron expression is required for cron trigger"); - } - if (!RoutineStore.isValidCron(trigger.cronExpression)) { - throw badRequest(`Invalid cron expression: "${trigger.cronExpression}"`); - } - } - if (trigger.type === "webhook") { - // Require an HMAC secret so the webhook endpoint authenticates callers - // via signed payloads. Without this, anyone who can reach the server - // and knows the routine id could trigger execution by sending an empty - // POST to /routines/:id/webhook. - if (typeof trigger.secret !== "string" || trigger.secret.trim().length < 16) { - throw badRequest( - "Webhook trigger requires a secret of at least 16 characters for HMAC signature verification", - ); - } - } - const hasSteps = Array.isArray(steps) && steps.length > 0; - const hasCommand = typeof command === "string" && command.trim().length > 0; - if (hasSteps) { - const stepErr = validateAutomationSteps(steps); - if (stepErr) { - throw badRequest(stepErr); - } - } - if (catchUpPolicy !== undefined) { - const validCatchUpPolicies: Array<"run" | "skip" | "run_one"> = ["run", "skip", "run_one"]; - if (!validCatchUpPolicies.includes(catchUpPolicy)) { - throw badRequest(`Invalid catchUpPolicy. Must be one of: ${validCatchUpPolicies.join(", ")}`); - } - } - if (executionPolicy !== undefined) { - const validExecutionPolicies: Array<"parallel" | "queue" | "reject"> = ["parallel", "queue", "reject"]; - if (!validExecutionPolicies.includes(executionPolicy)) { - throw badRequest(`Invalid executionPolicy. Must be one of: ${validExecutionPolicies.join(", ")}`); - } - } - - // Determine scope for the new routine - // Default to "project" for backward compatibility when scope is omitted - const routineScope = scope ?? "project"; - - const routine = await routineStore.createRoutine({ - name: name.trim(), - agentId: typeof agentId === "string" ? agentId.trim() : "", - description, - trigger, - command: hasCommand ? command : undefined, - steps: hasSteps ? steps : undefined, - timeoutMs, - catchUpPolicy, - executionPolicy, - enabled, - scope: routineScope, - }); - res.status(201).json(routine); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - rethrowAsApiError(err); - } - }); - - // GET /routines/:id — get a single routine - router.get("/routines/:id", async (req: Request, res: Response) => { - const scope = parseScopeParam(req); - const routineStore = resolveRoutineStore(req, scope); - - try { - const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; - const routine = await routineStore.getRoutine(id); - - // Scope isolation: if scope is specified, verify the routine belongs to that scope - if (scope && routine.scope !== scope) { - throw notFound("Routine not found"); - } - - res.json(routine); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - throw notFound("Routine not found"); - } - rethrowAsApiError(err); - } - }); - - // PATCH /routines/:id — update a routine - router.patch("/routines/:id", async (req: Request, res: Response) => { - const scope = parseScopeParam(req); - const routineStore = resolveRoutineStore(req, scope); - - try { - const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; - - // Scope isolation: if scope is specified, verify the routine belongs to that scope - if (scope) { - const existing = await routineStore.getRoutine(id); - if (existing.scope !== scope) { - throw notFound("Routine not found"); - } - } - - const { name, description, trigger, command, steps, timeoutMs, catchUpPolicy, executionPolicy, enabled } = req.body; - - // Validate name if provided - if (name !== undefined && !name.trim()) { - throw badRequest("Name cannot be empty"); - } - - // Validate trigger if provided - if (trigger !== undefined) { - if (trigger.type) { - const validTriggerTypes: RoutineTriggerType[] = ["cron", "webhook", "api", "manual"]; - if (!validTriggerTypes.includes(trigger.type)) { - throw badRequest(`Invalid trigger type. Must be one of: ${validTriggerTypes.join(", ")}`); - } - if (trigger.type === "cron" && trigger.cronExpression) { - if (!RoutineStore.isValidCron(trigger.cronExpression)) { - throw badRequest(`Invalid cron expression: "${trigger.cronExpression}"`); - } - } - if (trigger.type === "webhook") { - if (typeof trigger.secret !== "string" || trigger.secret.trim().length < 16) { - throw badRequest( - "Webhook trigger requires a secret of at least 16 characters for HMAC signature verification", - ); - } - } - } - } - if (Array.isArray(steps) && steps.length > 0) { - const stepErr = validateAutomationSteps(steps); - if (stepErr) { - throw badRequest(stepErr); - } - } - - const routine = await routineStore.updateRoutine(id, { - name: name !== undefined ? name.trim() : undefined, - description, - trigger, - command: command !== undefined ? command : undefined, - steps: steps !== undefined ? steps : undefined, - timeoutMs, - catchUpPolicy, - executionPolicy, - enabled, - }); - res.json(routine); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - throw notFound("Routine not found"); - } - if ((err instanceof Error ? err.message : String(err)).includes("cannot be empty") || (err instanceof Error ? err.message : String(err)).includes("Invalid cron")) { - throw badRequest(err instanceof Error ? err.message : String(err)); - } - rethrowAsApiError(err); - } - }); - - // DELETE /routines/:id — delete a routine - router.delete("/routines/:id", async (req: Request, res: Response) => { - const scope = parseScopeParam(req); - const routineStore = resolveRoutineStore(req, scope); - - try { - const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; - - // Scope isolation: if scope is specified, verify the routine belongs to that scope - if (scope) { - const existing = await routineStore.getRoutine(id); - if (existing.scope !== scope) { - throw notFound("Routine not found"); - } - } - - const deleted = await routineStore.deleteRoutine(id); - res.json(deleted); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - throw notFound("Routine not found"); - } - rethrowAsApiError(err); - } - }); - - // POST /routines/:id/run — manual trigger (backward-compatible alias for /trigger) - router.post("/routines/:id/run", async (req: Request, res: Response) => { - const scope = parseScopeParam(req); - const routineStore = resolveRoutineStore(req, scope); - const routineRunner = resolveRoutineRunner(req, scope); - - try { - const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; - const routine = await routineStore.getRoutine(id); - - // Scope isolation: if scope is specified, verify the routine belongs to that scope - if (scope && routine.scope !== scope) { - throw notFound("Routine not found"); - } - - // Validate routine is enabled - if (!routine.enabled) { - throw badRequest("Routine is disabled"); - } - - const liveRun = automationLiveRuns.start(routine.id); - const liveCallbacks = createAutomationLiveRunCallbacks(liveRun.runId); - try { - // Execute via RoutineRunner (persistence handled by RoutineRunner.completeRoutineExecution) - const result = await routineRunner.triggerManual(id, liveCallbacks); - const updated = await routineStore.getRoutine(id); - automationLiveRuns.complete(liveRun.runId, result); - res.json({ routine: updated, result, liveRunId: liveRun.runId }); - } catch (err) { - automationLiveRuns.fail(liveRun.runId, err instanceof Error ? err.message : String(err)); - throw err; - } - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - throw notFound("Routine not found"); - } - rethrowAsApiError(err); - } - }); - - // POST /routines/:id/trigger — canonical manual trigger (uses RoutineRunner) - // POST /routines/:id/run is a backward-compatible alias with identical behavior - router.post("/routines/:id/trigger", async (req: Request, res: Response) => { - const scope = parseScopeParam(req); - const routineStore = resolveRoutineStore(req, scope); - const routineRunner = resolveRoutineRunner(req, scope); - - try { - const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; - const routine = await routineStore.getRoutine(id); - - // Scope isolation: if scope is specified, verify the routine belongs to that scope - if (scope && routine.scope !== scope) { - throw notFound("Routine not found"); - } - - // Validate routine is enabled - if (!routine.enabled) { - throw badRequest("Routine is disabled"); - } - - const liveRun = automationLiveRuns.start(routine.id); - const liveCallbacks = createAutomationLiveRunCallbacks(liveRun.runId); - try { - // Execute via RoutineRunner (persistence handled by RoutineRunner.completeRoutineExecution) - const result = await routineRunner.triggerManual(id, liveCallbacks); - const updated = await routineStore.getRoutine(id); - automationLiveRuns.complete(liveRun.runId, result); - res.json({ routine: updated, result, liveRunId: liveRun.runId }); - } catch (err) { - automationLiveRuns.fail(liveRun.runId, err instanceof Error ? err.message : String(err)); - throw err; - } - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - throw notFound("Routine not found"); - } - rethrowAsApiError(err); - } - }); - - // GET /routines/:id/run/stream — stream live manual routine output. - router.get( - "/routines/:id/run/stream", - makeRunStreamHandler({ - resolveStore: resolveRoutineStore, - getEntity: (store, id) => store.getRoutine(id), - notFoundMessage: "Routine not found", - }), - ); - - // GET /routines/:id/runs — get execution history - router.get("/routines/:id/runs", async (req: Request, res: Response) => { - const scope = parseScopeParam(req); - const routineStore = resolveRoutineStore(req, scope); - - try { - const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; - const routine = await routineStore.getRoutine(id); - - // Scope isolation: if scope is specified, verify the routine belongs to that scope - if (scope && routine.scope !== scope) { - throw notFound("Routine not found"); - } - - res.json(routine.runHistory); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - throw notFound("Routine not found"); - } - rethrowAsApiError(err); - } - }); - - // POST /routines/:id/webhook — incoming webhook trigger - // Note: Webhook routes do NOT use scope params from the request - webhooks are triggered - // externally and the routine's own scope determines which store to use. - // The webhook URL should include the scope implicitly via the routine ID. - router.post("/routines/:id/webhook", async (req: Request, res: Response) => { - // Webhook triggers don't accept scope params from the request - // The routine's scope field determines which store to use - const routineStore = resolveRoutineStore(req, undefined); - const routineRunner = resolveRoutineRunner(req, undefined); - - try { - const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; - const routine = await routineStore.getRoutine(id); - - // Validate this is a webhook-type routine - if (!isWebhookTrigger(routine.trigger)) { - throw badRequest("Routine is not configured for webhook triggers"); - } - - // Validate routine is enabled - if (!routine.enabled) { - throw badRequest("Routine is disabled"); - } - - // Get raw body for HMAC verification - const rawBody = req.rawBody; - const signatureHeader = req.headers["x-hub-signature-256"] as string | undefined; - - // A webhook routine without a secret is treated as a misconfiguration - // and refused. New routines require a secret at create time (see POST - // /routines), but legacy routines persisted before that validation was - // added could still reach this branch without one. - if (!routine.trigger.secret) { - throw new ApiError( - 401, - "Webhook trigger is not configured with a secret; set routine.trigger.secret before use", - ); - } - if (!rawBody) { - throw badRequest("Raw body not available for signature verification"); - } - if (!signatureHeader) { - throw new ApiError(401, "Missing signature header"); - } - const verification = verifyWebhookSignature(rawBody, signatureHeader, routine.trigger.secret); - if (!verification.valid) { - throw new ApiError(401, verification.error ?? "Invalid signature"); - } - - // Execute via RoutineRunner (persistence handled by RoutineRunner.completeRoutineExecution) - const payload = req.body; - const result = await routineRunner.triggerWebhook(id, payload, signatureHeader); - const updated = await routineStore.getRoutine(id); - res.json({ routine: updated, result }); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - throw notFound("Routine not found"); - } - rethrowAsApiError(err); - } - }); - // ── Activity Log Routes ───────────────────────────────────────────── /** @@ -3454,714 +2465,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout aiSessionStore, })); - // ── Plugin Routes ───────────────────────────────────────────────────────── - // Plugin management endpoints with projectId scoping support. - // Uses getScopedStore(req) pattern for multi-project support. - // Requires pluginStore in options. - - /** - * GET /api/plugins - * List all installed plugins. - * Query: { projectId?: string, enabled?: boolean } - */ - router.get("/plugins", async (req: Request, res: Response) => { - const { store: scopedStore } = await getProjectContext(req); - const pluginStore = scopedStore.getPluginStore(); - - const filter: { enabled?: boolean } = {}; - if (req.query.enabled !== undefined) { - filter.enabled = req.query.enabled === "true"; - } - - const plugins = await pluginStore.listPlugins(filter); - res.json(plugins); - }); - - /** - * GET /api/plugins/ui-slots - * Get all UI slot definitions from active plugins. - * Returns aggregated array of { pluginId, slot } objects. - */ - router.get("/plugins/ui-slots", async (_req: Request, res: Response) => { - const slots = options?.pluginLoader?.getPluginUiSlots() ?? []; - const normalizedSlots = slots - .map((entry) => ({ - pluginId: entry.pluginId, - slot: { - ...entry.slot, - surface: entry.slot.surface ?? (typeof entry.slot.slotId === "string" ? entry.slot.slotId : undefined), - order: entry.slot.order ?? null, - }, - })) - .sort((a, b) => { - const orderA = typeof a.slot.order === "number" ? a.slot.order : Number.MAX_SAFE_INTEGER; - const orderB = typeof b.slot.order === "number" ? b.slot.order : Number.MAX_SAFE_INTEGER; - if (orderA !== orderB) return orderA - orderB; - if (a.pluginId !== b.pluginId) return a.pluginId.localeCompare(b.pluginId); - return String(a.slot.slotId).localeCompare(String(b.slot.slotId)); - }); - res.json(normalizedSlots); - }); - - /** - * GET /api/plugins/ui-contributions - * Get all structured UI contributions from active plugins. - */ - router.get("/plugins/ui-contributions", async (_req: Request, res: Response) => { - const contributions = options?.pluginLoader?.getPluginUiContributions() ?? []; - const normalizedContributions = contributions - .map((entry) => ({ - pluginId: entry.pluginId, - contribution: { - ...entry.contribution, - order: entry.contribution.order ?? null, - }, - })) - .sort((a, b) => { - const orderA = typeof a.contribution.order === "number" ? a.contribution.order : Number.MAX_SAFE_INTEGER; - const orderB = typeof b.contribution.order === "number" ? b.contribution.order : Number.MAX_SAFE_INTEGER; - if (orderA !== orderB) return orderA - orderB; - if (a.pluginId !== b.pluginId) return a.pluginId.localeCompare(b.pluginId); - return a.contribution.contributionId.localeCompare(b.contribution.contributionId); - }); - res.json(normalizedContributions); - }); - - - /** - * GET /api/plugins/dashboard-views - * Get all plugin top-level dashboard view definitions from active plugins. - * Returns aggregated array of { pluginId, view } objects. - */ - router.get("/plugins/dashboard-views", async (_req: Request, res: Response) => { - const views = await options?.pluginLoader?.getPluginDashboardViews() ?? []; - res.json(views); - }); - - /** - * GET /api/plugins/runtimes - * Get all plugin runtime metadata from active plugins. - * Returns aggregated array of { pluginId, runtimeId, name, description, version }. - */ - router.get("/plugins/runtimes", async (_req: Request, res: Response) => { - const runtimes = options?.pluginLoader?.getPluginRuntimes() ?? []; - const installed = runtimes.map(({ pluginId, runtime }) => ({ - pluginId, - runtimeId: runtime.metadata.runtimeId, - name: runtime.metadata.name, - description: runtime.metadata.description, - version: runtime.metadata.version, - })); - const installedRuntimeIds = new Set(installed.map((r) => r.runtimeId)); - const bundledFallback = BUNDLED_PLUGIN_RUNTIMES.filter( - (r) => !installedRuntimeIds.has(r.runtimeId), - ); - res.json([...installed, ...bundledFallback]); - }); - - /** - * GET /api/plugins/:id - * Get a single plugin by ID. - * Query: { projectId?: string } - */ - router.get("/plugins/:id", async (req: Request, res: Response, next: NextFunction) => { - // "registry" is a static sub-route (GET /plugins/registry) owned by the - // plugin sub-router mounted further below. Because this generic ":id" route - // is registered first, Express would otherwise match it for the literal - // path "/plugins/registry" (id === "registry") and throw - // 'Plugin "registry" not found', shadowing the real registry handler. - // Fall through so the mounted sub-router can serve the registry listing. - if (req.params.id === "registry") { - next(); - return; - } - const { store: scopedStore } = await getProjectContext(req); - const pluginStore = scopedStore.getPluginStore(); - const id = req.params.id as string; - - try { - const plugin = await pluginStore.getPlugin(id); - res.json(plugin); - } catch (err: unknown) { - if (err instanceof Error && (err instanceof Error ? err.message : String(err)).includes("not found")) { - throw notFound(`Plugin "${id}" not found`); - } - throw internalError(err instanceof Error ? err.message : "Unknown error"); - } - }); - - /** - * GET /api/plugins/:id/settings - * Get plugin settings by plugin ID. - * Query: { projectId?: string } - */ - router.get("/plugins/:id/settings", async (req: Request, res: Response) => { - const { store: scopedStore } = await getProjectContext(req); - const pluginStore = scopedStore.getPluginStore(); - const id = req.params.id as string; - - try { - const plugin = await pluginStore.getPlugin(id); - res.json(plugin.settings); - } catch (err: unknown) { - const isNotFoundError = err instanceof Error && (err instanceof Error ? err.message : String(err)).includes("not found"); - const isBundledFallback = BUNDLED_PLUGIN_RUNTIMES.some((r) => r.pluginId === id); - if (isNotFoundError && isBundledFallback) { - // Bundled runtime plugins can be surfaced in settings before they've - // been lazily installed. Return empty defaults so cards can open. - res.json({}); - return; - } - if (isNotFoundError) { - throw notFound(`Plugin "${id}" not found`); - } - throw internalError(err instanceof Error ? err.message : "Unknown error"); - } - }); - - /** - * POST /api/plugins - * Create or register a plugin. - * Requires `mode` discriminator in body: - * - mode: "register" → body must include { id, name, version, path }, optional { enabled, settings, projectId } - * - mode: "install" → body must include { path }, optional { projectId } - * Returns 201 on success, 400 for validation errors, 409 for conflicts. - */ - router.post("/plugins", async (req: Request, res: Response) => { - const { store: scopedStore } = await getProjectContext(req); - const pluginStore = scopedStore.getPluginStore(); - - if (!req.body || typeof req.body !== "object") { - throw badRequest("Request body is required"); - } - - const body = req.body as Record; - - // Validate mode discriminator is present - if (!("mode" in body) || typeof body.mode !== "string") { - throw badRequest("Request body must have a 'mode' field with value 'register' or 'install'"); - } - - const mode = body.mode as string; - - if (mode === "register") { - // Register mode: requires id, name, version, path - if (typeof body.id !== "string" || !body.id.trim()) { - throw badRequest("'id' is required for register mode and must be a non-empty string"); - } - if (typeof body.name !== "string" || !body.name.trim()) { - throw badRequest("'name' is required for register mode and must be a non-empty string"); - } - if (typeof body.version !== "string" || !body.version.trim()) { - throw badRequest("'version' is required for register mode and must be a non-empty string"); - } - if (typeof body.path !== "string" || !body.path.trim()) { - throw badRequest("'path' is required for register mode and must be a non-empty string"); - } - - const manifest: import("@fusion/core").PluginManifest = { - id: body.id as string, - name: body.name as string, - version: body.version as string, - description: typeof body.description === "string" ? body.description : undefined, - author: typeof body.author === "string" ? body.author : undefined, - homepage: typeof body.homepage === "string" ? body.homepage : undefined, - dependencies: Array.isArray(body.dependencies) ? (body.dependencies as string[]) : undefined, - settingsSchema: typeof body.settingsSchema === "object" && body.settingsSchema !== null - ? (body.settingsSchema as Record) - : undefined, - }; - - const settings = typeof body.settings === "object" && body.settings !== null - ? (body.settings as Record) - : undefined; - - // If enabled and loader is available, try to load the plugin - let plugin: import("@fusion/core").PluginInstallation; - try { - plugin = await pluginStore.registerPlugin({ - manifest, - path: body.path as string, - settings, - }); - - if (plugin.enabled && options?.pluginLoader) { - try { - await options.pluginLoader.loadPlugin(plugin.id); - } catch (loadErr) { - // Log but don't fail - plugin is registered, just not loaded - runtimeLogger.child("plugin-routes").error(`Failed to load plugin ${plugin.id}`, { - error: loadErr instanceof Error ? loadErr.message : String(loadErr), - }); - } - } - - res.status(201).json(plugin); - } catch (err: unknown) { - if (err instanceof Error && (err instanceof Error ? err.message : String(err)).includes("already registered")) { - throw conflict(err instanceof Error ? err.message : String(err)); - } - throw internalError(err instanceof Error ? err.message : "Failed to register plugin"); - } - } else if (mode === "install") { - // Install mode: requires path, loads manifest from path - // Supports package root and dist-folder selections via resolvePluginManifest - if (typeof body.path !== "string" || !body.path.trim()) { - throw badRequest("'path' is required for install mode and must be a non-empty string"); - } - - // Check if runtime install interface is available - if (!options?.pluginLoader) { - throw badRequest("Plugin install mode is not supported: plugin loader not available"); - } - - const aiScanOnLoad = body.aiScanOnLoad; - if (aiScanOnLoad !== undefined && typeof aiScanOnLoad !== "boolean") { - throw badRequest("'aiScanOnLoad' must be a boolean when provided"); - } - - const requestPath = (body.path as string).trim(); - const absoluteRequestPath = isAbsolute(requestPath) ? requestPath : resolve(process.cwd(), requestPath); - - let manifestPathForInstall = absoluteRequestPath; - let manifestResolutionError: ApiError | null = null; - let attemptedBundledLookup = false; - - try { - await resolvePluginManifest(manifestPathForInstall); - } catch (err) { - if (err instanceof ApiError && err.statusCode === 404) { - manifestResolutionError = err; - const bundledPluginId = extractBundledPluginId(requestPath) ?? extractBundledPluginId(absoluteRequestPath); - if (bundledPluginId) { - attemptedBundledLookup = true; - const bundledPath = resolveBundledPluginDirInDashboard(bundledPluginId); - if (bundledPath) { - manifestPathForInstall = bundledPath; - } - } - } else { - throw err; - } - } - - if (manifestResolutionError && manifestPathForInstall === absoluteRequestPath) { - if (attemptedBundledLookup) { - throw notFound( - `Plugin install path not found: ${requestPath}. ` - + "Checked resolved local path and bundled plugin locations.", - ); - } - throw manifestResolutionError; - } - - // Resolve manifest — supports package root and dist-folder selections - const { manifestDir, manifest } = await resolvePluginManifest(manifestPathForInstall); - - // Register the loadable entry FILE, not the package directory — Node ESM - // cannot import directories, so the loader rejects directory paths. - const entryPath = resolvePluginEntryPath(manifestDir); - if (!entryPath) { - throw badRequest( - `Plugin at ${manifestDir} has no loadable entry file ` - + "(expected bundled.js, dist/index.js, or src/index.ts)", - ); - } - - try { - const plugin = await pluginStore.registerPlugin({ - manifest, - path: entryPath, - ...(typeof aiScanOnLoad === "boolean" ? { aiScanOnLoad } : {}), - }); - - // If enabled, try to load it. If load fails while aiScanOnLoad=true, - // remove the new registration so install does not leave a broken record. - if (plugin.enabled) { - try { - await options.pluginLoader.loadPlugin(plugin.id); - } catch (loadErr) { - if (plugin.aiScanOnLoad) { - await pluginStore.unregisterPlugin(plugin.id); - throw badRequest(loadErr instanceof Error ? loadErr.message : String(loadErr)); - } - runtimeLogger.child("plugin-routes").error(`Failed to load plugin ${plugin.id}`, { - error: loadErr instanceof Error ? loadErr.message : String(loadErr), - }); - } - } - - res.status(201).json(plugin); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - if (err instanceof Error && (err instanceof Error ? err.message : String(err)).includes("already registered")) { - throw conflict(err instanceof Error ? err.message : String(err)); - } - throw internalError(err instanceof Error ? err.message : "Failed to register plugin"); - } - } else { - throw badRequest(`Invalid mode: '${mode}'. Must be 'register' or 'install'`); - } - }); - - /** - * POST /api/plugins/:id/enable - * Enable a plugin and start it. - * Body: { projectId?: string } - */ - router.post("/plugins/:id/enable", async (req: Request, res: Response) => { - const { store: scopedStore } = await getProjectContext(req); - const pluginStore = scopedStore.getPluginStore(); - const id = req.params.id as string; - - let plugin = await pluginStore.enablePlugin(id); - - // Heal legacy registrations that stored the package directory instead of - // a loadable entry file (Node ESM cannot import directories). Mirrors the - // CLI's startup heal in ensureBundledPluginInstalled. - try { - if (nodeFs.statSync(plugin.path).isDirectory()) { - const entryPath = resolvePluginEntryPath(plugin.path); - if (entryPath) { - plugin = await pluginStore.updatePlugin(id, { path: entryPath }); - } - } - } catch { - // Path missing or unreadable — let loadPlugin surface the real error. - } - - // Start the plugin if loader is available - if (options?.pluginLoader) { - try { - await options.pluginLoader.loadPlugin(id); - } catch (loadErr) { - // Update state to error - await pluginStore.updatePluginState( - id, - "error", - loadErr instanceof Error ? loadErr.message : String(loadErr), - ); - plugin = await pluginStore.getPlugin(id); - } - } - - res.json(plugin); - }); - - /** - * POST /api/plugins/:id/disable - * Disable a plugin and stop it. - * Body: { projectId?: string } - */ - router.post("/plugins/:id/disable", async (req: Request, res: Response) => { - const { store: scopedStore } = await getProjectContext(req); - const pluginStore = scopedStore.getPluginStore(); - const id = req.params.id as string; - - // Stop the plugin if loader is available - if (options?.pluginLoader) { - try { - await options.pluginLoader.stopPlugin(id); - } catch { - // Ignore errors from stopping - plugin might not be loaded - } - } - - const plugin = await pluginStore.disablePlugin(id); - res.json(plugin); - }); - - /** - * POST /api/plugins/:id/reload - * Reload a running plugin with updated code. - * Body: { projectId?: string } - */ - router.post("/plugins/:id/reload", async (req: Request, res: Response) => { - const { store: scopedStore } = await getProjectContext(req); - const pluginStore = scopedStore.getPluginStore(); - const id = req.params.id as string; - - let plugin: import("@fusion/core").PluginInstallation; - try { - plugin = await pluginStore.getPlugin(id); - } catch (err: unknown) { - if (err instanceof Error && (err instanceof Error ? err.message : String(err)).includes("not found")) { - throw notFound(`Plugin "${id}" not found`); - } - throw internalError(err instanceof Error ? err.message : "Unknown error"); - } - - if (plugin.state !== "started") { - throw badRequest("Plugin is not currently loaded. Use enable instead."); - } - - if (!options?.pluginRunner?.reloadPlugin) { - throw internalError("Plugin runner not available"); - } - - try { - await options.pluginRunner.reloadPlugin(id); - } catch (reloadErr: unknown) { - throw internalError(`Reload failed: ${reloadErr instanceof Error ? reloadErr.message : String(reloadErr)}`); - } - - const updatedPlugin = await pluginStore.getPlugin(id); - res.json(updatedPlugin); - }); - - /** - * PATCH /api/plugins/:id - * Update plugin config. - * Body: { aiScanOnLoad: boolean } - */ - router.patch("/plugins/:id", async (req: Request, res: Response) => { - const { store: scopedStore } = await getProjectContext(req); - const pluginStore = scopedStore.getPluginStore(); - const id = req.params.id as string; - - if (!req.body || typeof req.body !== "object" || typeof (req.body as { aiScanOnLoad?: unknown }).aiScanOnLoad !== "boolean") { - throw badRequest("Request body must be { aiScanOnLoad: boolean }"); - } - - try { - const plugin = await pluginStore.updatePlugin(id, { - aiScanOnLoad: (req.body as { aiScanOnLoad: boolean }).aiScanOnLoad, - }); - res.json(plugin); - } catch (err: unknown) { - if (err instanceof Error && err.message.includes("not found")) { - throw notFound(`Plugin "${id}" not found`); - } - throw internalError(err instanceof Error ? err.message : "Failed to update plugin"); - } - }); - - /** - * POST /api/plugins/:id/rescan - * Trigger a fresh plugin scan/load gate via reload or load flow. - */ - router.post("/plugins/:id/rescan", async (req: Request, res: Response) => { - const { store: scopedStore } = await getProjectContext(req); - const pluginStore = scopedStore.getPluginStore(); - const id = req.params.id as string; - - let plugin: import("@fusion/core").PluginInstallation; - try { - plugin = await pluginStore.getPlugin(id); - } catch { - throw notFound(`Plugin "${id}" not found`); - } - - if (!options?.pluginLoader) { - throw internalError("Plugin loader not available"); - } - - try { - if (plugin.state === "started" && options.pluginRunner?.reloadPlugin) { - await options.pluginRunner.reloadPlugin(id); - } else if (plugin.enabled) { - await options.pluginLoader.loadPlugin(id); - } - } catch (reloadErr) { - runtimeLogger.child("plugin-routes").error(`Failed to rescan plugin ${id}`, { - error: reloadErr instanceof Error ? reloadErr.message : String(reloadErr), - }); - } - - res.json(await pluginStore.getPlugin(id)); - }); - - /** - * GET /api/plugins/:id/setup-status - * Check plugin setup status. - */ - router.get("/plugins/:id/setup-status", async (req: Request, res: Response) => { - const { store: scopedStore } = await getProjectContext(req); - const pluginStore = scopedStore.getPluginStore(); - const id = req.params.id as string; - - let plugin: import("@fusion/core").PluginInstallation; - try { - plugin = await pluginStore.getPlugin(id); - } catch (err: unknown) { - if (err instanceof Error && err.message.includes("not found")) { - throw notFound(`Plugin "${id}" not found`); - } - throw internalError(err instanceof Error ? err.message : "Unknown error"); - } - - if (!options?.pluginRunner?.checkPluginSetup || !options?.pluginRunner?.getPluginSetupInfo) { - throw internalError("Plugin runner not available"); - } - - const setupInfo = options.pluginRunner.getPluginSetupInfo(); - const hasSetup = setupInfo.some((entry) => entry.pluginId === id); - - if (!hasSetup) { - res.json({ hasSetup: false }); - return; - } - - if (plugin.state !== "started") { - res.json({ - hasSetup: true, - setupCheckDeferred: true, - deferredReason: "plugin-not-started", - pluginState: plugin.state, - }); - return; - } - - const status = await options.pluginRunner.checkPluginSetup(id); - res.json({ hasSetup: true, ...status }); - }); - - /** - * POST /api/plugins/:id/setup/install - * Trigger plugin setup install hook. - */ - router.post("/plugins/:id/setup/install", async (req: Request, res: Response) => { - const { store: scopedStore } = await getProjectContext(req); - const pluginStore = scopedStore.getPluginStore(); - const id = req.params.id as string; - - const plugin = await pluginStore.getPlugin(id); - if (!plugin.enabled) { - throw badRequest("Plugin must be enabled before setup install"); - } - - if (!options?.pluginRunner?.installPluginSetup || !options?.pluginRunner?.getPluginSetupInfo) { - throw internalError("Plugin runner not available"); - } - - const setupInfo = options.pluginRunner.getPluginSetupInfo(); - const setup = setupInfo.find((entry) => entry.pluginId === id); - if (!setup?.hooks.install) { - throw badRequest("Plugin has no install hook"); - } - - const result = await options.pluginRunner.installPluginSetup(id); - res.json(result ?? { success: true }); - }); - - /** - * POST /api/plugins/:id/setup/uninstall - * Trigger plugin setup uninstall hook. - */ - router.post("/plugins/:id/setup/uninstall", async (req: Request, res: Response) => { - const { store: scopedStore } = await getProjectContext(req); - const pluginStore = scopedStore.getPluginStore(); - const id = req.params.id as string; - - await pluginStore.getPlugin(id); - - if (!options?.pluginRunner?.uninstallPluginSetup || !options?.pluginRunner?.getPluginSetupInfo) { - throw internalError("Plugin runner not available"); - } - - const setupInfo = options.pluginRunner.getPluginSetupInfo(); - const setup = setupInfo.find((entry) => entry.pluginId === id); - if (!setup) { - res.json({ success: true }); - return; - } - - const result = await options.pluginRunner.uninstallPluginSetup(id); - res.json(result ?? { success: true }); - }); - - /** - * PUT /api/plugins/:id/settings - * Update plugin settings. - * Body: { settings: Record, projectId?: string } - */ - router.put("/plugins/:id/settings", async (req: Request, res: Response) => { - const { store: scopedStore } = await getProjectContext(req); - const pluginStore = scopedStore.getPluginStore(); - const id = req.params.id as string; - - if (!req.body || typeof req.body !== "object") { - throw badRequest("Request body must be an object with 'settings' field"); - } - - const body = req.body as Record; - const settings = body.settings as Record | undefined; - - if (!settings || typeof settings !== "object") { - throw badRequest("Request body must have a 'settings' object"); - } - - // Auto-install bundled runtime plugins (Hermes/OpenClaw/Paperclip) on - // first save. The Settings UI surfaces these as fallback cards before - // they're actually registered, so the first PUT must lazily install them - // rather than 404. The host (CLI) injects ensureBundledPluginInstalled - // because dashboard doesn't know the on-disk bundle layout. - const isBundledFallback = BUNDLED_PLUGIN_RUNTIMES.some((r) => r.pluginId === id); - if (isBundledFallback && options?.ensureBundledPluginInstalled) { - let alreadyRegistered = true; - try { - await pluginStore.getPlugin(id); - } catch { - alreadyRegistered = false; - } - if (!alreadyRegistered) { - try { - const installOk = await options.ensureBundledPluginInstalled(id); - if (!installOk) { - throw internalError( - `Bundled plugin "${id}" is unavailable in this build and could not be auto-installed`, - ); - } - } catch (installErr) { - if (installErr instanceof ApiError) { - throw installErr; - } - throw internalError( - `Failed to auto-install bundled plugin "${id}": ${installErr instanceof Error ? installErr.message : String(installErr)}`, - ); - } - } - } - - try { - const plugin = await pluginStore.updatePluginSettings(id, settings); - res.json(plugin); - } catch (err: unknown) { - if (err instanceof Error && (err instanceof Error ? err.message : String(err)).includes("not found")) { - throw notFound(`Plugin "${id}" not found`); - } - if (err instanceof Error && (err instanceof Error ? err.message : String(err)).includes("validation failed")) { - throw badRequest(err instanceof Error ? err.message : String(err)); - } - throw internalError(err instanceof Error ? err.message : "Failed to update settings"); - } - }); - - /** - * DELETE /api/plugins/:id - * Uninstall a plugin. - * Query: { projectId?: string } - */ - router.delete("/plugins/:id", async (req: Request, res: Response) => { - const { store: scopedStore } = await getProjectContext(req); - const pluginStore = scopedStore.getPluginStore(); - const id = req.params.id as string; - - // Stop the plugin if loader is available - if (options?.pluginLoader) { - try { - await options.pluginLoader.stopPlugin(id); - } catch { - // Ignore - plugin might not be loaded - } - } - - await pluginStore.unregisterPlugin(id); - res.status(204).send(); - }); - // ── AI Session Routes ───────────────────────────────────────────────────── /** @@ -4967,671 +3270,3 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout (router as Router & { dispose?: () => void }).dispose = dispose; return router; } - -// ── Automation step helpers ───────────────────────────────────────── - -/** - * Validate an array of automation steps. - * Returns an error string if invalid, or null if valid. - */ -function validateAutomationSteps(steps: unknown[]): string | null { - for (let i = 0; i < steps.length; i++) { - const step = steps[i] as Record; - if (!step.id || typeof step.id !== "string") { - return `Step ${i + 1}: id is required`; - } - if (!step.type || (step.type !== "command" && step.type !== "ai-prompt" && step.type !== "create-task")) { - return `Step ${i + 1}: type must be "command", "ai-prompt", or "create-task"`; - } - if (!step.name || typeof step.name !== "string" || !step.name.trim()) { - return `Step ${i + 1}: name is required`; - } - if (step.type === "command") { - if (!step.command || typeof step.command !== "string" || !step.command.trim()) { - return `Step ${i + 1}: command is required for command steps`; - } - } - if (step.type === "ai-prompt") { - if (!step.prompt || typeof step.prompt !== "string" || !step.prompt.trim()) { - return `Step ${i + 1}: prompt is required for ai-prompt steps`; - } - if (step.allowedTools !== undefined) { - if (!Array.isArray(step.allowedTools)) { - return `Step ${i + 1}: allowedTools must be an array when provided`; - } - const selectableTools = new Set(AUTOMATION_SELECTABLE_TOOLS.map((tool) => tool.toLowerCase())); - for (const tool of step.allowedTools) { - if (typeof tool !== "string" || !selectableTools.has(tool.trim().toLowerCase())) { - return `Step ${i + 1}: allowedTools contains unknown tool "${String(tool)}"`; - } - } - } - } - if (step.type === "create-task") { - if (!step.taskDescription || typeof step.taskDescription !== "string" || !step.taskDescription.trim()) { - return `Step ${i + 1}: taskDescription is required for create-task steps`; - } - } - // Validate model fields are both present or both absent - const hasProvider = step.modelProvider && typeof step.modelProvider === "string"; - const hasModelId = step.modelId && typeof step.modelId === "string"; - if ((hasProvider && !hasModelId) || (!hasProvider && hasModelId)) { - return `Step ${i + 1}: modelProvider and modelId must both be present or both absent`; - } - /* - FNXC:Automations 2026-07-12-19:14: - Schedule and routine AI-capable steps can persist an optional reasoning-effort override. Validate it against the central THINKING_LEVELS set so routes accept omission/inherit plus known levels and reject drift before JSON step storage. - */ - if (step.thinkingLevel !== undefined) { - if (typeof step.thinkingLevel !== "string" || !THINKING_LEVELS.includes(step.thinkingLevel as (typeof THINKING_LEVELS)[number])) { - return `Step ${i + 1}: thinkingLevel must be one of ${THINKING_LEVELS.join(", ")}`; - } - } - } - return null; -} - -const DEFAULT_AUTOMATION_TIMEOUT_MS = 5 * 60 * 1000; -const AUTOMATION_MAX_BUFFER = 1024 * 1024; -const AUTOMATION_MAX_OUTPUT = 10240; -const AUTOMATION_LIVE_RUN_TTL_MS = 60 * 1000; -const AUTOMATION_LIVE_EVENT_CAPACITY = 200; - -type AutomationLiveRunStatus = "running" | "complete" | "error"; -type AutomationLiveEvent = { type: string; data?: unknown }; -type AutomationLiveRunCallbacks = { - onStep?: (data: Record) => void; - onText?: (delta: string) => void; - onToolStart?: (name: string, args?: Record) => void; - onToolEnd?: (name: string, isError: boolean, result?: unknown) => void; -}; - -type AutomationLiveRunRecord = { - runId: string; - scheduleId: string; - status: AutomationLiveRunStatus; - buffer: SessionEventBuffer; - listeners: Set<(event: AutomationLiveEvent, eventId: number) => void>; - output: string; - cleanupTimer?: NodeJS.Timeout; - /* - * FNXC:AutomationLiveOutput 2026-07-07-00:00 (FN-7652): - * Wall-clock start time (ms since epoch) used solely to decide whether a runId-less GET - * .../run/stream request should auto-attach to this run (see getForAutoAttach). Distinct from the - * result's own ISO `startedAt`/`completedAt` timestamps, which describe the automation's execution - * window, not stream-attach freshness. - */ - startedAt: number; -}; - -function createAutomationRunId(): string { - if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { - return crypto.randomUUID(); - } - return `automation-run-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; -} - -function capAutomationLiveText(current: string, delta: string): { next: string; delta: string } { - if (!delta) return { next: current, delta: "" }; - const remaining = AUTOMATION_MAX_OUTPUT - current.length; - if (remaining <= 0) return { next: current, delta: "" }; - const marker = "\n[output truncated]"; - const cappedDelta = delta.length > remaining - ? remaining > marker.length - ? `${delta.slice(0, remaining - marker.length)}${marker}` - : delta.slice(0, remaining) - : delta; - return { next: `${current}${cappedDelta}`, delta: cappedDelta }; -} - -function previewAutomationLiveValue(value: unknown): unknown { - if (value === undefined || value === null) return value; - try { - const text = typeof value === "string" ? value : JSON.stringify(value); - if (text.length <= 1000) return value; - return `${text.slice(0, 1000)}…`; - } catch { - return "[unserializable]"; - } -} - -/* -FNXC:AutomationLiveOutput 2026-06-26-00:00: -Manual automation runs need replayable live output without changing the POST /run result contract. Keep events in memory by runId, let schedule streams wait for the next run, and expire completed buffers so missed EventSource clients do not leak registry entries. -*/ -class AutomationLiveRunRegistry { - private readonly runs = new Map(); - private readonly latestRunBySchedule = new Map(); - private readonly scheduleStartListeners = new Map void>>(); - - /* - * FNXC:AutomationLiveOutput 2026-07-07-00:00 (FN-7652): - * A runId-less GET .../run/stream auto-attach must not pick up a run that finished well before this - * specific trigger (e.g. the previous manual run for the same schedule/routine, still within the - * AUTOMATION_LIVE_RUN_TTL_MS replay window). Auto-attaching to that stale run replays its own - * (unrelated) terminal `complete`/`error` event onto a brand-new trigger's stream, which is exactly - * the false "Run failed"-for-a-success-run bug (FN-7652). Bound how old a *finished* run may be and - * still be auto-attached; a still-`running` run has no age limit since it IS the in-flight trigger. - */ - private static readonly AUTO_ATTACH_STALE_WINDOW_MS = 10_000; - - start(scheduleId: string, runId = createAutomationRunId()): AutomationLiveRunRecord { - const run: AutomationLiveRunRecord = { - runId, - scheduleId, - status: "running", - buffer: new SessionEventBuffer(AUTOMATION_LIVE_EVENT_CAPACITY), - listeners: new Set(), - output: "", - startedAt: Date.now(), - }; - this.runs.set(runId, run); - this.latestRunBySchedule.set(scheduleId, runId); - this.broadcast(runId, { type: "run", data: { runId, scheduleId, status: "running" } }); - const starters = this.scheduleStartListeners.get(scheduleId); - if (starters) { - for (const listener of [...starters]) listener(run); - } - return run; - } - - get(runId: string | undefined, scheduleId: string): AutomationLiveRunRecord | undefined { - if (runId) { - const run = this.runs.get(runId); - return run?.scheduleId === scheduleId ? run : undefined; - } - const latestRunId = this.latestRunBySchedule.get(scheduleId); - return latestRunId ? this.runs.get(latestRunId) : undefined; - } - - /* - * FNXC:AutomationLiveOutput 2026-07-07-00:00 (FN-7652): - * Used by GET .../run/stream instead of `get()` when the caller supplied no explicit runId. Returns - * the latest run for the schedule/routine only when it is still live, or finished recently enough - * (AUTO_ATTACH_STALE_WINDOW_MS) to plausibly be the run this very request is racing against. - * Otherwise returns undefined so the caller falls back to `subscribeToScheduleStart` and waits for - * its own fresh `run` event, instead of replaying an unrelated older run's terminal outcome. - */ - getForAutoAttach(scheduleId: string): AutomationLiveRunRecord | undefined { - const latestRunId = this.latestRunBySchedule.get(scheduleId); - if (!latestRunId) return undefined; - const run = this.runs.get(latestRunId); - if (!run) return undefined; - if (run.status === "running") return run; - if (Date.now() - run.startedAt < AutomationLiveRunRegistry.AUTO_ATTACH_STALE_WINDOW_MS) return run; - return undefined; - } - - getBufferedEvents(runId: string, lastEventId = 0) { - return this.runs.get(runId)?.buffer.getEventsSince(lastEventId) ?? []; - } - - subscribe(runId: string, listener: (event: AutomationLiveEvent, eventId: number) => void): () => void { - const run = this.runs.get(runId); - if (!run) return () => {}; - run.listeners.add(listener); - return () => run.listeners.delete(listener); - } - - subscribeToScheduleStart(scheduleId: string, listener: (run: AutomationLiveRunRecord) => void): () => void { - let listeners = this.scheduleStartListeners.get(scheduleId); - if (!listeners) { - listeners = new Set(); - this.scheduleStartListeners.set(scheduleId, listeners); - } - listeners.add(listener); - return () => { - listeners?.delete(listener); - if (listeners?.size === 0) this.scheduleStartListeners.delete(scheduleId); - }; - } - - broadcast(runId: string, event: AutomationLiveEvent): number | undefined { - const run = this.runs.get(runId); - if (!run) return undefined; - const eventId = run.buffer.push(event.type, JSON.stringify(event.data ?? {})); - for (const listener of [...run.listeners]) listener(event, eventId); - return eventId; - } - - appendText(runId: string, delta: string): void { - const run = this.runs.get(runId); - if (!run) return; - const capped = capAutomationLiveText(run.output, delta); - run.output = capped.next; - if (capped.delta) this.broadcast(runId, { type: "output", data: { text: capped.delta } }); - } - - complete(runId: string, result: import("@fusion/core").AutomationRunResult): void { - const run = this.runs.get(runId); - if (!run) return; - run.status = result.success ? "complete" : "error"; - this.broadcast(runId, { type: result.success ? "complete" : "error", data: result.success ? { runId, result } : { runId, result, message: result.error ?? "Automation run failed" } }); - this.scheduleCleanup(run); - } - - fail(runId: string, message: string): void { - const run = this.runs.get(runId); - if (!run) return; - run.status = "error"; - this.broadcast(runId, { type: "error", data: { runId, message } }); - this.scheduleCleanup(run); - } - - private scheduleCleanup(run: AutomationLiveRunRecord): void { - if (run.cleanupTimer) clearTimeout(run.cleanupTimer); - run.cleanupTimer = setTimeout(() => { - this.runs.delete(run.runId); - if (this.latestRunBySchedule.get(run.scheduleId) === run.runId) { - this.latestRunBySchedule.delete(run.scheduleId); - } - }, AUTOMATION_LIVE_RUN_TTL_MS); - run.cleanupTimer.unref?.(); - } -} - -const automationLiveRuns = new AutomationLiveRunRegistry(); -const MANUAL_RUN_AI_SYSTEM_PROMPT = [ - "You are an AI automation agent executing a scheduled task.", - "You may use the coding tools selected for this automation step; follow any tool restrictions exactly.", - "Execute the prompt precisely and return concise, structured results.", - "When analyzing code or data, provide actionable summaries.", -].join("\n"); - -function truncateAutomationOutput(stdout: string, stderr: string): string { - let output = stdout; - if (stderr) { - output += stdout ? "\n--- stderr ---\n" : ""; - output += stderr; - } - if (output.length > AUTOMATION_MAX_OUTPUT) { - return output.slice(0, AUTOMATION_MAX_OUTPUT) + "\n[output truncated]"; - } - return output; -} - -function createAutomationLiveRunCallbacks(runId: string): AutomationLiveRunCallbacks { - return { - onStep: (data) => automationLiveRuns.broadcast(runId, { type: "step", data: { runId, ...data } }), - onText: (delta) => automationLiveRuns.appendText(runId, delta), - onToolStart: (name, args) => automationLiveRuns.broadcast(runId, { - type: "tool", - data: { runId, status: "started", name, args: previewAutomationLiveValue(args) }, - }), - onToolEnd: (name, isError, result) => automationLiveRuns.broadcast(runId, { - type: "tool", - data: { runId, status: "completed", name, isError, result: previewAutomationLiveValue(result) }, - }), - }; -} - -/** - * Execute a single shell command (used by manual run endpoint). - * - * FNXC:DatabaseBackup 2026-07-04-00:00: - * FN-7537: the dashboard's manual automation/schedule run path (legacy single-command schedules and - * `command`-type steps in `executeScheduleSteps`) previously always shelled the command out via `exec()`, - * unlike the scheduler (`CronRunner`) and routine runner (`RoutineRunner.executeCommand`), which both - * intercept the auto-backup command and run it in-process via the engine's already-open `TaskStore`. On - * hosts without a global `fn`/`runfusion.ai` binary on PATH this made a manual "Database Backup" run fail - * while the identical cron-triggered run succeeded. Mirror the cron/routine-runner interception here so a - * manual run behaves identically: when a `taskStore` is available and the command matches - * `isInProcessBackupCommand`/`isInProcessMemoryBackupCommand`, run the backup in-process instead of - * shelling out, using the same `formatInProcessBackupError` message shape on failure (parity with FN-7095). - */ -async function executeSingleCommand( - command: string, - timeoutMs: number | undefined, - startedAt: string, - taskStore?: TaskStore, -): Promise { - if (taskStore && isInProcessBackupCommand(command)) { - const fusionDir = taskStore.getFusionDir(); - try { - const { runBackupCommand, resolveGlobalBackupRoot } = await import("@fusion/core"); - const settings = await taskStore.getSettings(); - const result = await runBackupCommand(resolveGlobalBackupRoot(taskStore), settings); - const output = truncateAutomationOutput(result.output ?? "", ""); - return { - success: result.success, - output, - error: result.success ? undefined : formatInProcessBackupError(output, fusionDir), - startedAt, - completedAt: new Date().toISOString(), - }; - } catch (err) { - return { - success: false, - output: "", - error: formatInProcessBackupError(err, fusionDir), - startedAt, - completedAt: new Date().toISOString(), - }; - } - } - - if (taskStore && isInProcessMemoryBackupCommand(command)) { - const fusionDir = taskStore.getFusionDir(); - try { - const { runMemoryBackupCommand } = await import("@fusion/core"); - const settings = await taskStore.getSettings(); - const result = await runMemoryBackupCommand(fusionDir, settings); - return { - success: result.success, - output: truncateAutomationOutput(result.output ?? "", ""), - error: result.success ? undefined : result.output, - startedAt, - completedAt: new Date().toISOString(), - }; - } catch (err) { - return { - success: false, - output: "", - error: err instanceof Error ? err.message : String(err), - startedAt, - completedAt: new Date().toISOString(), - }; - } - } - - const { exec } = await import("node:child_process"); - const { promisify } = await import("node:util"); - const execAsyncFn = promisify(exec); - - const isWindows = process.platform === "win32"; - - try { - const { stdout, stderr } = await execAsyncFn(command, { - timeout: timeoutMs ?? DEFAULT_AUTOMATION_TIMEOUT_MS, - maxBuffer: AUTOMATION_MAX_BUFFER, - shell: isWindows ? "cmd.exe" : "/bin/sh", - }); - - return { - success: true, - output: truncateAutomationOutput(stdout, stderr), - startedAt, - completedAt: new Date().toISOString(), - }; - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - const execErr = err as NodeJS.ErrnoException & { stdout?: string; stderr?: string; killed?: boolean }; - - return { - success: false, - output: truncateAutomationOutput(execErr.stdout ?? "", execErr.stderr ?? ""), - error: execErr.killed - ? `Command timed out after ${(timeoutMs ?? DEFAULT_AUTOMATION_TIMEOUT_MS) / 1000}s` - : (err instanceof Error ? err.message : String(err)), - startedAt, - completedAt: new Date().toISOString(), - }; - } -} - -export async function resolveManualAiPromptMcpServers(taskStore: TaskStore) { - return (await resolveMcpServersForStore(taskStore)).servers; -} - -async function executeAiPromptStep( - step: import("@fusion/core").AutomationStep, - timeoutMs: number, - startedAt: string, - taskStore: TaskStore, - liveCallbacks?: AutomationLiveRunCallbacks, -): Promise { - if (!step.prompt?.trim()) { - return { - stepId: step.id, - stepName: step.name, - stepIndex: 0, - success: false, - output: "", - error: "AI prompt step has no prompt specified", - startedAt, - completedAt: new Date().toISOString(), - }; - } - - const createFnAgent = createFnAgentForRefine; - const promptWithFallback = enginePromptWithFallback; - if (!createFnAgent) { - return { - stepId: step.id, - stepName: step.name, - stepIndex: 0, - success: false, - output: "", - error: "AI agent not available", - startedAt, - completedAt: new Date().toISOString(), - }; - } - - const settings = await taskStore.getSettings(); - // Resolve model: step override → project execution lane → global execution lane → project default override → global default - // FNXC:ModelResolution 2026-06-25-12:00: FN-7039 requires manual AI-prompt workflow runs to use execution-lane settings before default settings because these runs have no task/runtime model context. - const defaultModel = resolveExecutionSettingsModel(settings); - const modelProvider = step.modelProvider?.trim() || defaultModel.provider; - const modelId = step.modelId?.trim() || defaultModel.modelId; - let responseText = ""; - /* - * FNXC:McpConfig 2026-06-26-00:00: - * Manual AI-prompt workflow runs are operator-triggered coding-agent sessions, so they must receive the task-store resolved MCP set just like task executor lanes. Do not log resolved MCP payloads because env/header values may contain materialized secrets. - * - * FNXC:Automations 2026-07-12-20:30: - * Manual/inline automation AI runs bypass CronRunner's executor seam, so they must pass the persisted step thinking level directly as createFnAgent.defaultThinkingLevel. Undefined or blank values preserve inherited defaults. - */ - const mcpServers = await resolveManualAiPromptMcpServers(taskStore); - const defaultThinkingLevel = step.thinkingLevel?.trim() || undefined; - - const { session } = await createFnAgent({ - cwd: process.cwd(), - systemPrompt: MANUAL_RUN_AI_SYSTEM_PROMPT, - tools: "coding", - toolsAllowlist: step.allowedTools, - defaultProvider: modelProvider, - defaultModelId: modelId, - defaultThinkingLevel, - mcpServers, - onText: (delta: string) => { - responseText += delta; - liveCallbacks?.onText?.(delta); - }, - onToolStart: liveCallbacks?.onToolStart, - onToolEnd: liveCallbacks?.onToolEnd, - }); - - try { - const promptPromise = promptWithFallback(session, step.prompt); - const timeoutPromise = new Promise((_resolve, reject) => { - setTimeout(() => reject(new Error(`AI prompt step timed out after ${timeoutMs / 1000}s`)), timeoutMs); - }); - - await Promise.race([promptPromise, timeoutPromise]); - - return { - stepId: step.id, - stepName: step.name, - stepIndex: 0, - success: true, - output: responseText.length > AUTOMATION_MAX_OUTPUT - ? responseText.slice(0, AUTOMATION_MAX_OUTPUT) + "\n[output truncated]" - : responseText, - startedAt, - completedAt: new Date().toISOString(), - }; - } catch (err: unknown) { - return { - stepId: step.id, - stepName: step.name, - stepIndex: 0, - success: false, - output: "", - error: err instanceof Error ? err.message : String(err), - startedAt, - completedAt: new Date().toISOString(), - }; - } finally { - try { - session.dispose(); - } catch { - // best-effort cleanup - } - } -} - -async function executeCreateTaskStep( - step: import("@fusion/core").AutomationStep, - startedAt: string, - taskStore: TaskStore, -): Promise { - if (!step.taskDescription?.trim()) { - return { - stepId: step.id, - stepName: step.name, - stepIndex: 0, - success: false, - output: "", - error: "Create-task step has no task description specified", - startedAt, - completedAt: new Date().toISOString(), - }; - } - - try { - /* - FNXC:Automations 2026-07-12-20:30: - Manual/inline create-task automation runs map the persisted step thinking level onto the created task so manual execution matches scheduled and routine behavior. - */ - const task = await taskStore.createTask({ - title: step.taskTitle?.trim() || undefined, - description: step.taskDescription.trim(), - column: (step.taskColumn as import("@fusion/core").Column) || "triage", - modelProvider: step.modelProvider?.trim() || undefined, - modelId: step.modelId?.trim() || undefined, - thinkingLevel: (step.thinkingLevel?.trim() || undefined) as import("@fusion/core").TaskCreateInput["thinkingLevel"], - source: { - sourceType: "workflow_step", - sourceMetadata: { stepId: step.id }, - }, - }); - return { - stepId: step.id, - stepName: step.name, - stepIndex: 0, - success: true, - output: `Created task ${task.id}: ${task.title || task.description.slice(0, 80)}`, - startedAt, - completedAt: new Date().toISOString(), - }; - } catch (err: unknown) { - return { - stepId: step.id, - stepName: step.name, - stepIndex: 0, - success: false, - output: "", - error: err instanceof Error ? err.message : String(err), - startedAt, - completedAt: new Date().toISOString(), - }; - } -} - -/** - * Execute all steps in a multi-step schedule (used by manual run endpoint). - */ -async function executeScheduleSteps( - schedule: import("@fusion/core").ScheduledTask, - startedAt: string, - taskStore: TaskStore, - liveCallbacks?: AutomationLiveRunCallbacks, -): Promise { - const steps = schedule.steps!; - const stepResults: import("@fusion/core").AutomationStepResult[] = []; - let overallSuccess = true; - let stoppedEarly = false; - - for (let i = 0; i < steps.length; i++) { - const step = steps[i]; - const stepStartedAt = new Date().toISOString(); - const timeoutMs = step.timeoutMs ?? schedule.timeoutMs ?? DEFAULT_AUTOMATION_TIMEOUT_MS; - - let stepResult: import("@fusion/core").AutomationStepResult; - liveCallbacks?.onStep?.({ stepIndex: i, stepId: step.id, stepName: step.name, stepType: step.type, status: "started" }); - - if (step.type === "command") { - const cmdResult = await executeSingleCommand(step.command ?? "", timeoutMs, stepStartedAt, taskStore); - stepResult = { - stepId: step.id, - stepName: step.name, - stepIndex: i, - success: cmdResult.success, - output: cmdResult.output, - error: cmdResult.error, - startedAt: stepStartedAt, - completedAt: cmdResult.completedAt, - }; - } else if (step.type === "ai-prompt") { - stepResult = await executeAiPromptStep(step, timeoutMs, stepStartedAt, taskStore, liveCallbacks); - stepResult.stepIndex = i; - } else if (step.type === "create-task") { - stepResult = await executeCreateTaskStep(step, stepStartedAt, taskStore); - stepResult.stepIndex = i; - } else { - stepResult = { - stepId: step.id, - stepName: step.name, - stepIndex: i, - success: false, - output: "", - error: `Unknown step type: "${step.type}"`, - startedAt: stepStartedAt, - completedAt: new Date().toISOString(), - }; - } - - stepResults.push(stepResult); - liveCallbacks?.onStep?.({ stepIndex: i, stepId: step.id, stepName: step.name, stepType: step.type, status: "completed", success: stepResult.success, error: stepResult.error }); - if (step.type !== "ai-prompt" && stepResult.output) { - liveCallbacks?.onText?.(stepResult.output); - } - - if (!stepResult.success) { - overallSuccess = false; - if (!step.continueOnFailure) { - stoppedEarly = true; - break; - } - } - } - - // Aggregate output - const outputParts: string[] = []; - for (const sr of stepResults) { - outputParts.push(`=== Step ${sr.stepIndex + 1}: ${sr.stepName} (${sr.success ? "success" : "FAILED"}) ===`); - if (sr.output) outputParts.push(sr.output); - if (sr.error) outputParts.push(`Error: ${sr.error}`); - } - let output = outputParts.join("\n"); - if (output.length > AUTOMATION_MAX_OUTPUT) { - output = output.slice(0, AUTOMATION_MAX_OUTPUT) + "\n[output truncated]"; - } - - const failedSteps = stepResults.filter((sr) => !sr.success); - const error = failedSteps.length > 0 - ? `${failedSteps.length} step(s) failed: ${failedSteps.map((s) => s.stepName).join(", ")}${stoppedEarly ? " (execution stopped)" : ""}` - : undefined; - - return { - success: overallSuccess, - output, - error, - startedAt, - completedAt: new Date().toISOString(), - stepResults, - }; -} diff --git a/packages/dashboard/src/routes/README.md b/packages/dashboard/src/routes/README.md index cce0cc6a81..3072471838 100644 --- a/packages/dashboard/src/routes/README.md +++ b/packages/dashboard/src/routes/README.md @@ -22,7 +22,7 @@ The following is the complete top-level registrar map currently imported by `rou - `registerGitLabRoutes` — domain registrar mounted by `createApiRoutes`. - `registerFilesTerminalWorkspaceRoutes` — domain registrar mounted by `createApiRoutes`. - `registerAgentsProjectsNodesRoutes` — domain registrar mounted by `createApiRoutes`. -- `registerPluginsAutomationRoutes` — domain registrar mounted by `createApiRoutes`. +- `registerPluginsAutomationRoutes` — automation and routine CRUD/manual-run/webhook endpoints plus live SSE streams, and plugin-management endpoints. It preserves the `/plugins/:id` registry pass-through; `createPluginRouter` remains mounted later by `routes.ts` so `/plugins/registry` retains precedence. Its co-located `automation-live-run.ts`, `automation-step-execution.ts`, and `plugin-bundled-runtimes.ts` helpers own replayable output, execution, and bundled-runtime fallback metadata. - `registerApprovalRoutes` — domain registrar mounted by `createApiRoutes`. - `registerWorktrunkRoutes` — domain registrar mounted by `createApiRoutes`. - `registerModelRoutes` — domain registrar mounted by `createApiRoutes`. @@ -130,6 +130,7 @@ Express matches in registration order. `create-api-routes-mount-sequence.ts` is - `registerProxyRoutes` is always last; its explicit `/proxy/:nodeId/health`, project, task, project-health, and event paths precede `ALL /proxy/:nodeId/{*splat}` inside the registrar. - Keep model → auth → usage, the agent core/list → core → runtime chain, and project → node → sync → mesh → discovery → inbound-sync ordering unchanged unless a tested precedence migration requires it. - Keep integrated routers before project/node routes and the integrated dev-server router before skills and proxy routes. +- Keep plugin management registration ahead of the later `createPluginRouter` mount. Its `/plugins/:id` handler calls `next()` for `registry`, allowing the sub-router's registry route to serve that static path. - Preserve the file aggregator's session-diff → file-workspace → terminal nesting and its operation-before-wildcard rules. ## Guardrails and verification diff --git a/packages/dashboard/src/routes/automation-live-run.ts b/packages/dashboard/src/routes/automation-live-run.ts new file mode 100644 index 0000000000..f4bd311fdc --- /dev/null +++ b/packages/dashboard/src/routes/automation-live-run.ts @@ -0,0 +1,322 @@ +import type { Request, Response } from "express"; +import { ApiError, notFound } from "../api-error.js"; +import { SessionEventBuffer, writeSSEEvent } from "../sse-buffer.js"; +import type { ScopeValue } from "./types.js"; + +export const DEFAULT_AUTOMATION_TIMEOUT_MS = 5 * 60 * 1000; +export const AUTOMATION_MAX_BUFFER = 1024 * 1024; +export const AUTOMATION_MAX_OUTPUT = 10240; +const AUTOMATION_LIVE_RUN_TTL_MS = 60 * 1000; +const AUTOMATION_LIVE_EVENT_CAPACITY = 200; + +type AutomationLiveRunStatus = "running" | "complete" | "error"; +type AutomationLiveEvent = { type: string; data?: unknown }; +export type AutomationLiveRunCallbacks = { + onStep?: (data: Record) => void; + onText?: (delta: string) => void; + onToolStart?: (name: string, args?: Record) => void; + onToolEnd?: (name: string, isError: boolean, result?: unknown) => void; +}; + +type AutomationLiveRunRecord = { + runId: string; + scheduleId: string; + status: AutomationLiveRunStatus; + buffer: SessionEventBuffer; + listeners: Set<(event: AutomationLiveEvent, eventId: number) => void>; + output: string; + cleanupTimer?: NodeJS.Timeout; + /* + * FNXC:AutomationLiveOutput 2026-07-07-00:00 (FN-7652): + * Wall-clock start time (ms since epoch) used solely to decide whether a runId-less GET + * .../run/stream request should auto-attach to this run (see getForAutoAttach). Distinct from the + * result's own ISO `startedAt`/`completedAt` timestamps, which describe the automation's execution + * window, not stream-attach freshness. + */ + startedAt: number; +}; + +function createAutomationRunId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `automation-run-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; +} + +function capAutomationLiveText(current: string, delta: string): { next: string; delta: string } { + if (!delta) return { next: current, delta: "" }; + const remaining = AUTOMATION_MAX_OUTPUT - current.length; + if (remaining <= 0) return { next: current, delta: "" }; + const marker = "\n[output truncated]"; + const cappedDelta = delta.length > remaining + ? remaining > marker.length + ? `${delta.slice(0, remaining - marker.length)}${marker}` + : delta.slice(0, remaining) + : delta; + return { next: `${current}${cappedDelta}`, delta: cappedDelta }; +} + +function previewAutomationLiveValue(value: unknown): unknown { + if (value === undefined || value === null) return value; + try { + const text = typeof value === "string" ? value : JSON.stringify(value); + if (text.length <= 1000) return value; + return `${text.slice(0, 1000)}…`; + } catch { + return "[unserializable]"; + } +} + +/* +FNXC:AutomationLiveOutput 2026-06-26-00:00: +Manual automation runs need replayable live output without changing the POST /run result contract. Keep events in memory by runId, let schedule streams wait for the next run, and expire completed buffers so missed EventSource clients do not leak registry entries. +*/ +class AutomationLiveRunRegistry { + private readonly runs = new Map(); + private readonly latestRunBySchedule = new Map(); + private readonly scheduleStartListeners = new Map void>>(); + + /* + * FNXC:AutomationLiveOutput 2026-07-07-00:00 (FN-7652): + * A runId-less GET .../run/stream auto-attach must not pick up a run that finished well before this + * specific trigger (e.g. the previous manual run for the same schedule/routine, still within the + * AUTOMATION_LIVE_RUN_TTL_MS replay window). Auto-attaching to that stale run replays its own + * (unrelated) terminal `complete`/`error` event onto a brand-new trigger's stream, which is exactly + * the false "Run failed"-for-a-success-run bug (FN-7652). Bound how old a *finished* run may be and + * still be auto-attached; a still-`running` run has no age limit since it IS the in-flight trigger. + */ + private static readonly AUTO_ATTACH_STALE_WINDOW_MS = 10_000; + + start(scheduleId: string, runId = createAutomationRunId()): AutomationLiveRunRecord { + const run: AutomationLiveRunRecord = { + runId, + scheduleId, + status: "running", + buffer: new SessionEventBuffer(AUTOMATION_LIVE_EVENT_CAPACITY), + listeners: new Set(), + output: "", + startedAt: Date.now(), + }; + this.runs.set(runId, run); + this.latestRunBySchedule.set(scheduleId, runId); + this.broadcast(runId, { type: "run", data: { runId, scheduleId, status: "running" } }); + const starters = this.scheduleStartListeners.get(scheduleId); + if (starters) { + for (const listener of [...starters]) listener(run); + } + return run; + } + + get(runId: string | undefined, scheduleId: string): AutomationLiveRunRecord | undefined { + if (runId) { + const run = this.runs.get(runId); + return run?.scheduleId === scheduleId ? run : undefined; + } + const latestRunId = this.latestRunBySchedule.get(scheduleId); + return latestRunId ? this.runs.get(latestRunId) : undefined; + } + + /* + * FNXC:AutomationLiveOutput 2026-07-07-00:00 (FN-7652): + * Used by GET .../run/stream instead of `get()` when the caller supplied no explicit runId. Returns + * the latest run for the schedule/routine only when it is still live, or finished recently enough + * (AUTO_ATTACH_STALE_WINDOW_MS) to plausibly be the run this very request is racing against. + * Otherwise returns undefined so the caller falls back to `subscribeToScheduleStart` and waits for + * its own fresh `run` event, instead of replaying an unrelated older run's terminal outcome. + */ + getForAutoAttach(scheduleId: string): AutomationLiveRunRecord | undefined { + const latestRunId = this.latestRunBySchedule.get(scheduleId); + if (!latestRunId) return undefined; + const run = this.runs.get(latestRunId); + if (!run) return undefined; + if (run.status === "running") return run; + if (Date.now() - run.startedAt < AutomationLiveRunRegistry.AUTO_ATTACH_STALE_WINDOW_MS) return run; + return undefined; + } + + getBufferedEvents(runId: string, lastEventId = 0) { + return this.runs.get(runId)?.buffer.getEventsSince(lastEventId) ?? []; + } + + subscribe(runId: string, listener: (event: AutomationLiveEvent, eventId: number) => void): () => void { + const run = this.runs.get(runId); + if (!run) return () => {}; + run.listeners.add(listener); + return () => run.listeners.delete(listener); + } + + subscribeToScheduleStart(scheduleId: string, listener: (run: AutomationLiveRunRecord) => void): () => void { + let listeners = this.scheduleStartListeners.get(scheduleId); + if (!listeners) { + listeners = new Set(); + this.scheduleStartListeners.set(scheduleId, listeners); + } + listeners.add(listener); + return () => { + listeners?.delete(listener); + if (listeners?.size === 0) this.scheduleStartListeners.delete(scheduleId); + }; + } + + broadcast(runId: string, event: AutomationLiveEvent): number | undefined { + const run = this.runs.get(runId); + if (!run) return undefined; + const eventId = run.buffer.push(event.type, JSON.stringify(event.data ?? {})); + for (const listener of [...run.listeners]) listener(event, eventId); + return eventId; + } + + appendText(runId: string, delta: string): void { + const run = this.runs.get(runId); + if (!run) return; + const capped = capAutomationLiveText(run.output, delta); + run.output = capped.next; + if (capped.delta) this.broadcast(runId, { type: "output", data: { text: capped.delta } }); + } + + complete(runId: string, result: import("@fusion/core").AutomationRunResult): void { + const run = this.runs.get(runId); + if (!run) return; + run.status = result.success ? "complete" : "error"; + this.broadcast(runId, { type: result.success ? "complete" : "error", data: result.success ? { runId, result } : { runId, result, message: result.error ?? "Automation run failed" } }); + this.scheduleCleanup(run); + } + + fail(runId: string, message: string): void { + const run = this.runs.get(runId); + if (!run) return; + run.status = "error"; + this.broadcast(runId, { type: "error", data: { runId, message } }); + this.scheduleCleanup(run); + } + + private scheduleCleanup(run: AutomationLiveRunRecord): void { + if (run.cleanupTimer) clearTimeout(run.cleanupTimer); + run.cleanupTimer = setTimeout(() => { + this.runs.delete(run.runId); + if (this.latestRunBySchedule.get(run.scheduleId) === run.runId) { + this.latestRunBySchedule.delete(run.scheduleId); + } + }, AUTOMATION_LIVE_RUN_TTL_MS); + run.cleanupTimer.unref?.(); + } +} + +export const automationLiveRuns = new AutomationLiveRunRegistry(); +export const MANUAL_RUN_AI_SYSTEM_PROMPT = [ + "You are an AI automation agent executing a scheduled task.", + "You may use the coding tools selected for this automation step; follow any tool restrictions exactly.", + "Execute the prompt precisely and return concise, structured results.", + "When analyzing code or data, provide actionable summaries.", +].join("\n"); + +export function createAutomationLiveRunCallbacks(runId: string): AutomationLiveRunCallbacks { + return { + onStep: (data) => automationLiveRuns.broadcast(runId, { type: "step", data: { runId, ...data } }), + onText: (delta) => automationLiveRuns.appendText(runId, delta), + onToolStart: (name, args) => automationLiveRuns.broadcast(runId, { + type: "tool", + data: { runId, status: "started", name, args: previewAutomationLiveValue(args) }, + }), + onToolEnd: (name, isError, result) => automationLiveRuns.broadcast(runId, { + type: "tool", + data: { runId, status: "completed", name, isError, result: previewAutomationLiveValue(result) }, + }), + }; +} + + +/** Creates the shared automation/routine live SSE stream handler without duplicating generic SSE parsing. */ +export function createAutomationRunStreamHandlerFactory(deps: { + parseScopeParam(req: Request): ScopeValue | undefined; + rethrowAsApiError(error: unknown, fallbackMessage?: string): never; + parseLastEventId(req: Request): number | undefined; + replayBufferedSSE(res: Response, bufferedEvents: Array<{ id: number; event: string; data: string }>): boolean; +}) { + const { parseScopeParam, rethrowAsApiError, parseLastEventId, replayBufferedSSE } = deps; + return function makeRunStreamHandler(config: { + resolveStore: (req: Request, scope: ScopeValue | undefined) => TStore; + getEntity: (store: TStore, id: string) => Promise; + notFoundMessage: string; + }): (req: Request, res: Response) => Promise { + const { resolveStore, getEntity, notFoundMessage } = config; + return async (req: Request, res: Response) => { + const scope = parseScopeParam(req); + const store = resolveStore(req, scope); + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + + try { + const entity = await getEntity(store, id); + if (scope && entity.scope !== scope) { + throw notFound(notFoundMessage); + } + + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + res.setHeader("X-Accel-Buffering", "no"); + res.flushHeaders(); + res.write(": connected\n\n"); + + const requestedRunId = typeof req.query.runId === "string" ? req.query.runId : undefined; + const lastEventId = parseLastEventId(req); + let unsubscribeRun: (() => void) | undefined; + let unsubscribeStart: (() => void) | undefined; + + const attachRun = (run: AutomationLiveRunRecord) => { + const buffered = automationLiveRuns.getBufferedEvents(run.runId, lastEventId ?? 0); + if (!replayBufferedSSE(res, buffered)) { + res.end(); + return; + } + if (run.status !== "running") { + res.end(); + return; + } + unsubscribeRun = automationLiveRuns.subscribe(run.runId, (event, eventId) => { + if (!writeSSEEvent(res, event.type, JSON.stringify(event.data ?? {}), eventId)) { + unsubscribeRun?.(); + return; + } + if (event.type === "complete" || event.type === "error") { + unsubscribeRun?.(); + res.end(); + } + }); + }; + + // FNXC:AutomationLiveOutput 2026-07-07-00:00 (FN-7652): no explicit runId means "attach me to + // this request's own run" — use getForAutoAttach so a stale finished run from before this + // trigger isn't mistaken for it (see AutomationLiveRunRegistry.getForAutoAttach). + const existingRun = requestedRunId + ? automationLiveRuns.get(requestedRunId, entity.id) + : automationLiveRuns.getForAutoAttach(entity.id); + if (existingRun) { + attachRun(existingRun); + } else if (requestedRunId) { + writeSSEEvent(res, "error", JSON.stringify({ message: "Live run not found or expired", runId: requestedRunId })); + res.end(); + } else { + unsubscribeStart = automationLiveRuns.subscribeToScheduleStart(entity.id, (run) => { + unsubscribeStart?.(); + attachRun(run); + }); + } + + req.on("close", () => { + unsubscribeRun?.(); + unsubscribeStart?.(); + }); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + throw notFound(notFoundMessage); + } + rethrowAsApiError(err); + } + }; + } + +} diff --git a/packages/dashboard/src/routes/automation-step-execution.ts b/packages/dashboard/src/routes/automation-step-execution.ts new file mode 100644 index 0000000000..4d1ed5752c --- /dev/null +++ b/packages/dashboard/src/routes/automation-step-execution.ts @@ -0,0 +1,445 @@ +import type { TaskStore } from "@fusion/core"; +import { AUTOMATION_SELECTABLE_TOOLS, THINKING_LEVELS, resolveExecutionSettingsModel } from "@fusion/core"; +import { createFnAgent as engineCreateFnAgentForRefine, promptWithFallback as enginePromptWithFallback, resolveMcpServersForStore, isInProcessBackupCommand, isInProcessMemoryBackupCommand, formatInProcessBackupError } from "@fusion/engine"; +import { ApiError } from "../api-error.js"; +import { AUTOMATION_MAX_BUFFER, AUTOMATION_MAX_OUTPUT, DEFAULT_AUTOMATION_TIMEOUT_MS, MANUAL_RUN_AI_SYSTEM_PROMPT, type AutomationLiveRunCallbacks } from "./automation-live-run.js"; + +/** + * Validate an array of automation steps. + * Returns an error string if invalid, or null if valid. + */ +export function validateAutomationSteps(steps: unknown[]): string | null { + for (let i = 0; i < steps.length; i++) { + const step = steps[i] as Record; + if (!step.id || typeof step.id !== "string") { + return `Step ${i + 1}: id is required`; + } + if (!step.type || (step.type !== "command" && step.type !== "ai-prompt" && step.type !== "create-task")) { + return `Step ${i + 1}: type must be "command", "ai-prompt", or "create-task"`; + } + if (!step.name || typeof step.name !== "string" || !step.name.trim()) { + return `Step ${i + 1}: name is required`; + } + if (step.type === "command") { + if (!step.command || typeof step.command !== "string" || !step.command.trim()) { + return `Step ${i + 1}: command is required for command steps`; + } + } + if (step.type === "ai-prompt") { + if (!step.prompt || typeof step.prompt !== "string" || !step.prompt.trim()) { + return `Step ${i + 1}: prompt is required for ai-prompt steps`; + } + if (step.allowedTools !== undefined) { + if (!Array.isArray(step.allowedTools)) { + return `Step ${i + 1}: allowedTools must be an array when provided`; + } + const selectableTools = new Set(AUTOMATION_SELECTABLE_TOOLS.map((tool) => tool.toLowerCase())); + for (const tool of step.allowedTools) { + if (typeof tool !== "string" || !selectableTools.has(tool.trim().toLowerCase())) { + return `Step ${i + 1}: allowedTools contains unknown tool "${String(tool)}"`; + } + } + } + } + if (step.type === "create-task") { + if (!step.taskDescription || typeof step.taskDescription !== "string" || !step.taskDescription.trim()) { + return `Step ${i + 1}: taskDescription is required for create-task steps`; + } + } + // Validate model fields are both present or both absent + const hasProvider = step.modelProvider && typeof step.modelProvider === "string"; + const hasModelId = step.modelId && typeof step.modelId === "string"; + if ((hasProvider && !hasModelId) || (!hasProvider && hasModelId)) { + return `Step ${i + 1}: modelProvider and modelId must both be present or both absent`; + } + /* + FNXC:Automations 2026-07-12-19:14: + Schedule and routine AI-capable steps can persist an optional reasoning-effort override. Validate it against the central THINKING_LEVELS set so routes accept omission/inherit plus known levels and reject drift before JSON step storage. + */ + if (step.thinkingLevel !== undefined) { + if (typeof step.thinkingLevel !== "string" || !THINKING_LEVELS.includes(step.thinkingLevel as (typeof THINKING_LEVELS)[number])) { + return `Step ${i + 1}: thinkingLevel must be one of ${THINKING_LEVELS.join(", ")}`; + } + } + } + return null; +} + +/** + * Execute a single shell command (used by manual run endpoint). + * + * FNXC:DatabaseBackup 2026-07-04-00:00: + * FN-7537: the dashboard's manual automation/schedule run path (legacy single-command schedules and + * `command`-type steps in `executeScheduleSteps`) previously always shelled the command out via `exec()`, + * unlike the scheduler (`CronRunner`) and routine runner (`RoutineRunner.executeCommand`), which both + * intercept the auto-backup command and run it in-process via the engine's already-open `TaskStore`. On + * hosts without a global `fn`/`runfusion.ai` binary on PATH this made a manual "Database Backup" run fail + * while the identical cron-triggered run succeeded. Mirror the cron/routine-runner interception here so a + * manual run behaves identically: when a `taskStore` is available and the command matches + * `isInProcessBackupCommand`/`isInProcessMemoryBackupCommand`, run the backup in-process instead of + * shelling out, using the same `formatInProcessBackupError` message shape on failure (parity with FN-7095). + */ +export async function executeSingleCommand( + command: string, + timeoutMs: number | undefined, + startedAt: string, + taskStore?: TaskStore, +): Promise { + if (taskStore && isInProcessBackupCommand(command)) { + const fusionDir = taskStore.getFusionDir(); + try { + const { runBackupCommand, resolveGlobalBackupRoot } = await import("@fusion/core"); + const settings = await taskStore.getSettings(); + const result = await runBackupCommand(resolveGlobalBackupRoot(taskStore), settings); + const output = truncateAutomationOutput(result.output ?? "", ""); + return { + success: result.success, + output, + error: result.success ? undefined : formatInProcessBackupError(output, fusionDir), + startedAt, + completedAt: new Date().toISOString(), + }; + } catch (err) { + return { + success: false, + output: "", + error: formatInProcessBackupError(err, fusionDir), + startedAt, + completedAt: new Date().toISOString(), + }; + } + } + + if (taskStore && isInProcessMemoryBackupCommand(command)) { + const fusionDir = taskStore.getFusionDir(); + try { + const { runMemoryBackupCommand } = await import("@fusion/core"); + const settings = await taskStore.getSettings(); + const result = await runMemoryBackupCommand(fusionDir, settings); + return { + success: result.success, + output: truncateAutomationOutput(result.output ?? "", ""), + error: result.success ? undefined : result.output, + startedAt, + completedAt: new Date().toISOString(), + }; + } catch (err) { + return { + success: false, + output: "", + error: err instanceof Error ? err.message : String(err), + startedAt, + completedAt: new Date().toISOString(), + }; + } + } + + const { exec } = await import("node:child_process"); + const { promisify } = await import("node:util"); + const execAsyncFn = promisify(exec); + + const isWindows = process.platform === "win32"; + + try { + const { stdout, stderr } = await execAsyncFn(command, { + timeout: timeoutMs ?? DEFAULT_AUTOMATION_TIMEOUT_MS, + maxBuffer: AUTOMATION_MAX_BUFFER, + shell: isWindows ? "cmd.exe" : "/bin/sh", + }); + + return { + success: true, + output: truncateAutomationOutput(stdout, stderr), + startedAt, + completedAt: new Date().toISOString(), + }; + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + const execErr = err as NodeJS.ErrnoException & { stdout?: string; stderr?: string; killed?: boolean }; + + return { + success: false, + output: truncateAutomationOutput(execErr.stdout ?? "", execErr.stderr ?? ""), + error: execErr.killed + ? `Command timed out after ${(timeoutMs ?? DEFAULT_AUTOMATION_TIMEOUT_MS) / 1000}s` + : (err instanceof Error ? err.message : String(err)), + startedAt, + completedAt: new Date().toISOString(), + }; + } +} + +function truncateAutomationOutput(stdout: string, stderr: string): string { + let output = stdout; + if (stderr) output += stdout ? `\n--- stderr ---\n${stderr}` : stderr; + return output.length > AUTOMATION_MAX_OUTPUT ? `${output.slice(0, AUTOMATION_MAX_OUTPUT)}\n[output truncated]` : output; +} + +export async function resolveManualAiPromptMcpServers(taskStore: TaskStore) { + return (await resolveMcpServersForStore(taskStore)).servers; +} + +async function executeAiPromptStep( + step: import("@fusion/core").AutomationStep, + timeoutMs: number, + startedAt: string, + taskStore: TaskStore, + liveCallbacks?: AutomationLiveRunCallbacks, + getCreateFnAgent: () => typeof import("@fusion/engine").createFnAgent | undefined = () => engineCreateFnAgentForRefine, +): Promise { + if (!step.prompt?.trim()) { + return { + stepId: step.id, + stepName: step.name, + stepIndex: 0, + success: false, + output: "", + error: "AI prompt step has no prompt specified", + startedAt, + completedAt: new Date().toISOString(), + }; + } + + const createFnAgent = getCreateFnAgent(); + const promptWithFallback = enginePromptWithFallback; + if (!createFnAgent) { + return { + stepId: step.id, + stepName: step.name, + stepIndex: 0, + success: false, + output: "", + error: "AI agent not available", + startedAt, + completedAt: new Date().toISOString(), + }; + } + + const settings = await taskStore.getSettings(); + // Resolve model: step override → project execution lane → global execution lane → project default override → global default + // FNXC:ModelResolution 2026-06-25-12:00: FN-7039 requires manual AI-prompt workflow runs to use execution-lane settings before default settings because these runs have no task/runtime model context. + const defaultModel = resolveExecutionSettingsModel(settings); + const modelProvider = step.modelProvider?.trim() || defaultModel.provider; + const modelId = step.modelId?.trim() || defaultModel.modelId; + let responseText = ""; + /* + * FNXC:McpConfig 2026-06-26-00:00: + * Manual AI-prompt workflow runs are operator-triggered coding-agent sessions, so they must receive the task-store resolved MCP set just like task executor lanes. Do not log resolved MCP payloads because env/header values may contain materialized secrets. + * + * FNXC:Automations 2026-07-12-20:30: + * Manual/inline automation AI runs bypass CronRunner's executor seam, so they must pass the persisted step thinking level directly as createFnAgent.defaultThinkingLevel. Undefined or blank values preserve inherited defaults. + */ + const mcpServers = await resolveManualAiPromptMcpServers(taskStore); + const defaultThinkingLevel = step.thinkingLevel?.trim() || undefined; + + const { session } = await createFnAgent({ + cwd: process.cwd(), + systemPrompt: MANUAL_RUN_AI_SYSTEM_PROMPT, + tools: "coding", + toolsAllowlist: step.allowedTools, + defaultProvider: modelProvider, + defaultModelId: modelId, + defaultThinkingLevel, + mcpServers, + onText: (delta: string) => { + responseText += delta; + liveCallbacks?.onText?.(delta); + }, + onToolStart: liveCallbacks?.onToolStart, + onToolEnd: liveCallbacks?.onToolEnd, + }); + + try { + const promptPromise = promptWithFallback(session, step.prompt); + const timeoutPromise = new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error(`AI prompt step timed out after ${timeoutMs / 1000}s`)), timeoutMs); + }); + + await Promise.race([promptPromise, timeoutPromise]); + + return { + stepId: step.id, + stepName: step.name, + stepIndex: 0, + success: true, + output: responseText.length > AUTOMATION_MAX_OUTPUT + ? responseText.slice(0, AUTOMATION_MAX_OUTPUT) + "\n[output truncated]" + : responseText, + startedAt, + completedAt: new Date().toISOString(), + }; + } catch (err: unknown) { + return { + stepId: step.id, + stepName: step.name, + stepIndex: 0, + success: false, + output: "", + error: err instanceof Error ? err.message : String(err), + startedAt, + completedAt: new Date().toISOString(), + }; + } finally { + try { + session.dispose(); + } catch { + // best-effort cleanup + } + } +} + +async function executeCreateTaskStep( + step: import("@fusion/core").AutomationStep, + startedAt: string, + taskStore: TaskStore, +): Promise { + if (!step.taskDescription?.trim()) { + return { + stepId: step.id, + stepName: step.name, + stepIndex: 0, + success: false, + output: "", + error: "Create-task step has no task description specified", + startedAt, + completedAt: new Date().toISOString(), + }; + } + + try { + /* + FNXC:Automations 2026-07-12-20:30: + Manual/inline create-task automation runs map the persisted step thinking level onto the created task so manual execution matches scheduled and routine behavior. + */ + const task = await taskStore.createTask({ + title: step.taskTitle?.trim() || undefined, + description: step.taskDescription.trim(), + column: (step.taskColumn as import("@fusion/core").Column) || "triage", + modelProvider: step.modelProvider?.trim() || undefined, + modelId: step.modelId?.trim() || undefined, + thinkingLevel: (step.thinkingLevel?.trim() || undefined) as import("@fusion/core").TaskCreateInput["thinkingLevel"], + source: { + sourceType: "workflow_step", + sourceMetadata: { stepId: step.id }, + }, + }); + return { + stepId: step.id, + stepName: step.name, + stepIndex: 0, + success: true, + output: `Created task ${task.id}: ${task.title || task.description.slice(0, 80)}`, + startedAt, + completedAt: new Date().toISOString(), + }; + } catch (err: unknown) { + return { + stepId: step.id, + stepName: step.name, + stepIndex: 0, + success: false, + output: "", + error: err instanceof Error ? err.message : String(err), + startedAt, + completedAt: new Date().toISOString(), + }; + } +} + +/** + * Execute all steps in a multi-step schedule (used by manual run endpoint). + */ +export async function executeScheduleSteps( + schedule: import("@fusion/core").ScheduledTask, + startedAt: string, + taskStore: TaskStore, + liveCallbacks?: AutomationLiveRunCallbacks, + getCreateFnAgent: () => typeof import("@fusion/engine").createFnAgent | undefined = () => engineCreateFnAgentForRefine, +): Promise { + const steps = schedule.steps!; + const stepResults: import("@fusion/core").AutomationStepResult[] = []; + let overallSuccess = true; + let stoppedEarly = false; + + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + const stepStartedAt = new Date().toISOString(); + const timeoutMs = step.timeoutMs ?? schedule.timeoutMs ?? DEFAULT_AUTOMATION_TIMEOUT_MS; + + let stepResult: import("@fusion/core").AutomationStepResult; + liveCallbacks?.onStep?.({ stepIndex: i, stepId: step.id, stepName: step.name, stepType: step.type, status: "started" }); + + if (step.type === "command") { + const cmdResult = await executeSingleCommand(step.command ?? "", timeoutMs, stepStartedAt, taskStore); + stepResult = { + stepId: step.id, + stepName: step.name, + stepIndex: i, + success: cmdResult.success, + output: cmdResult.output, + error: cmdResult.error, + startedAt: stepStartedAt, + completedAt: cmdResult.completedAt, + }; + } else if (step.type === "ai-prompt") { + stepResult = await executeAiPromptStep(step, timeoutMs, stepStartedAt, taskStore, liveCallbacks, getCreateFnAgent); + stepResult.stepIndex = i; + } else if (step.type === "create-task") { + stepResult = await executeCreateTaskStep(step, stepStartedAt, taskStore); + stepResult.stepIndex = i; + } else { + stepResult = { + stepId: step.id, + stepName: step.name, + stepIndex: i, + success: false, + output: "", + error: `Unknown step type: "${step.type}"`, + startedAt: stepStartedAt, + completedAt: new Date().toISOString(), + }; + } + + stepResults.push(stepResult); + liveCallbacks?.onStep?.({ stepIndex: i, stepId: step.id, stepName: step.name, stepType: step.type, status: "completed", success: stepResult.success, error: stepResult.error }); + if (step.type !== "ai-prompt" && stepResult.output) { + liveCallbacks?.onText?.(stepResult.output); + } + + if (!stepResult.success) { + overallSuccess = false; + if (!step.continueOnFailure) { + stoppedEarly = true; + break; + } + } + } + + // Aggregate output + const outputParts: string[] = []; + for (const sr of stepResults) { + outputParts.push(`=== Step ${sr.stepIndex + 1}: ${sr.stepName} (${sr.success ? "success" : "FAILED"}) ===`); + if (sr.output) outputParts.push(sr.output); + if (sr.error) outputParts.push(`Error: ${sr.error}`); + } + let output = outputParts.join("\n"); + if (output.length > AUTOMATION_MAX_OUTPUT) { + output = output.slice(0, AUTOMATION_MAX_OUTPUT) + "\n[output truncated]"; + } + + const failedSteps = stepResults.filter((sr) => !sr.success); + const error = failedSteps.length > 0 + ? `${failedSteps.length} step(s) failed: ${failedSteps.map((s) => s.stepName).join(", ")}${stoppedEarly ? " (execution stopped)" : ""}` + : undefined; + + return { + success: overallSuccess, + output, + error, + startedAt, + completedAt: new Date().toISOString(), + stepResults, + }; +} diff --git a/packages/dashboard/src/routes/plugin-bundled-runtimes.ts b/packages/dashboard/src/routes/plugin-bundled-runtimes.ts new file mode 100644 index 0000000000..a9451d1fcb --- /dev/null +++ b/packages/dashboard/src/routes/plugin-bundled-runtimes.ts @@ -0,0 +1,88 @@ +import { resolve, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import * as nodeFs from "node:fs"; +import { hermesRuntimeMetadata } from "@fusion-plugin-examples/hermes-runtime"; +import { openclawRuntimeMetadata } from "@fusion-plugin-examples/openclaw-runtime"; + +// Bundled runtime metadata exposed in /api/plugins/runtimes even when the +// corresponding plugin has not been explicitly installed. Installed plugins +// override these entries by runtimeId. +export const BUNDLED_PLUGIN_RUNTIMES: Array<{ + pluginId: string; + runtimeId: string; + name: string; + description?: string; + version: string; +}> = [ + { + pluginId: "fusion-plugin-hermes-runtime", + runtimeId: hermesRuntimeMetadata.runtimeId, + name: hermesRuntimeMetadata.name, + ...(hermesRuntimeMetadata.description ? { description: hermesRuntimeMetadata.description } : {}), + version: hermesRuntimeMetadata.version ?? "0.0.0", + }, + { + pluginId: "fusion-plugin-openclaw-runtime", + runtimeId: openclawRuntimeMetadata.runtimeId, + name: openclawRuntimeMetadata.name, + ...(openclawRuntimeMetadata.description ? { description: openclawRuntimeMetadata.description } : {}), + version: openclawRuntimeMetadata.version ?? "0.0.0", + }, + { + pluginId: "fusion-plugin-paperclip-runtime", + runtimeId: "paperclip", + name: "Paperclip Runtime", + description: "Drives a Paperclip agent via the wakeup + heartbeat-run REST API", + version: "1.0.0", + }, +]; +const BUNDLED_PLUGIN_IDS = new Set([ + "fusion-plugin-dependency-graph", + "fusion-plugin-reports", + "fusion-plugin-whatsapp-chat", + "fusion-plugin-roadmap", + "fusion-plugin-hermes-runtime", + "fusion-plugin-openclaw-runtime", + "fusion-plugin-paperclip-runtime", + "fusion-plugin-cursor-runtime", + "fusion-plugin-grok-runtime", + "fusion-plugin-claude-runtime", + "fusion-plugin-omp-runtime", + "fusion-plugin-cli-printing-press", + "fusion-plugin-compound-engineering", + "fusion-plugin-quality", +]); + +export function extractBundledPluginId(pathInput: string): string | null { + const normalized = pathInput.replace(/\\/gu, "/").replace(/\/+$/u, "").trim(); + if (BUNDLED_PLUGIN_IDS.has(normalized)) { + return normalized; + } + + for (const pluginId of BUNDLED_PLUGIN_IDS) { + if (normalized.endsWith(`/plugins/${pluginId}`)) { + return pluginId; + } + } + + return null; +} + +export function resolveBundledPluginDirInDashboard(pluginId: string): string | null { + const moduleDir = resolve(fileURLToPath(import.meta.url), ".."); + const dashboardPackageRoot = resolve(moduleDir, ".."); + const candidates = [ + join(dashboardPackageRoot, "dist", "plugins", pluginId), + join(dashboardPackageRoot, "plugins", pluginId), + join(dashboardPackageRoot, "..", "..", "plugins", pluginId), + ]; + + for (const candidate of candidates) { + if (nodeFs.existsSync(join(candidate, "manifest.json"))) { + return candidate; + } + } + + return null; +} + diff --git a/packages/dashboard/src/routes/register-plugins-automation.ts b/packages/dashboard/src/routes/register-plugins-automation.ts index ad8a175b24..99fbb3ce1a 100644 --- a/packages/dashboard/src/routes/register-plugins-automation.ts +++ b/packages/dashboard/src/routes/register-plugins-automation.ts @@ -1,5 +1,1541 @@ +import type { NextFunction, Request, Response } from "express"; +import { AutomationStore, RoutineStore, isWebhookTrigger, resolvePluginEntryPath, type RoutineTriggerType, type ScheduleType } from "@fusion/core"; +import { ApiError, badRequest, conflict, internalError, notFound } from "../api-error.js"; +import { verifyWebhookSignature } from "../github-webhooks.js"; +import { resolvePluginManifest } from "../plugin-routes.js"; +import { isAbsolute, resolve } from "node:path"; +import * as nodeFs from "node:fs"; import type { ApiRoutesContext } from "./types.js"; +import { automationLiveRuns, createAutomationLiveRunCallbacks, createAutomationRunStreamHandlerFactory } from "./automation-live-run.js"; +import { executeScheduleSteps, executeSingleCommand, validateAutomationSteps } from "./automation-step-execution.js"; +import { BUNDLED_PLUGIN_RUNTIMES, extractBundledPluginId, resolveBundledPluginDirInDashboard } from "./plugin-bundled-runtimes.js"; + +export interface PluginsAutomationRouteDependencies { + parseLastEventId(req: Request): number | undefined; + replayBufferedSSE(res: Response, bufferedEvents: Array<{ id: number; event: string; data: string }>): boolean; + getCreateFnAgent: () => typeof import("@fusion/engine").createFnAgent | undefined; +} + +/* +FNXC:PluginsAutomationRoutes 2026-07-19-12:00: +Automation, routine, and plugin-management endpoints live in this registrar so routes.ts remains an orchestrator. Preserve registration order: Express parameter matching makes operation paths and the registry pass-through precedence-sensitive. +*/ +export function registerPluginsAutomationRoutes(ctx: ApiRoutesContext, deps: PluginsAutomationRouteDependencies): void { + const { router, options, parseScopeParam, resolveAutomationStore, resolveRoutineStore, resolveRoutineRunner, getScopedStore, getProjectContext, rethrowAsApiError, runtimeLogger } = ctx; + const makeRunStreamHandler = createAutomationRunStreamHandlerFactory({ parseScopeParam, rethrowAsApiError, ...deps }); + // ── Automation / Scheduled Task Routes ──────────────────────────── + // + // Scope-aware endpoints: Accept `scope=global|project` query param or body field. + // - When scope=global: Operations target the global automation store + // - When scope=project: Operations target project-scoped automations (filtered by scope) + // - When scope is omitted: Legacy default behavior (global store, backward compatible) + // + // Error codes: + // - 400: Invalid scope value or validation failure + // - 404: Schedule not found + // - 503: Automation store unavailable + + // GET /automations — list all scheduled tasks (optionally filtered by scope) + router.get("/automations", async (req: Request, res: Response) => { + // Return empty array when no store available (legacy backward-compatible behavior) + if (!options?.automationStore) { + return res.json([]); + } + + try { + const scope = parseScopeParam(req); + const automationStore = resolveAutomationStore(req, scope); + + // Get all schedules and filter by scope if specified + // When scope is omitted, return all schedules (legacy behavior) + const allSchedules = await automationStore.listSchedules(); + if (scope) { + const filteredSchedules = allSchedules.filter((s) => s.scope === scope); + res.json(filteredSchedules); + } else { + res.json(allSchedules); + } + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err); + } + }); + + // POST /automations — create a new schedule (with optional scope) + router.post("/automations", async (req: Request, res: Response) => { + const scope = parseScopeParam(req); + const automationStore = resolveAutomationStore(req, scope); + + try { + const { name, description, scheduleType, cronExpression, command, enabled, timeoutMs, steps } = req.body; + + // Validation + if (!name?.trim()) { + throw badRequest("Name is required"); + } + const hasSteps = Array.isArray(steps) && steps.length > 0; + if (!hasSteps && !command?.trim()) { + throw badRequest("Command is required when no steps are provided"); + } + const validTypes = ["hourly", "daily", "weekly", "monthly", "custom", "every15Minutes", "every30Minutes", "every2Hours", "every6Hours", "every12Hours", "weekdays"]; + if (!scheduleType || !validTypes.includes(scheduleType)) { + throw badRequest(`Invalid schedule type. Must be one of: ${validTypes.join(", ")}`); + } + if (scheduleType === "custom") { + if (!cronExpression?.trim()) { + throw badRequest("Cron expression is required for custom schedule type"); + } + if (!AutomationStore.isValidCron(cronExpression)) { + throw badRequest(`Invalid cron expression: "${cronExpression}"`); + } + } + // Validate steps if provided + if (hasSteps) { + const stepErr = validateAutomationSteps(steps); + if (stepErr) { + throw badRequest(stepErr); + } + } + + // Determine scope for the new schedule + // Default to "project" for backward compatibility when scope is omitted + const scheduleScope = scope ?? "project"; + + const schedule = await automationStore.createSchedule({ + name, + description, + scheduleType: scheduleType as ScheduleType, + cronExpression, + command: command ?? "", + enabled, + timeoutMs, + steps: hasSteps ? steps : undefined, + scope: scheduleScope, + }); + res.status(201).json(schedule); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err); + } + }); + + // GET /automations/:id — get a single schedule + router.get("/automations/:id", async (req: Request, res: Response) => { + const scope = parseScopeParam(req); + const automationStore = resolveAutomationStore(req, scope); + + try { + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const schedule = await automationStore.getSchedule(id); + + // Scope isolation: if scope is specified, verify the schedule belongs to that scope + if (scope && schedule.scope !== scope) { + throw notFound("Schedule not found"); + } + + res.json(schedule); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + throw notFound("Schedule not found"); + } + rethrowAsApiError(err); + } + }); + + // PATCH /automations/:id — update a schedule + router.patch("/automations/:id", async (req: Request, res: Response) => { + const scope = parseScopeParam(req); + const automationStore = resolveAutomationStore(req, scope); + + try { + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + + // Scope isolation: if scope is specified, verify the schedule belongs to that scope + // by fetching it first (can't filter in update without scope support in store) + if (scope) { + const existing = await automationStore.getSchedule(id); + if (existing.scope !== scope) { + throw notFound("Schedule not found"); + } + } + + const { name, description, scheduleType, cronExpression, command, enabled, timeoutMs, steps } = req.body; + + // Validate cron if switching to custom + if (scheduleType === "custom" && cronExpression) { + if (!AutomationStore.isValidCron(cronExpression)) { + throw badRequest(`Invalid cron expression: "${cronExpression}"`); + } + } + + // Validate steps if provided + if (Array.isArray(steps) && steps.length > 0) { + const stepErr = validateAutomationSteps(steps); + if (stepErr) { + throw badRequest(stepErr); + } + } + + const schedule = await automationStore.updateSchedule(id, { + name, + description, + scheduleType, + cronExpression, + command, + enabled, + timeoutMs, + steps: steps !== undefined ? steps : undefined, + }); + res.json(schedule); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + throw notFound("Schedule not found"); + } + if ((err instanceof Error ? err.message : String(err)).includes("cannot be empty") || (err instanceof Error ? err.message : String(err)).includes("Invalid cron")) { + throw badRequest(err instanceof Error ? err.message : String(err)); + } + rethrowAsApiError(err); + } + }); + + // DELETE /automations/:id — delete a schedule + router.delete("/automations/:id", async (req: Request, res: Response) => { + const scope = parseScopeParam(req); + const automationStore = resolveAutomationStore(req, scope); + + try { + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + + // Scope isolation: if scope is specified, verify the schedule belongs to that scope + if (scope) { + const existing = await automationStore.getSchedule(id); + if (existing.scope !== scope) { + throw notFound("Schedule not found"); + } + } + + const deleted = await automationStore.deleteSchedule(id); + res.json(deleted); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + throw notFound("Schedule not found"); + } + rethrowAsApiError(err); + } + }); + + // POST /automations/:id/run — trigger a manual run + router.post("/automations/:id/run", async (req: Request, res: Response) => { + const scope = parseScopeParam(req); + const automationStore = resolveAutomationStore(req, scope); + let liveRunId: string | undefined; + + try { + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const schedule = await automationStore.getSchedule(id); + + // Scope isolation: if scope is specified, verify the schedule belongs to that scope + if (scope && schedule.scope !== scope) { + throw notFound("Schedule not found"); + } + + const liveRun = automationLiveRuns.start(schedule.id); + liveRunId = liveRun.runId; + const liveCallbacks = createAutomationLiveRunCallbacks(liveRun.runId); + const startedAt = new Date().toISOString(); + const scopedStore = await getScopedStore(req); + let result: import("@fusion/core").AutomationRunResult; + + if (schedule.steps && schedule.steps.length > 0) { + // Multi-step execution + result = await executeScheduleSteps(schedule, startedAt, scopedStore, liveCallbacks, deps.getCreateFnAgent); + } else { + // Legacy single-command execution + // FNXC:Automations 2026-07-04-00:00: + // FN-7537: command/backup runs (including the new in-process backup branch inside + // executeSingleCommand) stream through the same onStep/onText live-run callbacks as every other + // step type, so the live-output panel populates during the run (step-start immediately, output once + // available) rather than only at the terminal `complete` event. + liveCallbacks.onStep?.({ stepIndex: 0, stepId: "command", stepName: schedule.name, stepType: "command", status: "started" }); + result = await executeSingleCommand(schedule.command, schedule.timeoutMs, startedAt, scopedStore); + liveCallbacks.onStep?.({ stepIndex: 0, stepId: "command", stepName: schedule.name, stepType: "command", status: "completed", success: result.success, error: result.error }); + if (result.output) liveCallbacks.onText?.(result.output); + } + + // Record the result + const updated = await automationStore.recordRun(schedule.id, result); + automationLiveRuns.complete(liveRun.runId, result); + res.json({ schedule: updated, result, liveRunId: liveRun.runId }); + } catch (err: unknown) { + if (liveRunId) { + automationLiveRuns.fail(liveRunId, err instanceof Error ? err.message : String(err)); + } + if (err instanceof ApiError) { + throw err; + } + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + throw notFound("Schedule not found"); + } + rethrowAsApiError(err); + } + }); + + /** + * FNXC:AutomationLiveOutput 2026-07-07-08:30 (FN-7663, follow-up from FN-7652): + * `/automations/:id/run/stream` and `/routines/:id/run/stream` are otherwise-identical SSE + * endpoints layered over the same `AutomationLiveRunRegistry` (`automationLiveRuns`). Before + * this consolidation, each route carried its own copy of the header/replay/subscribe/teardown + * logic — the FN-7652 live-output fix had to be applied twice, and any future fix could drift + * between the two copies. This single generic factory is parameterized ONLY by what actually + * differs between the two routes (store resolver, entity getter, not-found message) so a fix + * to the streaming behavior is written once and applies to both endpoints. + */ + // GET /automations/:id/run/stream — stream live manual-run output. + router.get( + "/automations/:id/run/stream", + makeRunStreamHandler({ + resolveStore: resolveAutomationStore, + getEntity: (store, id) => store.getSchedule(id), + notFoundMessage: "Schedule not found", + }), + ); + + // POST /automations/:id/toggle — toggle enabled/disabled + router.post("/automations/:id/toggle", async (req: Request, res: Response) => { + const scope = parseScopeParam(req); + const automationStore = resolveAutomationStore(req, scope); + + try { + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const schedule = await automationStore.getSchedule(id); + + // Scope isolation: if scope is specified, verify the schedule belongs to that scope + if (scope && schedule.scope !== scope) { + throw notFound("Schedule not found"); + } + + const updated = await automationStore.updateSchedule(id, { + enabled: !schedule.enabled, + }); + res.json(updated); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + throw notFound("Schedule not found"); + } + rethrowAsApiError(err); + } + }); + + // POST /automations/:id/steps/reorder — reorder steps + router.post("/automations/:id/steps/reorder", async (req: Request, res: Response) => { + const scope = parseScopeParam(req); + const automationStore = resolveAutomationStore(req, scope); + + try { + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + + // Scope isolation: if scope is specified, verify the schedule belongs to that scope + if (scope) { + const existing = await automationStore.getSchedule(id); + if (existing.scope !== scope) { + throw notFound("Schedule not found"); + } + } + + const { stepIds } = req.body; + if (!Array.isArray(stepIds)) { + throw badRequest("stepIds must be an array"); + } + const schedule = await automationStore.reorderSteps(id, stepIds); + res.json(schedule); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + throw notFound("Schedule not found"); + } + if ((err instanceof Error ? err.message : String(err)).includes("mismatch") || (err instanceof Error ? err.message : String(err)).includes("Unknown step") || (err instanceof Error ? err.message : String(err)).includes("no steps")) { + throw badRequest(err instanceof Error ? err.message : String(err)); + } + rethrowAsApiError(err); + } + }); + + // ── Routine Routes ────────────────────────────────────────────────── + // + // Scope-aware endpoints: Accept `scope=global|project` query param or body field. + // - When scope=global: Operations target the global routine store + // - When scope=project: Operations target project-scoped routines (filtered by scope) + // - When scope is omitted: Legacy default behavior (global store, backward compatible) + // + // Error codes: + // - 400: Invalid scope value or validation failure + // - 401: Webhook signature verification failed + // - 403: Webhook disabled/forbidden + // - 404: Routine not found + // - 503: Routine store or runner unavailable + + // GET /routines — list all routines (optionally filtered by scope) + router.get("/routines", async (req: Request, res: Response) => { + // Return empty array when no store available (legacy backward-compatible behavior) + if (!options?.routineStore) { + return res.json([]); + } + + try { + const scope = parseScopeParam(req); + const routineStore = resolveRoutineStore(req, scope); + + // Get all routines and filter by scope if specified + // When scope is omitted, return all routines (legacy behavior) + const allRoutines = await routineStore.listRoutines(); + if (scope) { + const filteredRoutines = allRoutines.filter((r) => r.scope === scope); + res.json(filteredRoutines); + } else { + res.json(allRoutines); + } + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err); + } + }); + + // POST /routines — create a new routine (with optional scope) + router.post("/routines", async (req: Request, res: Response) => { + const scope = parseScopeParam(req); + const routineStore = resolveRoutineStore(req, scope); + + try { + const { name, agentId, description, trigger, command, steps, timeoutMs, catchUpPolicy, executionPolicy, enabled } = req.body; + + // Validation + if (!name?.trim()) { + throw badRequest("Name is required"); + } + if (!trigger) { + throw badRequest("Trigger is required"); + } + if (!trigger.type) { + throw badRequest("Trigger must have a type field"); + } + const validTriggerTypes: RoutineTriggerType[] = ["cron", "webhook", "api", "manual"]; + if (!validTriggerTypes.includes(trigger.type)) { + throw badRequest(`Invalid trigger type. Must be one of: ${validTriggerTypes.join(", ")}`); + } + if (trigger.type === "cron") { + if (!trigger.cronExpression?.trim()) { + throw badRequest("Cron expression is required for cron trigger"); + } + if (!RoutineStore.isValidCron(trigger.cronExpression)) { + throw badRequest(`Invalid cron expression: "${trigger.cronExpression}"`); + } + } + if (trigger.type === "webhook") { + // Require an HMAC secret so the webhook endpoint authenticates callers + // via signed payloads. Without this, anyone who can reach the server + // and knows the routine id could trigger execution by sending an empty + // POST to /routines/:id/webhook. + if (typeof trigger.secret !== "string" || trigger.secret.trim().length < 16) { + throw badRequest( + "Webhook trigger requires a secret of at least 16 characters for HMAC signature verification", + ); + } + } + const hasSteps = Array.isArray(steps) && steps.length > 0; + const hasCommand = typeof command === "string" && command.trim().length > 0; + if (hasSteps) { + const stepErr = validateAutomationSteps(steps); + if (stepErr) { + throw badRequest(stepErr); + } + } + if (catchUpPolicy !== undefined) { + const validCatchUpPolicies: Array<"run" | "skip" | "run_one"> = ["run", "skip", "run_one"]; + if (!validCatchUpPolicies.includes(catchUpPolicy)) { + throw badRequest(`Invalid catchUpPolicy. Must be one of: ${validCatchUpPolicies.join(", ")}`); + } + } + if (executionPolicy !== undefined) { + const validExecutionPolicies: Array<"parallel" | "queue" | "reject"> = ["parallel", "queue", "reject"]; + if (!validExecutionPolicies.includes(executionPolicy)) { + throw badRequest(`Invalid executionPolicy. Must be one of: ${validExecutionPolicies.join(", ")}`); + } + } + + // Determine scope for the new routine + // Default to "project" for backward compatibility when scope is omitted + const routineScope = scope ?? "project"; + + const routine = await routineStore.createRoutine({ + name: name.trim(), + agentId: typeof agentId === "string" ? agentId.trim() : "", + description, + trigger, + command: hasCommand ? command : undefined, + steps: hasSteps ? steps : undefined, + timeoutMs, + catchUpPolicy, + executionPolicy, + enabled, + scope: routineScope, + }); + res.status(201).json(routine); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err); + } + }); + + // GET /routines/:id — get a single routine + router.get("/routines/:id", async (req: Request, res: Response) => { + const scope = parseScopeParam(req); + const routineStore = resolveRoutineStore(req, scope); + + try { + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const routine = await routineStore.getRoutine(id); + + // Scope isolation: if scope is specified, verify the routine belongs to that scope + if (scope && routine.scope !== scope) { + throw notFound("Routine not found"); + } + + res.json(routine); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + throw notFound("Routine not found"); + } + rethrowAsApiError(err); + } + }); + + // PATCH /routines/:id — update a routine + router.patch("/routines/:id", async (req: Request, res: Response) => { + const scope = parseScopeParam(req); + const routineStore = resolveRoutineStore(req, scope); + + try { + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + + // Scope isolation: if scope is specified, verify the routine belongs to that scope + if (scope) { + const existing = await routineStore.getRoutine(id); + if (existing.scope !== scope) { + throw notFound("Routine not found"); + } + } + + const { name, description, trigger, command, steps, timeoutMs, catchUpPolicy, executionPolicy, enabled } = req.body; + + // Validate name if provided + if (name !== undefined && !name.trim()) { + throw badRequest("Name cannot be empty"); + } + + // Validate trigger if provided + if (trigger !== undefined) { + if (trigger.type) { + const validTriggerTypes: RoutineTriggerType[] = ["cron", "webhook", "api", "manual"]; + if (!validTriggerTypes.includes(trigger.type)) { + throw badRequest(`Invalid trigger type. Must be one of: ${validTriggerTypes.join(", ")}`); + } + if (trigger.type === "cron" && trigger.cronExpression) { + if (!RoutineStore.isValidCron(trigger.cronExpression)) { + throw badRequest(`Invalid cron expression: "${trigger.cronExpression}"`); + } + } + if (trigger.type === "webhook") { + if (typeof trigger.secret !== "string" || trigger.secret.trim().length < 16) { + throw badRequest( + "Webhook trigger requires a secret of at least 16 characters for HMAC signature verification", + ); + } + } + } + } + if (Array.isArray(steps) && steps.length > 0) { + const stepErr = validateAutomationSteps(steps); + if (stepErr) { + throw badRequest(stepErr); + } + } + + const routine = await routineStore.updateRoutine(id, { + name: name !== undefined ? name.trim() : undefined, + description, + trigger, + command: command !== undefined ? command : undefined, + steps: steps !== undefined ? steps : undefined, + timeoutMs, + catchUpPolicy, + executionPolicy, + enabled, + }); + res.json(routine); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + throw notFound("Routine not found"); + } + if ((err instanceof Error ? err.message : String(err)).includes("cannot be empty") || (err instanceof Error ? err.message : String(err)).includes("Invalid cron")) { + throw badRequest(err instanceof Error ? err.message : String(err)); + } + rethrowAsApiError(err); + } + }); + + // DELETE /routines/:id — delete a routine + router.delete("/routines/:id", async (req: Request, res: Response) => { + const scope = parseScopeParam(req); + const routineStore = resolveRoutineStore(req, scope); + + try { + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + + // Scope isolation: if scope is specified, verify the routine belongs to that scope + if (scope) { + const existing = await routineStore.getRoutine(id); + if (existing.scope !== scope) { + throw notFound("Routine not found"); + } + } + + const deleted = await routineStore.deleteRoutine(id); + res.json(deleted); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + throw notFound("Routine not found"); + } + rethrowAsApiError(err); + } + }); + + // POST /routines/:id/run — manual trigger (backward-compatible alias for /trigger) + router.post("/routines/:id/run", async (req: Request, res: Response) => { + const scope = parseScopeParam(req); + const routineStore = resolveRoutineStore(req, scope); + const routineRunner = resolveRoutineRunner(req, scope); + + try { + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const routine = await routineStore.getRoutine(id); + + // Scope isolation: if scope is specified, verify the routine belongs to that scope + if (scope && routine.scope !== scope) { + throw notFound("Routine not found"); + } + + // Validate routine is enabled + if (!routine.enabled) { + throw badRequest("Routine is disabled"); + } + + const liveRun = automationLiveRuns.start(routine.id); + const liveCallbacks = createAutomationLiveRunCallbacks(liveRun.runId); + try { + // Execute via RoutineRunner (persistence handled by RoutineRunner.completeRoutineExecution) + const result = await routineRunner.triggerManual(id, liveCallbacks); + const updated = await routineStore.getRoutine(id); + automationLiveRuns.complete(liveRun.runId, result); + res.json({ routine: updated, result, liveRunId: liveRun.runId }); + } catch (err) { + automationLiveRuns.fail(liveRun.runId, err instanceof Error ? err.message : String(err)); + throw err; + } + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + throw notFound("Routine not found"); + } + rethrowAsApiError(err); + } + }); + + // POST /routines/:id/trigger — canonical manual trigger (uses RoutineRunner) + // POST /routines/:id/run is a backward-compatible alias with identical behavior + router.post("/routines/:id/trigger", async (req: Request, res: Response) => { + const scope = parseScopeParam(req); + const routineStore = resolveRoutineStore(req, scope); + const routineRunner = resolveRoutineRunner(req, scope); + + try { + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const routine = await routineStore.getRoutine(id); + + // Scope isolation: if scope is specified, verify the routine belongs to that scope + if (scope && routine.scope !== scope) { + throw notFound("Routine not found"); + } + + // Validate routine is enabled + if (!routine.enabled) { + throw badRequest("Routine is disabled"); + } + + const liveRun = automationLiveRuns.start(routine.id); + const liveCallbacks = createAutomationLiveRunCallbacks(liveRun.runId); + try { + // Execute via RoutineRunner (persistence handled by RoutineRunner.completeRoutineExecution) + const result = await routineRunner.triggerManual(id, liveCallbacks); + const updated = await routineStore.getRoutine(id); + automationLiveRuns.complete(liveRun.runId, result); + res.json({ routine: updated, result, liveRunId: liveRun.runId }); + } catch (err) { + automationLiveRuns.fail(liveRun.runId, err instanceof Error ? err.message : String(err)); + throw err; + } + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + throw notFound("Routine not found"); + } + rethrowAsApiError(err); + } + }); + + // GET /routines/:id/run/stream — stream live manual routine output. + router.get( + "/routines/:id/run/stream", + makeRunStreamHandler({ + resolveStore: resolveRoutineStore, + getEntity: (store, id) => store.getRoutine(id), + notFoundMessage: "Routine not found", + }), + ); + + // GET /routines/:id/runs — get execution history + router.get("/routines/:id/runs", async (req: Request, res: Response) => { + const scope = parseScopeParam(req); + const routineStore = resolveRoutineStore(req, scope); + + try { + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const routine = await routineStore.getRoutine(id); + + // Scope isolation: if scope is specified, verify the routine belongs to that scope + if (scope && routine.scope !== scope) { + throw notFound("Routine not found"); + } + + res.json(routine.runHistory); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + throw notFound("Routine not found"); + } + rethrowAsApiError(err); + } + }); + + // POST /routines/:id/webhook — incoming webhook trigger + // Note: Webhook routes do NOT use scope params from the request - webhooks are triggered + // externally and the routine's own scope determines which store to use. + // The webhook URL should include the scope implicitly via the routine ID. + router.post("/routines/:id/webhook", async (req: Request, res: Response) => { + // Webhook triggers don't accept scope params from the request + // The routine's scope field determines which store to use + const routineStore = resolveRoutineStore(req, undefined); + const routineRunner = resolveRoutineRunner(req, undefined); + + try { + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const routine = await routineStore.getRoutine(id); + + // Validate this is a webhook-type routine + if (!isWebhookTrigger(routine.trigger)) { + throw badRequest("Routine is not configured for webhook triggers"); + } + + // Validate routine is enabled + if (!routine.enabled) { + throw badRequest("Routine is disabled"); + } + + // Get raw body for HMAC verification + const rawBody = req.rawBody; + const signatureHeader = req.headers["x-hub-signature-256"] as string | undefined; + + // A webhook routine without a secret is treated as a misconfiguration + // and refused. New routines require a secret at create time (see POST + // /routines), but legacy routines persisted before that validation was + // added could still reach this branch without one. + if (!routine.trigger.secret) { + throw new ApiError( + 401, + "Webhook trigger is not configured with a secret; set routine.trigger.secret before use", + ); + } + if (!rawBody) { + throw badRequest("Raw body not available for signature verification"); + } + if (!signatureHeader) { + throw new ApiError(401, "Missing signature header"); + } + const verification = verifyWebhookSignature(rawBody, signatureHeader, routine.trigger.secret); + if (!verification.valid) { + throw new ApiError(401, verification.error ?? "Invalid signature"); + } + + // Execute via RoutineRunner (persistence handled by RoutineRunner.completeRoutineExecution) + const payload = req.body; + const result = await routineRunner.triggerWebhook(id, payload, signatureHeader); + const updated = await routineStore.getRoutine(id); + res.json({ routine: updated, result }); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + throw notFound("Routine not found"); + } + rethrowAsApiError(err); + } + }); + + + // ── Plugin Routes ───────────────────────────────────────────────────────── + // Plugin management endpoints with projectId scoping support. + // Uses getScopedStore(req) pattern for multi-project support. + // Requires pluginStore in options. + + /** + * GET /api/plugins + * List all installed plugins. + * Query: { projectId?: string, enabled?: boolean } + */ + router.get("/plugins", async (req: Request, res: Response) => { + const { store: scopedStore } = await getProjectContext(req); + const pluginStore = scopedStore.getPluginStore(); + + const filter: { enabled?: boolean } = {}; + if (req.query.enabled !== undefined) { + filter.enabled = req.query.enabled === "true"; + } + + const plugins = await pluginStore.listPlugins(filter); + res.json(plugins); + }); + + /** + * GET /api/plugins/ui-slots + * Get all UI slot definitions from active plugins. + * Returns aggregated array of { pluginId, slot } objects. + */ + router.get("/plugins/ui-slots", async (_req: Request, res: Response) => { + const slots = options?.pluginLoader?.getPluginUiSlots() ?? []; + const normalizedSlots = slots + .map((entry) => ({ + pluginId: entry.pluginId, + slot: { + ...entry.slot, + surface: entry.slot.surface ?? (typeof entry.slot.slotId === "string" ? entry.slot.slotId : undefined), + order: entry.slot.order ?? null, + }, + })) + .sort((a, b) => { + const orderA = typeof a.slot.order === "number" ? a.slot.order : Number.MAX_SAFE_INTEGER; + const orderB = typeof b.slot.order === "number" ? b.slot.order : Number.MAX_SAFE_INTEGER; + if (orderA !== orderB) return orderA - orderB; + if (a.pluginId !== b.pluginId) return a.pluginId.localeCompare(b.pluginId); + return String(a.slot.slotId).localeCompare(String(b.slot.slotId)); + }); + res.json(normalizedSlots); + }); + + /** + * GET /api/plugins/ui-contributions + * Get all structured UI contributions from active plugins. + */ + router.get("/plugins/ui-contributions", async (_req: Request, res: Response) => { + const contributions = options?.pluginLoader?.getPluginUiContributions() ?? []; + const normalizedContributions = contributions + .map((entry) => ({ + pluginId: entry.pluginId, + contribution: { + ...entry.contribution, + order: entry.contribution.order ?? null, + }, + })) + .sort((a, b) => { + const orderA = typeof a.contribution.order === "number" ? a.contribution.order : Number.MAX_SAFE_INTEGER; + const orderB = typeof b.contribution.order === "number" ? b.contribution.order : Number.MAX_SAFE_INTEGER; + if (orderA !== orderB) return orderA - orderB; + if (a.pluginId !== b.pluginId) return a.pluginId.localeCompare(b.pluginId); + return a.contribution.contributionId.localeCompare(b.contribution.contributionId); + }); + res.json(normalizedContributions); + }); + + + /** + * GET /api/plugins/dashboard-views + * Get all plugin top-level dashboard view definitions from active plugins. + * Returns aggregated array of { pluginId, view } objects. + */ + router.get("/plugins/dashboard-views", async (_req: Request, res: Response) => { + const views = await options?.pluginLoader?.getPluginDashboardViews() ?? []; + res.json(views); + }); + + /** + * GET /api/plugins/runtimes + * Get all plugin runtime metadata from active plugins. + * Returns aggregated array of { pluginId, runtimeId, name, description, version }. + */ + router.get("/plugins/runtimes", async (_req: Request, res: Response) => { + const runtimes = options?.pluginLoader?.getPluginRuntimes() ?? []; + const installed = runtimes.map(({ pluginId, runtime }) => ({ + pluginId, + runtimeId: runtime.metadata.runtimeId, + name: runtime.metadata.name, + description: runtime.metadata.description, + version: runtime.metadata.version, + })); + const installedRuntimeIds = new Set(installed.map((r) => r.runtimeId)); + const bundledFallback = BUNDLED_PLUGIN_RUNTIMES.filter( + (r) => !installedRuntimeIds.has(r.runtimeId), + ); + res.json([...installed, ...bundledFallback]); + }); + + /** + * GET /api/plugins/:id + * Get a single plugin by ID. + * Query: { projectId?: string } + */ + router.get("/plugins/:id", async (req: Request, res: Response, next: NextFunction) => { + // "registry" is a static sub-route (GET /plugins/registry) owned by the + // plugin sub-router mounted further below. Because this generic ":id" route + // is registered first, Express would otherwise match it for the literal + // path "/plugins/registry" (id === "registry") and throw + // 'Plugin "registry" not found', shadowing the real registry handler. + // Fall through so the mounted sub-router can serve the registry listing. + if (req.params.id === "registry") { + next(); + return; + } + const { store: scopedStore } = await getProjectContext(req); + const pluginStore = scopedStore.getPluginStore(); + const id = req.params.id as string; + + try { + const plugin = await pluginStore.getPlugin(id); + res.json(plugin); + } catch (err: unknown) { + if (err instanceof Error && (err instanceof Error ? err.message : String(err)).includes("not found")) { + throw notFound(`Plugin "${id}" not found`); + } + throw internalError(err instanceof Error ? err.message : "Unknown error"); + } + }); + + /** + * GET /api/plugins/:id/settings + * Get plugin settings by plugin ID. + * Query: { projectId?: string } + */ + router.get("/plugins/:id/settings", async (req: Request, res: Response) => { + const { store: scopedStore } = await getProjectContext(req); + const pluginStore = scopedStore.getPluginStore(); + const id = req.params.id as string; + + try { + const plugin = await pluginStore.getPlugin(id); + res.json(plugin.settings); + } catch (err: unknown) { + const isNotFoundError = err instanceof Error && (err instanceof Error ? err.message : String(err)).includes("not found"); + const isBundledFallback = BUNDLED_PLUGIN_RUNTIMES.some((r) => r.pluginId === id); + if (isNotFoundError && isBundledFallback) { + // Bundled runtime plugins can be surfaced in settings before they've + // been lazily installed. Return empty defaults so cards can open. + res.json({}); + return; + } + if (isNotFoundError) { + throw notFound(`Plugin "${id}" not found`); + } + throw internalError(err instanceof Error ? err.message : "Unknown error"); + } + }); + + /** + * POST /api/plugins + * Create or register a plugin. + * Requires `mode` discriminator in body: + * - mode: "register" → body must include { id, name, version, path }, optional { enabled, settings, projectId } + * - mode: "install" → body must include { path }, optional { projectId } + * Returns 201 on success, 400 for validation errors, 409 for conflicts. + */ + router.post("/plugins", async (req: Request, res: Response) => { + const { store: scopedStore } = await getProjectContext(req); + const pluginStore = scopedStore.getPluginStore(); + + if (!req.body || typeof req.body !== "object") { + throw badRequest("Request body is required"); + } + + const body = req.body as Record; + + // Validate mode discriminator is present + if (!("mode" in body) || typeof body.mode !== "string") { + throw badRequest("Request body must have a 'mode' field with value 'register' or 'install'"); + } + + const mode = body.mode as string; + + if (mode === "register") { + // Register mode: requires id, name, version, path + if (typeof body.id !== "string" || !body.id.trim()) { + throw badRequest("'id' is required for register mode and must be a non-empty string"); + } + if (typeof body.name !== "string" || !body.name.trim()) { + throw badRequest("'name' is required for register mode and must be a non-empty string"); + } + if (typeof body.version !== "string" || !body.version.trim()) { + throw badRequest("'version' is required for register mode and must be a non-empty string"); + } + if (typeof body.path !== "string" || !body.path.trim()) { + throw badRequest("'path' is required for register mode and must be a non-empty string"); + } + + const manifest: import("@fusion/core").PluginManifest = { + id: body.id as string, + name: body.name as string, + version: body.version as string, + description: typeof body.description === "string" ? body.description : undefined, + author: typeof body.author === "string" ? body.author : undefined, + homepage: typeof body.homepage === "string" ? body.homepage : undefined, + dependencies: Array.isArray(body.dependencies) ? (body.dependencies as string[]) : undefined, + settingsSchema: typeof body.settingsSchema === "object" && body.settingsSchema !== null + ? (body.settingsSchema as Record) + : undefined, + }; + + const settings = typeof body.settings === "object" && body.settings !== null + ? (body.settings as Record) + : undefined; + + // If enabled and loader is available, try to load the plugin + let plugin: import("@fusion/core").PluginInstallation; + try { + plugin = await pluginStore.registerPlugin({ + manifest, + path: body.path as string, + settings, + }); + + if (plugin.enabled && options?.pluginLoader) { + try { + await options.pluginLoader.loadPlugin(plugin.id); + } catch (loadErr) { + // Log but don't fail - plugin is registered, just not loaded + runtimeLogger.child("plugin-routes").error(`Failed to load plugin ${plugin.id}`, { + error: loadErr instanceof Error ? loadErr.message : String(loadErr), + }); + } + } + + res.status(201).json(plugin); + } catch (err: unknown) { + if (err instanceof Error && (err instanceof Error ? err.message : String(err)).includes("already registered")) { + throw conflict(err instanceof Error ? err.message : String(err)); + } + throw internalError(err instanceof Error ? err.message : "Failed to register plugin"); + } + } else if (mode === "install") { + // Install mode: requires path, loads manifest from path + // Supports package root and dist-folder selections via resolvePluginManifest + if (typeof body.path !== "string" || !body.path.trim()) { + throw badRequest("'path' is required for install mode and must be a non-empty string"); + } + + // Check if runtime install interface is available + if (!options?.pluginLoader) { + throw badRequest("Plugin install mode is not supported: plugin loader not available"); + } + + const aiScanOnLoad = body.aiScanOnLoad; + if (aiScanOnLoad !== undefined && typeof aiScanOnLoad !== "boolean") { + throw badRequest("'aiScanOnLoad' must be a boolean when provided"); + } + + const requestPath = (body.path as string).trim(); + const absoluteRequestPath = isAbsolute(requestPath) ? requestPath : resolve(process.cwd(), requestPath); + + let manifestPathForInstall = absoluteRequestPath; + let manifestResolutionError: ApiError | null = null; + let attemptedBundledLookup = false; + + try { + await resolvePluginManifest(manifestPathForInstall); + } catch (err) { + if (err instanceof ApiError && err.statusCode === 404) { + manifestResolutionError = err; + const bundledPluginId = extractBundledPluginId(requestPath) ?? extractBundledPluginId(absoluteRequestPath); + if (bundledPluginId) { + attemptedBundledLookup = true; + const bundledPath = resolveBundledPluginDirInDashboard(bundledPluginId); + if (bundledPath) { + manifestPathForInstall = bundledPath; + } + } + } else { + throw err; + } + } + + if (manifestResolutionError && manifestPathForInstall === absoluteRequestPath) { + if (attemptedBundledLookup) { + throw notFound( + `Plugin install path not found: ${requestPath}. ` + + "Checked resolved local path and bundled plugin locations.", + ); + } + throw manifestResolutionError; + } + + // Resolve manifest — supports package root and dist-folder selections + const { manifestDir, manifest } = await resolvePluginManifest(manifestPathForInstall); + + // Register the loadable entry FILE, not the package directory — Node ESM + // cannot import directories, so the loader rejects directory paths. + const entryPath = resolvePluginEntryPath(manifestDir); + if (!entryPath) { + throw badRequest( + `Plugin at ${manifestDir} has no loadable entry file ` + + "(expected bundled.js, dist/index.js, or src/index.ts)", + ); + } + + try { + const plugin = await pluginStore.registerPlugin({ + manifest, + path: entryPath, + ...(typeof aiScanOnLoad === "boolean" ? { aiScanOnLoad } : {}), + }); + + // If enabled, try to load it. If load fails while aiScanOnLoad=true, + // remove the new registration so install does not leave a broken record. + if (plugin.enabled) { + try { + await options.pluginLoader.loadPlugin(plugin.id); + } catch (loadErr) { + if (plugin.aiScanOnLoad) { + await pluginStore.unregisterPlugin(plugin.id); + throw badRequest(loadErr instanceof Error ? loadErr.message : String(loadErr)); + } + runtimeLogger.child("plugin-routes").error(`Failed to load plugin ${plugin.id}`, { + error: loadErr instanceof Error ? loadErr.message : String(loadErr), + }); + } + } + + res.status(201).json(plugin); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + if (err instanceof Error && (err instanceof Error ? err.message : String(err)).includes("already registered")) { + throw conflict(err instanceof Error ? err.message : String(err)); + } + throw internalError(err instanceof Error ? err.message : "Failed to register plugin"); + } + } else { + throw badRequest(`Invalid mode: '${mode}'. Must be 'register' or 'install'`); + } + }); + + /** + * POST /api/plugins/:id/enable + * Enable a plugin and start it. + * Body: { projectId?: string } + */ + router.post("/plugins/:id/enable", async (req: Request, res: Response) => { + const { store: scopedStore } = await getProjectContext(req); + const pluginStore = scopedStore.getPluginStore(); + const id = req.params.id as string; + + let plugin = await pluginStore.enablePlugin(id); + + // Heal legacy registrations that stored the package directory instead of + // a loadable entry file (Node ESM cannot import directories). Mirrors the + // CLI's startup heal in ensureBundledPluginInstalled. + try { + if (nodeFs.statSync(plugin.path).isDirectory()) { + const entryPath = resolvePluginEntryPath(plugin.path); + if (entryPath) { + plugin = await pluginStore.updatePlugin(id, { path: entryPath }); + } + } + } catch { + // Path missing or unreadable — let loadPlugin surface the real error. + } + + // Start the plugin if loader is available + if (options?.pluginLoader) { + try { + await options.pluginLoader.loadPlugin(id); + } catch (loadErr) { + // Update state to error + await pluginStore.updatePluginState( + id, + "error", + loadErr instanceof Error ? loadErr.message : String(loadErr), + ); + plugin = await pluginStore.getPlugin(id); + } + } + + res.json(plugin); + }); + + /** + * POST /api/plugins/:id/disable + * Disable a plugin and stop it. + * Body: { projectId?: string } + */ + router.post("/plugins/:id/disable", async (req: Request, res: Response) => { + const { store: scopedStore } = await getProjectContext(req); + const pluginStore = scopedStore.getPluginStore(); + const id = req.params.id as string; + + // Stop the plugin if loader is available + if (options?.pluginLoader) { + try { + await options.pluginLoader.stopPlugin(id); + } catch { + // Ignore errors from stopping - plugin might not be loaded + } + } + + const plugin = await pluginStore.disablePlugin(id); + res.json(plugin); + }); + + /** + * POST /api/plugins/:id/reload + * Reload a running plugin with updated code. + * Body: { projectId?: string } + */ + router.post("/plugins/:id/reload", async (req: Request, res: Response) => { + const { store: scopedStore } = await getProjectContext(req); + const pluginStore = scopedStore.getPluginStore(); + const id = req.params.id as string; + + let plugin: import("@fusion/core").PluginInstallation; + try { + plugin = await pluginStore.getPlugin(id); + } catch (err: unknown) { + if (err instanceof Error && (err instanceof Error ? err.message : String(err)).includes("not found")) { + throw notFound(`Plugin "${id}" not found`); + } + throw internalError(err instanceof Error ? err.message : "Unknown error"); + } + + if (plugin.state !== "started") { + throw badRequest("Plugin is not currently loaded. Use enable instead."); + } + + if (!options?.pluginRunner?.reloadPlugin) { + throw internalError("Plugin runner not available"); + } + + try { + await options.pluginRunner.reloadPlugin(id); + } catch (reloadErr: unknown) { + throw internalError(`Reload failed: ${reloadErr instanceof Error ? reloadErr.message : String(reloadErr)}`); + } + + const updatedPlugin = await pluginStore.getPlugin(id); + res.json(updatedPlugin); + }); + + /** + * PATCH /api/plugins/:id + * Update plugin config. + * Body: { aiScanOnLoad: boolean } + */ + router.patch("/plugins/:id", async (req: Request, res: Response) => { + const { store: scopedStore } = await getProjectContext(req); + const pluginStore = scopedStore.getPluginStore(); + const id = req.params.id as string; + + if (!req.body || typeof req.body !== "object" || typeof (req.body as { aiScanOnLoad?: unknown }).aiScanOnLoad !== "boolean") { + throw badRequest("Request body must be { aiScanOnLoad: boolean }"); + } + + try { + const plugin = await pluginStore.updatePlugin(id, { + aiScanOnLoad: (req.body as { aiScanOnLoad: boolean }).aiScanOnLoad, + }); + res.json(plugin); + } catch (err: unknown) { + if (err instanceof Error && err.message.includes("not found")) { + throw notFound(`Plugin "${id}" not found`); + } + throw internalError(err instanceof Error ? err.message : "Failed to update plugin"); + } + }); + + /** + * POST /api/plugins/:id/rescan + * Trigger a fresh plugin scan/load gate via reload or load flow. + */ + router.post("/plugins/:id/rescan", async (req: Request, res: Response) => { + const { store: scopedStore } = await getProjectContext(req); + const pluginStore = scopedStore.getPluginStore(); + const id = req.params.id as string; + + let plugin: import("@fusion/core").PluginInstallation; + try { + plugin = await pluginStore.getPlugin(id); + } catch { + throw notFound(`Plugin "${id}" not found`); + } + + if (!options?.pluginLoader) { + throw internalError("Plugin loader not available"); + } + + try { + if (plugin.state === "started" && options.pluginRunner?.reloadPlugin) { + await options.pluginRunner.reloadPlugin(id); + } else if (plugin.enabled) { + await options.pluginLoader.loadPlugin(id); + } + } catch (reloadErr) { + runtimeLogger.child("plugin-routes").error(`Failed to rescan plugin ${id}`, { + error: reloadErr instanceof Error ? reloadErr.message : String(reloadErr), + }); + } + + res.json(await pluginStore.getPlugin(id)); + }); + + /** + * GET /api/plugins/:id/setup-status + * Check plugin setup status. + */ + router.get("/plugins/:id/setup-status", async (req: Request, res: Response) => { + const { store: scopedStore } = await getProjectContext(req); + const pluginStore = scopedStore.getPluginStore(); + const id = req.params.id as string; + + let plugin: import("@fusion/core").PluginInstallation; + try { + plugin = await pluginStore.getPlugin(id); + } catch (err: unknown) { + if (err instanceof Error && err.message.includes("not found")) { + throw notFound(`Plugin "${id}" not found`); + } + throw internalError(err instanceof Error ? err.message : "Unknown error"); + } + + if (!options?.pluginRunner?.checkPluginSetup || !options?.pluginRunner?.getPluginSetupInfo) { + throw internalError("Plugin runner not available"); + } + + const setupInfo = options.pluginRunner.getPluginSetupInfo(); + const hasSetup = setupInfo.some((entry) => entry.pluginId === id); + + if (!hasSetup) { + res.json({ hasSetup: false }); + return; + } + + if (plugin.state !== "started") { + res.json({ + hasSetup: true, + setupCheckDeferred: true, + deferredReason: "plugin-not-started", + pluginState: plugin.state, + }); + return; + } + + const status = await options.pluginRunner.checkPluginSetup(id); + res.json({ hasSetup: true, ...status }); + }); + + /** + * POST /api/plugins/:id/setup/install + * Trigger plugin setup install hook. + */ + router.post("/plugins/:id/setup/install", async (req: Request, res: Response) => { + const { store: scopedStore } = await getProjectContext(req); + const pluginStore = scopedStore.getPluginStore(); + const id = req.params.id as string; + + const plugin = await pluginStore.getPlugin(id); + if (!plugin.enabled) { + throw badRequest("Plugin must be enabled before setup install"); + } + + if (!options?.pluginRunner?.installPluginSetup || !options?.pluginRunner?.getPluginSetupInfo) { + throw internalError("Plugin runner not available"); + } + + const setupInfo = options.pluginRunner.getPluginSetupInfo(); + const setup = setupInfo.find((entry) => entry.pluginId === id); + if (!setup?.hooks.install) { + throw badRequest("Plugin has no install hook"); + } + + const result = await options.pluginRunner.installPluginSetup(id); + res.json(result ?? { success: true }); + }); + + /** + * POST /api/plugins/:id/setup/uninstall + * Trigger plugin setup uninstall hook. + */ + router.post("/plugins/:id/setup/uninstall", async (req: Request, res: Response) => { + const { store: scopedStore } = await getProjectContext(req); + const pluginStore = scopedStore.getPluginStore(); + const id = req.params.id as string; + + await pluginStore.getPlugin(id); + + if (!options?.pluginRunner?.uninstallPluginSetup || !options?.pluginRunner?.getPluginSetupInfo) { + throw internalError("Plugin runner not available"); + } + + const setupInfo = options.pluginRunner.getPluginSetupInfo(); + const setup = setupInfo.find((entry) => entry.pluginId === id); + if (!setup) { + res.json({ success: true }); + return; + } + + const result = await options.pluginRunner.uninstallPluginSetup(id); + res.json(result ?? { success: true }); + }); + + /** + * PUT /api/plugins/:id/settings + * Update plugin settings. + * Body: { settings: Record, projectId?: string } + */ + router.put("/plugins/:id/settings", async (req: Request, res: Response) => { + const { store: scopedStore } = await getProjectContext(req); + const pluginStore = scopedStore.getPluginStore(); + const id = req.params.id as string; + + if (!req.body || typeof req.body !== "object") { + throw badRequest("Request body must be an object with 'settings' field"); + } + + const body = req.body as Record; + const settings = body.settings as Record | undefined; + + if (!settings || typeof settings !== "object") { + throw badRequest("Request body must have a 'settings' object"); + } + + // Auto-install bundled runtime plugins (Hermes/OpenClaw/Paperclip) on + // first save. The Settings UI surfaces these as fallback cards before + // they're actually registered, so the first PUT must lazily install them + // rather than 404. The host (CLI) injects ensureBundledPluginInstalled + // because dashboard doesn't know the on-disk bundle layout. + const isBundledFallback = BUNDLED_PLUGIN_RUNTIMES.some((r) => r.pluginId === id); + if (isBundledFallback && options?.ensureBundledPluginInstalled) { + let alreadyRegistered = true; + try { + await pluginStore.getPlugin(id); + } catch { + alreadyRegistered = false; + } + if (!alreadyRegistered) { + try { + const installOk = await options.ensureBundledPluginInstalled(id); + if (!installOk) { + throw internalError( + `Bundled plugin "${id}" is unavailable in this build and could not be auto-installed`, + ); + } + } catch (installErr) { + if (installErr instanceof ApiError) { + throw installErr; + } + throw internalError( + `Failed to auto-install bundled plugin "${id}": ${installErr instanceof Error ? installErr.message : String(installErr)}`, + ); + } + } + } + + try { + const plugin = await pluginStore.updatePluginSettings(id, settings); + res.json(plugin); + } catch (err: unknown) { + if (err instanceof Error && (err instanceof Error ? err.message : String(err)).includes("not found")) { + throw notFound(`Plugin "${id}" not found`); + } + if (err instanceof Error && (err instanceof Error ? err.message : String(err)).includes("validation failed")) { + throw badRequest(err instanceof Error ? err.message : String(err)); + } + throw internalError(err instanceof Error ? err.message : "Failed to update settings"); + } + }); + + /** + * DELETE /api/plugins/:id + * Uninstall a plugin. + * Query: { projectId?: string } + */ + router.delete("/plugins/:id", async (req: Request, res: Response) => { + const { store: scopedStore } = await getProjectContext(req); + const pluginStore = scopedStore.getPluginStore(); + const id = req.params.id as string; + + // Stop the plugin if loader is available + if (options?.pluginLoader) { + try { + await options.pluginLoader.stopPlugin(id); + } catch { + // Ignore - plugin might not be loaded + } + } + + await pluginStore.unregisterPlugin(id); + res.status(204).send(); + }); + -export function registerPluginsAutomationRoutes(_ctx: ApiRoutesContext): void { - // Step scaffold: route extraction lands in subsequent steps. } diff --git a/scripts/lib/routes-modular-baseline.json b/scripts/lib/routes-modular-baseline.json index c6935c997e..38a4d21c1e 100644 --- a/scripts/lib/routes-modular-baseline.json +++ b/scripts/lib/routes-modular-baseline.json @@ -1,3 +1,3 @@ { - "inlineRouteRegistrations": 79 + "inlineRouteRegistrations": 42 } diff --git a/scripts/line-count-baseline.json b/scripts/line-count-baseline.json index 486f534067..383079c416 100644 --- a/scripts/line-count-baseline.json +++ b/scripts/line-count-baseline.json @@ -73,7 +73,7 @@ "packages/dashboard/src/github.ts": 4984, "packages/dashboard/src/mission-routes.ts": 3909, "packages/dashboard/src/planning.ts": 3322, - "packages/dashboard/src/routes.ts": 5629, + "packages/dashboard/src/routes.ts": 3272, "packages/dashboard/src/routes/register-git-github.ts": 6342, "packages/dashboard/src/routes/register-settings-memory-routes.ts": 2403, "packages/dashboard/src/routes/register-task-workflow-routes.ts": 5530,