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:
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
322
packages/dashboard/src/routes/automation-live-run.ts
Normal file
322
packages/dashboard/src/routes/automation-live-run.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
445
packages/dashboard/src/routes/automation-step-execution.ts
Normal file
445
packages/dashboard/src/routes/automation-step-execution.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
88
packages/dashboard/src/routes/plugin-bundled-runtimes.ts
Normal file
88
packages/dashboard/src/routes/plugin-bundled-runtimes.ts
Normal 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
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"inlineRouteRegistrations": 79
|
||||
"inlineRouteRegistrations": 42
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user