FN-8403: extract automation and plugin route registrars

Move automation and plugin HTTP handlers out of the routes aggregator into focused domain modules.

- Extract live automation streaming and step-execution helpers
- Register automation, routine, webhook, and plugin handlers through the domain registrar
- Document plugin route ordering and update modular-route baselines

Files changed:
 packages/dashboard/src/routes.ts                   | 2779 ++------------------
 packages/dashboard/src/routes/README.md            |    3 +-
 .../dashboard/src/routes/automation-live-run.ts    |  322 +++
 .../src/routes/automation-step-execution.ts        |  445 ++++
 .../src/routes/plugin-bundled-runtimes.ts          |   88 +
 .../src/routes/register-plugins-automation.ts      | 1540 ++++++++++-
 scripts/lib/routes-modular-baseline.json           |    2 +-
 scripts/line-count-baseline.json                   |    2 +-
 8 files changed, 2604 insertions(+), 2577 deletions(-)

Fusion-Task-Id: FN-8403

Fusion-Task-Lineage: 2072d39b-6335-4163-a56a-7d418edc9095

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-19 17:22:59 -07:00
parent c37fb90724
commit b07f207b00
8 changed files with 2401 additions and 2374 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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<string, unknown>) => void;
onText?: (delta: string) => void;
onToolStart?: (name: string, args?: Record<string, unknown>) => 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<string, AutomationLiveRunRecord>();
private readonly latestRunBySchedule = new Map<string, string>();
private readonly scheduleStartListeners = new Map<string, Set<(run: AutomationLiveRunRecord) => 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<TStore, TEntity extends { id: string; scope?: ScopeValue }>(config: {
resolveStore: (req: Request, scope: ScopeValue | undefined) => TStore;
getEntity: (store: TStore, id: string) => Promise<TEntity>;
notFoundMessage: string;
}): (req: Request, res: Response) => Promise<void> {
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);
}
};
}
}

View File

@@ -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<string, unknown>;
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<import("@fusion/core").AutomationRunResult> {
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<import("@fusion/core").AutomationStepResult> {
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<never>((_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<import("@fusion/core").AutomationStepResult> {
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<import("@fusion/core").AutomationRunResult> {
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,
};
}

View File

@@ -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;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,3 @@
{
"inlineRouteRegistrations": 79
"inlineRouteRegistrations": 42
}

View File

@@ -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,