diff --git a/.changeset/fn-7038-automation-tools-live-output.md b/.changeset/fn-7038-automation-tools-live-output.md new file mode 100644 index 0000000000..63f1eb3de7 --- /dev/null +++ b/.changeset/fn-7038-automation-tools-live-output.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Automation AI steps now run with all tools by default, with a per-step tool selector and live run output. +category: feature +dev: Adds AutomationStep.allowedTools + AUTOMATION_SELECTABLE_TOOLS (core); toolsAllowlist on createFnAgent (engine); SSE GET /automations/:id/run/stream and /routines/:id/run/stream (dashboard). diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index c6bce1edf2..9843a89de3 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -71,6 +71,15 @@ Content views such as Artifacts, Research, Insights, Skills, Memory, Evals, Goal On mobile viewports, the Right Dock never renders. The compact Header actions and bottom `MobileNavBar` keep their existing mobile behavior even when the experiment is enabled. +## Automations + + + + +Open **Automations** from the left sidebar (or the mobile More surfaces) to create cron, webhook, API, or manual routines. AI Prompt steps now run with all selectable coding tools by default: **Read**, **Bash**, **Edit**, **Write**, **Grep**, **Find**, and **Ls**. In the routine editor, use **Allowed tools** on a simple AI Prompt action or any multi-step AI Prompt step to clear or re-select tools. Leaving every tool selected stores the legacy default, so existing schedules continue to run with full tool access; clearing every box is an explicit no-tools configuration. + +When you choose **Run now**, the routine card opens a **Live output** panel while the manual run is active. The panel appends step status, AI text deltas, and tool start/finish activity as the run executes, then the card falls back to the persisted final run output and run history once the server records the result. The same `RoutineCard` surface is used by the floating modal and embedded Automations view, so live output appears in both presentations and collapses into a single-column card layout on mobile. + ## Deep Links Use deep links to open a specific task directly from notifications, chat, or external tools. diff --git a/packages/core/src/__tests__/automation.test.ts b/packages/core/src/__tests__/automation.test.ts index 55e01202c3..aaa70f3432 100644 --- a/packages/core/src/__tests__/automation.test.ts +++ b/packages/core/src/__tests__/automation.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { CronExpressionParser } from "cron-parser"; import { AUTOMATION_PRESETS, + AUTOMATION_SELECTABLE_TOOLS, MAX_RUN_HISTORY, type AutomationRunResult, type AutomationStep, @@ -115,6 +116,16 @@ describe("ScheduleType", () => { }); }); +describe("AUTOMATION_SELECTABLE_TOOLS", () => { + it("lists the builtin coding tools offered to automation AI steps", () => { + expect(AUTOMATION_SELECTABLE_TOOLS).toEqual(["Read", "Bash", "Edit", "Write", "Grep", "Find", "Ls"]); + }); + + it("contains unique tool names", () => { + expect(new Set(AUTOMATION_SELECTABLE_TOOLS).size).toBe(AUTOMATION_SELECTABLE_TOOLS.length); + }); +}); + describe("MAX_RUN_HISTORY", () => { it("is set to 50", () => { expect(MAX_RUN_HISTORY).toBe(50); @@ -166,7 +177,7 @@ describe("Interface contracts with AutomationStore", () => { }); }); - it("supports AutomationStep ai-prompt shape", () => { + it("supports AutomationStep ai-prompt shape with optional allowedTools", () => { const aiPromptStep: AutomationStep = { id: "step-ai-1", type: "ai-prompt", @@ -174,6 +185,7 @@ describe("Interface contracts with AutomationStore", () => { prompt: "Summarize the latest run output", modelProvider: "anthropic", modelId: "claude-sonnet-4-5", + allowedTools: ["Read", "Grep"], }; expect(aiPromptStep).toMatchObject({ @@ -183,9 +195,22 @@ describe("Interface contracts with AutomationStore", () => { prompt: "Summarize the latest run output", modelProvider: "anthropic", modelId: "claude-sonnet-4-5", + allowedTools: ["Read", "Grep"], }); }); + it("supports explicit empty allowedTools on AutomationStep ai-prompt shape", () => { + const aiPromptStep: AutomationStep = { + id: "step-ai-empty-tools", + type: "ai-prompt", + name: "No tools", + prompt: "Summarize without tools", + allowedTools: [], + }; + + expect(aiPromptStep.allowedTools).toEqual([]); + }); + it("supports successful AutomationRunResult shape", () => { const runResult: AutomationRunResult = { success: true, diff --git a/packages/core/src/automation.ts b/packages/core/src/automation.ts index 6a4b01fd04..7f4548e76a 100644 --- a/packages/core/src/automation.ts +++ b/packages/core/src/automation.ts @@ -17,6 +17,17 @@ export const AUTOMATION_PRESETS: Record, string> // ── Automation Step Types ──────────────────────────────────────────── +/** + * Builtin tool names that automation AI-prompt steps can expose in the dashboard selector. + * + * FNXC:AutomationTools 2026-06-26-00:00: + * Automation AI steps default to every selectable coding tool for backward-compatible legacy schedules. Persist an explicit allowlist only when the operator narrows the set; an empty allowlist intentionally means no tools. + */ +export const AUTOMATION_SELECTABLE_TOOLS = ["Read", "Bash", "Edit", "Write", "Grep", "Find", "Ls"] as const; + +/** Selectable automation AI tool name. */ +export type AutomationSelectableTool = (typeof AUTOMATION_SELECTABLE_TOOLS)[number]; + /** The type of an automation step. */ export type AutomationStepType = "command" | "ai-prompt" | "create-task"; @@ -36,6 +47,13 @@ export interface AutomationStep { modelProvider?: string; /** AI model ID (for ai-prompt steps). */ modelId?: string; + /** + * Optional tool allowlist for ai-prompt steps. + * + * FNXC:Automations 2026-06-26-00:00: + * Undefined means the agent receives all automation coding tools by default. A provided array restricts the agent to those tool names, and an empty array deliberately runs the prompt with no tools. + */ + allowedTools?: string[]; /** Task title for the created task (for create-task steps). */ taskTitle?: string; /** Task description for the created task (for create-task steps). */ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5db487efa3..3d31839d60 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -876,8 +876,8 @@ export { resolveTaskGithubTracking, } from "./github-tracking.js"; export type { RepoSlug, ResolvedTaskGithubTracking } from "./github-tracking.js"; -export { AUTOMATION_PRESETS, MAX_RUN_HISTORY } from "./automation.js"; -export type { ScheduleType, ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, AutomationStepType, AutomationStep, AutomationStepResult } from "./automation.js"; +export { AUTOMATION_PRESETS, AUTOMATION_SELECTABLE_TOOLS, MAX_RUN_HISTORY } from "./automation.js"; +export type { ScheduleType, ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, AutomationStepType, AutomationStep, AutomationStepResult, AutomationSelectableTool } from "./automation.js"; export { AutomationStore } from "./automation-store.js"; export type { AutomationStoreEvents } from "./automation-store.js"; export { runCommandAsync } from "./run-command.js"; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index caf3a17537..45edadf7a5 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -6195,6 +6195,12 @@ export interface AgentHeartbeatEvent { /** What triggered a heartbeat run */ export type HeartbeatInvocationSource = "on_demand" | "timer" | "assignment" | "automation" | "routine"; +/* +FNXC:AutomationTools 2026-06-26-00:00: +Dashboard source-checkout builds alias @fusion/core to this frontend-safe module, so mirror the automation AI-step tool catalog here as a runtime export for UI selectors. +*/ +export const AUTOMATION_SELECTABLE_TOOLS = ["Read", "Bash", "Edit", "Write", "Grep", "Find", "Ls"] as const; + /** Snapshot of the last blocked state for a task, used for dedup comparison. */ export interface BlockedStateSnapshot { /** The task ID that was blocked */ diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 0c98e0ef6d..8a90a41c99 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -5142,6 +5142,21 @@ export function reorderAutomationSteps(id: string, stepIds: string[], options?: export interface RoutineRunResponse { routine: Routine; result: RoutineExecutionResult; + liveRunId?: string; +} + +export type RoutineRunStreamEvent = + | { type: "run"; runId?: string; scheduleId?: string; status?: string } + | { type: "step"; runId?: string; stepIndex?: number; stepId?: string; stepName?: string; stepType?: string; status?: string; success?: boolean; error?: string } + | { type: "output"; runId?: string; text?: string } + | { type: "tool"; runId?: string; status?: string; name?: string; args?: unknown; isError?: boolean; result?: unknown } + | { type: "complete"; runId?: string; result?: RoutineExecutionResult } + | { type: "error"; runId?: string; message?: string; result?: RoutineExecutionResult }; + +export interface RoutineRunStreamHandlers { + onEvent: (event: RoutineRunStreamEvent) => void; + onConnectionStateChange?: (state: StreamConnectionState) => void; + onFatalError?: (message: string) => void; } export function fetchRoutines(options?: SchedulingScopeOptions): Promise { @@ -5180,6 +5195,39 @@ export function runRoutine(id: string, options?: SchedulingScopeOptions): Promis }); } +export function streamRoutineRun(id: string, handlers: RoutineRunStreamHandlers, options?: SchedulingScopeOptions & { runId?: string }) { + const baseUrl = withSchedulingScope(`/routines/${id}/run/stream`, options); + const separator = baseUrl.includes("?") ? "&" : "?"; + const url = options?.runId ? `${baseUrl}${separator}runId=${encodeURIComponent(options.runId)}` : baseUrl; + const parse = (type: RoutineRunStreamEvent["type"], event: MessageEvent) => { + let data: Record = {}; + try { + data = event.data ? JSON.parse(event.data) : {}; + } catch { + data = { message: event.data }; + } + handlers.onEvent({ type, ...data } as RoutineRunStreamEvent); + }; + return createResilientEventSource( + url, + { + events: { + run: (event) => parse("run", event), + step: (event) => parse("step", event), + output: (event) => parse("output", event), + tool: (event) => parse("tool", event), + complete: (event) => parse("complete", event), + error: (event) => parse("error", event), + }, + }, + { + maxReconnectAttempts: 2, + onConnectionStateChange: handlers.onConnectionStateChange, + onFatalError: handlers.onFatalError, + }, + ); +} + export function fetchRoutineRuns(id: string, options?: SchedulingScopeOptions): Promise { return api(withSchedulingScope(`/routines/${id}/runs`, options)); } diff --git a/packages/dashboard/app/components/RoutineCard.tsx b/packages/dashboard/app/components/RoutineCard.tsx index ba3c0c0060..1a5fd0caae 100644 --- a/packages/dashboard/app/components/RoutineCard.tsx +++ b/packages/dashboard/app/components/RoutineCard.tsx @@ -98,6 +98,8 @@ interface RoutineCardProps { running?: boolean; /** Latest manual-run output shown inline until refreshed routine data catches up. */ lastRunOutput?: { output: string; error?: string; success: boolean } | null; + /** Incremental live run transcript while a manual run is in progress. */ + liveRunOutput?: { output: string; status: "idle" | "running" | "complete" | "error" } | null; } function RunResultBadge({ result }: { result: RoutineExecutionResult }) { @@ -158,7 +160,7 @@ function RunHistoryItem({ result, index }: { result: RoutineExecutionResult; ind ); } -export function RoutineCard({ routine, onEdit, onDelete, onRun, onToggle, running, lastRunOutput }: RoutineCardProps) { +export function RoutineCard({ routine, onEdit, onDelete, onRun, onToggle, running, lastRunOutput, liveRunOutput }: RoutineCardProps) { const { t } = useTranslation("app"); const [showHistory, setShowHistory] = useState(false); const { confirm } = useConfirm(); @@ -301,6 +303,18 @@ export function RoutineCard({ routine, onEdit, onDelete, onRun, onToggle, runnin + {liveRunOutput && ( +
+
+ + {liveRunOutput.status === "running" ? t("schedule.liveOutputRunning", "Live output — running") : t("schedule.liveOutput", "Live output")} +
+ {liveRunOutput.output && ( +
{liveRunOutput.output}
+ )} +
+ )} + {lastRunOutput && (
{lastRunOutput.output && ( diff --git a/packages/dashboard/app/components/ScheduleForm.tsx b/packages/dashboard/app/components/ScheduleForm.tsx index a6f1a919a2..aac9a61b92 100644 --- a/packages/dashboard/app/components/ScheduleForm.tsx +++ b/packages/dashboard/app/components/ScheduleForm.tsx @@ -2,6 +2,7 @@ import { useState, useCallback, useEffect } from "react"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { Globe, Folder } from "lucide-react"; +import { AUTOMATION_SELECTABLE_TOOLS } from "@fusion/core"; import type { ScheduledTask, ScheduledTaskCreateInput, ScheduleType, AutomationStep } from "@fusion/core"; import { ScheduleStepsEditor } from "./ScheduleStepsEditor"; import { CustomModelDropdown } from "./CustomModelDropdown"; @@ -61,6 +62,16 @@ function generateStepId(): string { return `step-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; } +const ALL_AUTOMATION_TOOLS = [...AUTOMATION_SELECTABLE_TOOLS]; + +function normalizeAllowedTools(selectedTools: string[]): string[] | undefined { + return selectedTools.length === ALL_AUTOMATION_TOOLS.length ? undefined : selectedTools; +} + +function resolveAllowedToolSelection(step?: AutomationStep): string[] { + return step?.allowedTools === undefined ? ALL_AUTOMATION_TOOLS : step.allowedTools; +} + type ScheduleMode = "simple" | "advanced"; type SimpleType = "command" | "ai-prompt" | "create-task"; @@ -146,6 +157,16 @@ export function ScheduleForm({ schedule, onSubmit, onCancel, scope: formScope, p } return ""; }); + /* + FNXC:AutomationTools 2026-06-26-00:00: + Automation AI prompts default to every selectable coding tool for legacy schedules. Persist undefined for all-selected, but preserve an explicit empty array as the operator's no-tools choice. + */ + const [simpleAllowedTools, setSimpleAllowedTools] = useState(() => { + if (schedule?.steps && schedule.steps.length === 1 && schedule.steps[0].type === "ai-prompt" && !schedule.command) { + return resolveAllowedToolSelection(schedule.steps[0]); + } + return ALL_AUTOMATION_TOOLS; + }); // Create-task fields const [taskTitle, setTaskTitle] = useState(() => { if (schedule?.steps && schedule.steps.length === 1 && schedule.steps[0].type === "create-task" && !schedule.command) { @@ -343,6 +364,7 @@ export function ScheduleForm({ schedule, onSubmit, onCancel, scope: formScope, p prompt: prompt.trim(), modelProvider: modelProvider.trim() || undefined, modelId: modelId.trim() || undefined, + allowedTools: normalizeAllowedTools(simpleAllowedTools), }; submitData = { name: name.trim(), @@ -398,7 +420,7 @@ export function ScheduleForm({ schedule, onSubmit, onCancel, scope: formScope, p setSubmitting(false); } }, - [validate, onSubmit, name, description, scheduleType, cronExpression, command, prompt, modelProvider, modelId, enabled, timeoutMs, mode, simpleType, steps, localScope, projectId, schedule?.scope, taskTitle, taskDescription, taskColumn], + [validate, onSubmit, name, description, scheduleType, cronExpression, command, prompt, modelProvider, modelId, simpleAllowedTools, enabled, timeoutMs, mode, simpleType, steps, localScope, projectId, schedule?.scope, taskTitle, taskDescription, taskColumn], ); const cronFieldId = "schedule-cron"; @@ -649,6 +671,35 @@ export function ScheduleForm({ schedule, onSubmit, onCancel, scope: formScope, p {t("schedule.modelHelp", "AI model for this prompt. Uses default if not selected.")} )}
+ +
+ {t("schedule.allowedToolsLabel", "Allowed tools")} + {t("schedule.allowedToolsHint", "AI prompt steps use all tools by default. Clear tools only when this automation should run without tool access.")} +
+ + +
+
+ {ALL_AUTOMATION_TOOLS.map((tool) => ( + + ))} +
+
) : ( <> diff --git a/packages/dashboard/app/components/ScheduleStepsEditor.tsx b/packages/dashboard/app/components/ScheduleStepsEditor.tsx index 4d00b3d29c..7d42cb82e5 100644 --- a/packages/dashboard/app/components/ScheduleStepsEditor.tsx +++ b/packages/dashboard/app/components/ScheduleStepsEditor.tsx @@ -1,6 +1,7 @@ import { useState, useCallback, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Plus, Trash2, ChevronUp, ChevronDown, Pencil, GripVertical } from "lucide-react"; +import { AUTOMATION_SELECTABLE_TOOLS } from "@fusion/core"; import type { AutomationStep, AutomationStepType } from "@fusion/core"; import { StepTypeBadge } from "./StepTypeBadge"; import { CustomModelDropdown } from "./CustomModelDropdown"; @@ -25,6 +26,16 @@ function generateStepId(): string { return `step-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; } +const ALL_AUTOMATION_TOOLS = [...AUTOMATION_SELECTABLE_TOOLS]; + +function normalizeAllowedTools(selectedTools: string[]): string[] | undefined { + return selectedTools.length === ALL_AUTOMATION_TOOLS.length ? undefined : selectedTools; +} + +function resolveAllowedToolSelection(step?: AutomationStep): string[] { + return step?.allowedTools === undefined ? ALL_AUTOMATION_TOOLS : step.allowedTools; +} + function createEmptyStep(type: AutomationStepType): AutomationStep { if (type === "command") { return { @@ -69,6 +80,11 @@ function StepEditor({ step, onSave, onCancel }: StepEditorProps) { const [prompt, setPrompt] = useState(step.prompt ?? ""); const [modelProvider, setModelProvider] = useState(step.modelProvider ?? ""); const [modelId, setModelId] = useState(step.modelId ?? ""); + /* + FNXC:AutomationTools 2026-06-26-00:00: + Multi-step AI prompts share the simple form's default-all contract: undefined means every selectable coding tool, while [] intentionally removes all tools for the step. + */ + const [allowedTools, setAllowedTools] = useState(() => resolveAllowedToolSelection(step)); const [taskTitle, setTaskTitle] = useState(step.taskTitle ?? ""); const [taskDescription, setTaskDescription] = useState(step.taskDescription ?? ""); const [taskColumn, setTaskColumn] = useState(step.taskColumn ?? "triage"); @@ -158,6 +174,7 @@ function StepEditor({ step, onSave, onCancel }: StepEditorProps) { taskColumn: type === "create-task" ? taskColumn : undefined, modelProvider: (type === "ai-prompt" || type === "create-task") && modelProvider.trim() ? modelProvider.trim() : undefined, modelId: (type === "ai-prompt" || type === "create-task") && modelId.trim() ? modelId.trim() : undefined, + allowedTools: type === "ai-prompt" ? normalizeAllowedTools(allowedTools) : undefined, timeoutMs: timeoutMs || undefined, continueOnFailure, }; @@ -165,6 +182,7 @@ function StepEditor({ step, onSave, onCancel }: StepEditorProps) { // Clear ai-prompt and create-task specific fields when switching to command if (type !== "ai-prompt") { delete baseStep.prompt; + delete baseStep.allowedTools; } if (type !== "create-task") { delete baseStep.taskTitle; @@ -173,7 +191,7 @@ function StepEditor({ step, onSave, onCancel }: StepEditorProps) { } onSave(baseStep as AutomationStep); - }, [validate, onSave, step, name, type, command, prompt, taskTitle, taskDescription, taskColumn, modelProvider, modelId, timeoutMs, continueOnFailure]); + }, [validate, onSave, step, name, type, command, prompt, taskTitle, taskDescription, taskColumn, modelProvider, modelId, allowedTools, timeoutMs, continueOnFailure]); return (
@@ -247,6 +265,35 @@ function StepEditor({ step, onSave, onCancel }: StepEditorProps) { {modelsError && {modelsError}} {t("schedule.modelHelp", "AI model for this step. Uses default if not selected.")}
+ +
+ {t("schedule.allowedToolsLabel", "Allowed tools")} + {t("schedule.allowedToolsHint", "AI prompt steps use all tools by default. Clear tools only when this automation should run without tool access.")} +
+ + +
+
+ {ALL_AUTOMATION_TOOLS.map((tool) => ( + + ))} +
+
)} diff --git a/packages/dashboard/app/components/ScheduledTasksModal.tsx b/packages/dashboard/app/components/ScheduledTasksModal.tsx index 70252cfb60..6162b52fd3 100644 --- a/packages/dashboard/app/components/ScheduledTasksModal.tsx +++ b/packages/dashboard/app/components/ScheduledTasksModal.tsx @@ -1,7 +1,7 @@ // ScheduledTasksModal renders schedule/routine cards using .scheduling-*, .routine-*, // .schedule-form classes that live in ScriptsModal.css. Both modals share that file. import "./ScriptsModal.css"; -import { useState, useEffect, useCallback, useMemo } from "react"; +import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import { Plus, Zap, Globe, Folder, X } from "lucide-react"; import type { Routine, RoutineCreateInput } from "@fusion/core"; @@ -12,7 +12,9 @@ import { updateRoutine, deleteRoutine, runRoutine, + streamRoutineRun, } from "../api"; +import type { RoutineRunStreamEvent } from "../api"; import { RoutineCard } from "./RoutineCard"; import { RoutineEditor } from "./RoutineEditor"; import type { ToastType } from "../hooks/useToast"; @@ -54,6 +56,8 @@ export function ScheduledTasksModal({ onClose, addToast, projectId, presentation const [editingRoutine, setEditingRoutine] = useState(); const [runningRoutineId, setRunningRoutineId] = useState(null); const [lastRunOutput, setLastRunOutput] = useState>({}); + const [liveRunOutput, setLiveRunOutput] = useState>({}); + const liveRunStreamsRef = useRef void }>>({}); // FNXC:AutomationsEmbedded 2026-06-22-00:00: Two-pane embedded layout tracks the routine selected in the left list to render its detail on the right. const [selectedRoutineId, setSelectedRoutineId] = useState(null); @@ -119,6 +123,56 @@ export function ScheduledTasksModal({ onClose, addToast, projectId, presentation return () => document.removeEventListener("keydown", handleKey); }, [onClose, routineView, escapeEnabled]); + useEffect(() => { + return () => { + for (const stream of Object.values(liveRunStreamsRef.current)) stream.close(); + liveRunStreamsRef.current = {}; + }; + }, []); + + const appendLiveRunLine = useCallback((routineId: string, line: string, status: "running" | "complete" | "error" = "running") => { + setLiveRunOutput((previous) => { + const current = previous[routineId]?.output ?? ""; + return { + ...previous, + [routineId]: { + output: current ? `${current}\n${line}` : line, + status, + }, + }; + }); + }, []); + + /* + FNXC:AutomationLiveOutput 2026-06-26-00:00: + The modal and embedded Automations view both render RoutineCard, so the run handler owns one SSE stream per routine and passes the accumulated live transcript down instead of duplicating stream logic per presentation. + */ + const handleLiveRunEvent = useCallback((routineId: string, event: RoutineRunStreamEvent) => { + if (event.type === "output" && event.text) { + appendLiveRunLine(routineId, event.text); + return; + } + if (event.type === "tool" && event.name) { + appendLiveRunLine(routineId, event.status === "completed" ? `Tool ${event.name} finished${event.isError ? " with errors" : ""}` : `Tool ${event.name} started`); + return; + } + if (event.type === "step" && event.stepName) { + appendLiveRunLine(routineId, event.status === "completed" ? `Step ${Number(event.stepIndex ?? 0) + 1}: ${event.stepName} ${event.success ? "completed" : "failed"}` : `Step ${Number(event.stepIndex ?? 0) + 1}: ${event.stepName} started`); + return; + } + if (event.type === "complete") { + appendLiveRunLine(routineId, t("schedule.liveRunComplete", "Run complete"), "complete"); + liveRunStreamsRef.current[routineId]?.close(); + delete liveRunStreamsRef.current[routineId]; + return; + } + if (event.type === "error") { + appendLiveRunLine(routineId, event.message ?? t("schedule.liveRunError", "Run failed"), "error"); + liveRunStreamsRef.current[routineId]?.close(); + delete liveRunStreamsRef.current[routineId]; + } + }, [appendLiveRunLine, t]); + // ── Routine CRUD handlers ─────────────────────────────────────────────── const handleCreateRoutine = useCallback( @@ -172,6 +226,15 @@ export function ScheduledTasksModal({ onClose, addToast, projectId, presentation const handleRunRoutine = useCallback( async (routine: Routine) => { setRunningRoutineId(routine.id); + setLiveRunOutput((previous) => ({ + ...previous, + [routine.id]: { output: t("schedule.liveRunStarting", "Starting run…"), status: "running" }, + })); + liveRunStreamsRef.current[routine.id]?.close(); + liveRunStreamsRef.current[routine.id] = streamRoutineRun(routine.id, { + onEvent: (event) => handleLiveRunEvent(routine.id, event), + onFatalError: (message) => appendLiveRunLine(routine.id, message, "error"), + }, scopeOptions); try { const { result } = await runRoutine(routine.id, scopeOptions); setLastRunOutput((previous) => ({ @@ -191,10 +254,12 @@ export function ScheduledTasksModal({ onClose, addToast, projectId, presentation } catch (err) { addToast(getErrorMessage(err) || t("schedule.runError", "Failed to run routine"), "error"); } finally { + liveRunStreamsRef.current[routine.id]?.close(); + delete liveRunStreamsRef.current[routine.id]; setRunningRoutineId(null); } }, - [addToast, loadRoutines, scopeOptions, t], + [addToast, appendLiveRunLine, handleLiveRunEvent, loadRoutines, scopeOptions, t], ); const handleToggleRoutine = useCallback( @@ -221,6 +286,7 @@ export function ScheduledTasksModal({ onClose, addToast, projectId, presentation useEffect(() => { if (routineView !== "list") { setLastRunOutput({}); + setLiveRunOutput({}); } }, [routineView]); @@ -295,6 +361,7 @@ export function ScheduledTasksModal({ onClose, addToast, projectId, presentation onToggle={handleToggleRoutine} running={runningRoutineId === r.id} lastRunOutput={lastRunOutput[r.id] ?? null} + liveRunOutput={liveRunOutput[r.id] ?? null} /> ))} @@ -411,6 +478,7 @@ export function ScheduledTasksModal({ onClose, addToast, projectId, presentation onToggle={handleToggleRoutine} running={runningRoutineId === selectedRoutine.id} lastRunOutput={lastRunOutput[selectedRoutine.id] ?? null} + liveRunOutput={liveRunOutput[selectedRoutine.id] ?? null} /> ) : ( diff --git a/packages/dashboard/app/components/ScriptsModal.css b/packages/dashboard/app/components/ScriptsModal.css index 3dee893fcc..52aaf0c317 100644 --- a/packages/dashboard/app/components/ScriptsModal.css +++ b/packages/dashboard/app/components/ScriptsModal.css @@ -814,6 +814,41 @@ The Automations sub-header scope button bar should match the Artifacts tab bar s flex: 1; } +/* +FNXC:AutomationTools 2026-06-26-00:00: +Automation AI steps expose an explicit per-step tool allowlist. Keep the selector compact and token-based so the modal and embedded Automations surfaces share the same responsive layout. +*/ +.automation-tool-selector { + border: solid var(--border); + border-width: thin; + border-radius: var(--radius-sm); + padding: var(--space-md); +} + +.automation-tool-selector legend { + padding: 0 var(--space-xs); + font-weight: 600; + color: var(--text-primary); +} + +.automation-tool-selector__actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-sm); + margin-top: var(--space-sm); +} + +.automation-tool-selector__grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr)); + gap: var(--space-sm); + margin-top: var(--space-sm); +} + +.automation-tool-selector__option { + margin: 0; +} + /* Schedule form within modal */ .schedule-form { padding: 0; @@ -859,6 +894,10 @@ The Automations sub-header scope button bar should match the Artifacts tab bar s padding-right: var(--space-lg); } + .automation-tool-selector__grid { + grid-template-columns: 1fr; + } + .scheduling-toolbar { flex-wrap: wrap; gap: var(--space-sm); @@ -1046,6 +1085,46 @@ The Automations sub-header scope button bar should match the Artifacts tab bar s overflow: hidden; } +/* +FNXC:AutomationLiveOutput 2026-06-26-00:00: +Live routine output must reuse the existing run-output typography and status-dot convention while the manual run is active, then give way to the persisted final result after refresh. +*/ +.routine-live-output { + margin-top: var(--space-sm); + border: solid var(--border); + border-width: thin; + border-radius: var(--radius-sm); + background: var(--bg-secondary); + overflow: hidden; +} + +.routine-live-output.running { + border-color: var(--color-warning); +} + +.routine-live-output.error { + border-color: var(--color-error); +} + +.routine-live-output-header { + display: flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-sm) var(--space-md); + color: var(--text-muted); + font-size: var(--font-size-sm); + border-bottom: solid var(--border); + border-bottom-width: thin; +} + +.routine-live-output.running .status-dot { + background: var(--color-warning); +} + +.routine-live-output.error .status-dot { + background: var(--color-error); +} + .routine-run-output.success { border-left: 3px solid var(--color-success); background: color-mix(in srgb, var(--color-success) 5%, transparent); diff --git a/packages/dashboard/app/components/__tests__/RoutineCard.test.tsx b/packages/dashboard/app/components/__tests__/RoutineCard.test.tsx index de7b97f71f..a5158eefe6 100644 --- a/packages/dashboard/app/components/__tests__/RoutineCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/RoutineCard.test.tsx @@ -178,6 +178,51 @@ describe("RoutineCard", () => { expect(screen.getByText("hello")).toBeDefined(); }); + it("renders live run output while running", () => { + render( + , + ); + expect(screen.getByText("Live output — running")).toBeDefined(); + expect(screen.getByText("incremental line")).toBeDefined(); + }); + + it("renders completed live run output without the running label", () => { + render( + , + ); + expect(screen.getByText("Live output")).toBeDefined(); + expect(screen.queryByText("Live output — running")).toBeNull(); + expect(screen.getByText("done line")).toBeDefined(); + }); + + it("does not render a live-output panel when liveRunOutput is null", () => { + render( + , + ); + expect(screen.queryByText("Live output")).toBeNull(); + }); + it("renders inline run error when lastRunOutput fails", () => { render( ({ ListPlus: () => ➕, })); -// Mock @fusion/core to provide type-only exports (no runtime values needed) -vi.mock("@fusion/core", () => ({})); +// Mock @fusion/core to provide the runtime tool catalog used by ScheduleForm. +vi.mock("@fusion/core", () => ({ + AUTOMATION_SELECTABLE_TOOLS: ["Read", "Bash", "Edit", "Write", "Grep", "Find", "Ls"], +})); // Mock api const mockFetchModels = vi.fn().mockResolvedValue({ @@ -182,6 +184,69 @@ describe("ScheduleForm", () => { }); }); + it("shows all automation tools checked by default in simple AI Prompt mode", () => { + render(); + + fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" })); + + for (const tool of ["Read", "Bash", "Edit", "Write", "Grep", "Find", "Ls"]) { + expect(screen.getByLabelText(tool)).toHaveProperty("checked", true); + } + }); + + it("submits undefined allowedTools when every simple AI Prompt tool is selected", async () => { + render(); + + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "AI Job" } }); + fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" })); + fireEvent.change(screen.getByLabelText("Prompt"), { target: { value: "Summarize recent commits" } }); + fireEvent.click(screen.getByText("Create Schedule")); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + steps: [expect.objectContaining({ allowedTools: undefined })], + }), + ); + }); + }); + + it("submits a restricted allowedTools array from simple AI Prompt mode", async () => { + render(); + + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "AI Job" } }); + fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" })); + fireEvent.change(screen.getByLabelText("Prompt"), { target: { value: "Summarize recent commits" } }); + fireEvent.click(screen.getByLabelText("Bash")); + fireEvent.click(screen.getByText("Create Schedule")); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + steps: [expect.objectContaining({ allowedTools: ["Read", "Edit", "Write", "Grep", "Find", "Ls"] })], + }), + ); + }); + }); + + it("submits an explicit empty allowedTools array when simple AI Prompt tools are cleared", async () => { + render(); + + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "AI Job" } }); + fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" })); + fireEvent.change(screen.getByLabelText("Prompt"), { target: { value: "Summarize recent commits" } }); + fireEvent.click(screen.getByRole("button", { name: "Clear" })); + fireEvent.click(screen.getByText("Create Schedule")); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + steps: [expect.objectContaining({ allowedTools: [] })], + }), + ); + }); + }); + it("submits with model provider and model ID when provided in simple AI Prompt mode", async () => { render(); @@ -272,6 +337,37 @@ describe("ScheduleForm", () => { // Model dropdown should be present expect(screen.getByTestId("model-dropdown")).toBeDefined(); }); + + it("restores restricted tool selection when editing a simple AI Prompt schedule", async () => { + const schedule = makeSchedule({ + steps: [ + { + id: "step-1", + type: "ai-prompt", + name: "AI Schedule", + prompt: "Summarize this", + allowedTools: ["Read", "Grep"], + }, + ], + command: "", + }); + + render(); + + expect(screen.getByLabelText("Read")).toHaveProperty("checked", true); + expect(screen.getByLabelText("Grep")).toHaveProperty("checked", true); + expect(screen.getByLabelText("Bash")).toHaveProperty("checked", false); + + fireEvent.click(screen.getByText("Save Changes")); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + steps: [expect.objectContaining({ allowedTools: ["Read", "Grep"] })], + }), + ); + }); + }); }); describe("edit mode", () => { diff --git a/packages/dashboard/app/components/__tests__/ScheduleStepsEditor.test.tsx b/packages/dashboard/app/components/__tests__/ScheduleStepsEditor.test.tsx index d82c9d107c..e03a4e0d2d 100644 --- a/packages/dashboard/app/components/__tests__/ScheduleStepsEditor.test.tsx +++ b/packages/dashboard/app/components/__tests__/ScheduleStepsEditor.test.tsx @@ -7,8 +7,10 @@ import type { AutomationStep } from "@fusion/core"; type ScheduleStepsEditorProps = ComponentProps; -// Mock @fusion/core -vi.mock("@fusion/core", () => ({})); +// Mock @fusion/core runtime constants used by the editor. +vi.mock("@fusion/core", () => ({ + AUTOMATION_SELECTABLE_TOOLS: ["Read", "Bash", "Edit", "Write", "Grep", "Find", "Ls"], +})); // Mock lucide-react vi.mock("lucide-react", () => ({ @@ -342,6 +344,67 @@ describe("ScheduleStepsEditor", () => { }); }); + describe("tool selection", () => { + it("shows all tools checked by default for advanced AI prompt steps", () => { + const steps = [makeStep({ id: "s1", name: "AI Step", type: "ai-prompt", prompt: "Test prompt" })]; + render(); + + fireEvent.click(screen.getByLabelText("Edit AI Step")); + + for (const tool of ["Read", "Bash", "Edit", "Write", "Grep", "Find", "Ls"]) { + expect(screen.getByLabelText(tool)).toHaveProperty("checked", true); + } + }); + + it("saves a restricted allowedTools array for advanced AI prompt steps", () => { + const steps = [makeStep({ id: "s1", name: "AI Step", type: "ai-prompt", prompt: "Test prompt" })]; + render(); + + fireEvent.click(screen.getByLabelText("Edit AI Step")); + fireEvent.click(screen.getByLabelText("Bash")); + fireEvent.click(screen.getByText("Save Step")); + + expect(onChange).toHaveBeenCalledWith([ + expect.objectContaining({ + type: "ai-prompt", + allowedTools: ["Read", "Edit", "Write", "Grep", "Find", "Ls"], + }), + ]); + }); + + it("saves undefined allowedTools when advanced AI prompt tools are all selected", () => { + const steps = [makeStep({ id: "s1", name: "AI Step", type: "ai-prompt", prompt: "Test prompt", allowedTools: ["Read"] })]; + render(); + + fireEvent.click(screen.getByLabelText("Edit AI Step")); + fireEvent.click(screen.getByRole("button", { name: "Select all" })); + fireEvent.click(screen.getByText("Save Step")); + + expect(onChange).toHaveBeenCalledWith([ + expect.objectContaining({ + type: "ai-prompt", + allowedTools: undefined, + }), + ]); + }); + + it("saves an explicit empty allowedTools array when advanced tools are cleared", () => { + const steps = [makeStep({ id: "s1", name: "AI Step", type: "ai-prompt", prompt: "Test prompt" })]; + render(); + + fireEvent.click(screen.getByLabelText("Edit AI Step")); + fireEvent.click(screen.getByRole("button", { name: "Clear" })); + fireEvent.click(screen.getByText("Save Step")); + + expect(onChange).toHaveBeenCalledWith([ + expect.objectContaining({ + type: "ai-prompt", + allowedTools: [], + }), + ]); + }); + }); + describe("model selection", () => { it("shows model dropdown for AI prompt step type", async () => { const steps = [makeStep({ id: "s1", name: "AI Step", type: "ai-prompt", prompt: "Test prompt" })]; diff --git a/packages/dashboard/app/components/__tests__/ScheduledTasksModal.test.tsx b/packages/dashboard/app/components/__tests__/ScheduledTasksModal.test.tsx index 4700b1747f..0733255da2 100644 --- a/packages/dashboard/app/components/__tests__/ScheduledTasksModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/ScheduledTasksModal.test.tsx @@ -27,7 +27,9 @@ vi.mock("lucide-react", () => ({ X: () => Close, })); -vi.mock("@fusion/core", () => ({})); +vi.mock("@fusion/core", () => ({ + AUTOMATION_SELECTABLE_TOOLS: ["Read", "Bash", "Edit", "Write", "Grep", "Find", "Ls"], +})); const mockConfirm = vi.fn(); @@ -46,6 +48,7 @@ const mockCreateRoutine = vi.fn(); const mockUpdateRoutine = vi.fn(); const mockDeleteRoutine = vi.fn(); const mockRunRoutine = vi.fn(); +const mockStreamRoutineRun = vi.fn(); vi.mock("../../api", () => ({ fetchAutomations: (...args: any[]) => mockFetchAutomations(...args), @@ -59,6 +62,7 @@ vi.mock("../../api", () => ({ updateRoutine: (...args: any[]) => mockUpdateRoutine(...args), deleteRoutine: (...args: any[]) => mockDeleteRoutine(...args), runRoutine: (...args: any[]) => mockRunRoutine(...args), + streamRoutineRun: (...args: any[]) => mockStreamRoutineRun(...args), fetchModels: vi.fn().mockResolvedValue({ models: [ { provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 }, @@ -124,6 +128,7 @@ describe("ScheduledTasksModal", () => { mockConfirm.mockResolvedValue(true); mockFetchAutomations.mockResolvedValue([]); mockFetchRoutines.mockResolvedValue([]); + mockStreamRoutineRun.mockReturnValue({ close: vi.fn() }); localStorage.removeItem("floating-window:automation"); localStorage.removeItem("fusion:automation-modal-size"); setViewport(1200, 900); @@ -373,14 +378,25 @@ describe("ScheduledTasksModal", () => { it("runs routines, shows toast, and renders inline output on the card", async () => { const routine = makeRoutine({ name: "My Routine" }); mockFetchRoutines.mockResolvedValue([routine]); - mockRunRoutine.mockResolvedValue({ - result: { - routineId: routine.id, - success: true, - output: "Done", - startedAt: "2026-04-08T00:00:00.000Z", - completedAt: "2026-04-08T00:01:00.000Z", - }, + let streamHandlers: { onEvent: (event: any) => void } | undefined; + mockStreamRoutineRun.mockImplementation((_id, handlers) => { + streamHandlers = handlers; + return { close: vi.fn() }; + }); + mockRunRoutine.mockImplementation(async () => { + streamHandlers?.onEvent({ type: "step", stepIndex: 0, stepName: "Analyze", status: "started" }); + streamHandlers?.onEvent({ type: "output", text: "live line" }); + streamHandlers?.onEvent({ type: "tool", status: "started", name: "Read" }); + streamHandlers?.onEvent({ type: "complete" }); + return { + result: { + routineId: routine.id, + success: true, + output: "Done", + startedAt: "2026-04-08T00:00:00.000Z", + completedAt: "2026-04-08T00:01:00.000Z", + }, + }; }); render(); @@ -391,8 +407,10 @@ describe("ScheduledTasksModal", () => { fireEvent.click(screen.getByLabelText("Run My Routine now")); await waitFor(() => { + expect(mockStreamRoutineRun).toHaveBeenCalledWith("routine-001", expect.any(Object), { scope: "global" }); expect(mockRunRoutine).toHaveBeenCalledWith("routine-001", { scope: "global" }); expect(addToast).toHaveBeenCalledWith('"My Routine" completed successfully', "success"); + expect(screen.getByText(/live line/)).toBeDefined(); expect(screen.getByText("Done")).toBeDefined(); }); }); diff --git a/packages/dashboard/src/__tests__/routes-automation.test.ts b/packages/dashboard/src/__tests__/routes-automation.test.ts index 20ddf6c9a4..6a95eea165 100644 --- a/packages/dashboard/src/__tests__/routes-automation.test.ts +++ b/packages/dashboard/src/__tests__/routes-automation.test.ts @@ -111,13 +111,15 @@ vi.mock("@fusion/core", async (importOriginal) => { vi.mock("@fusion/engine", async () => { const { createEngineMock } = await import("../test/mockCoreEngine.js"); return createEngineMock({ - createFnAgent: vi.fn(async (options?: { onText?: (delta: string) => void }) => ({ + createFnAgent: vi.fn(async (options?: { onText?: (delta: string) => void; onToolStart?: (name: string, args?: Record) => void; onToolEnd?: (name: string, isError: boolean, result?: unknown) => void }) => ({ session: { state: { messages: [] as Array<{ role: string; content: string }>, }, prompt: vi.fn(async function (this: { state?: { messages?: Array<{ role: string; content: string }> } }, message: string) { + options?.onToolStart?.("Read", { path: "README.md" }); options?.onText?.("mock-ai-output"); + options?.onToolEnd?.("Read", false, "read result"); const messages = this.state?.messages ?? []; messages.push({ role: "user", content: message }); messages.push({ @@ -699,6 +701,26 @@ describe("Automation routes", () => { expect(res.body.error).toContain("Invalid schedule type"); }); + it("returns 400 for ai-prompt allowedTools with an unknown tool", async () => { + const { app } = buildApp(); + const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({ + name: "Test", + command: "", + scheduleType: "hourly", + steps: [ + { + id: "step-ai", + type: "ai-prompt", + name: "AI", + prompt: "Summarize", + allowedTools: ["Read", "UnknownTool"], + }, + ], + }), { "Content-Type": "application/json" }); + expect(res.status).toBe(400); + expect(res.body.error).toContain("allowedTools contains unknown tool"); + }); + it("returns 400 for custom type with missing cron", async () => { const { app } = buildApp(); const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({ @@ -789,7 +811,8 @@ describe("Automation routes", () => { ); }); - it("executes ai-prompt steps during manual runs", async () => { + it("executes ai-prompt steps during manual runs with the selected tool allowlist", async () => { + vi.mocked(createFnAgent).mockClear(); const mockStore = createMockAutomationStore(); mockStore.getSchedule.mockResolvedValue({ ...FAKE_SCHEDULE, @@ -800,6 +823,7 @@ describe("Automation routes", () => { type: "ai-prompt", name: "AI analysis", prompt: "Summarize repository status", + allowedTools: ["Read", "Grep"], }, ], }); @@ -824,6 +848,81 @@ describe("Automation routes", () => { ]), }), ); + expect(vi.mocked(createFnAgent)).toHaveBeenCalledWith(expect.objectContaining({ + tools: "coding", + toolsAllowlist: ["Read", "Grep"], + })); + }); + + it("streams buffered live events for a completed manual AI prompt run", async () => { + vi.mocked(createFnAgent).mockClear(); + const mockStore = createMockAutomationStore(); + mockStore.getSchedule.mockResolvedValue({ + ...FAKE_SCHEDULE, + command: "", + steps: [ + { + id: "step-ai", + type: "ai-prompt", + name: "Analyze", + prompt: "Analyze recent activity", + }, + ], + }); + const { app } = buildApp(mockStore); + + const runRes = await REQUEST(app, "POST", "/api/automations/sched-001/run"); + expect(runRes.status).toBe(200); + expect(runRes.body.liveRunId).toBeTruthy(); + expect(runRes.body.result.output).toContain("mock-ai-output"); + + const streamRes = await performRequest(app, "GET", `/api/automations/sched-001/run/stream?runId=${runRes.body.liveRunId}`); + expect(streamRes.status).toBe(200); + const body = String(streamRes.body); + expect(body).toContain("event: step"); + expect(body).toContain("event: output"); + expect(body).toContain("mock-ai-output"); + expect(body).toContain("event: tool"); + expect(body).toContain("Read"); + expect(body).toContain("event: complete"); + expect(runRes.body.result.stepResults).toHaveLength(1); + }); + + it("returns a terminal SSE error for an unknown manual run id", async () => { + const mockStore = createMockAutomationStore(); + mockStore.getSchedule.mockResolvedValue({ ...FAKE_SCHEDULE }); + const { app } = buildApp(mockStore); + + const streamRes = await performRequest(app, "GET", "/api/automations/sched-001/run/stream?runId=missing-run"); + expect(streamRes.status).toBe(200); + expect(String(streamRes.body)).toContain("event: error"); + expect(String(streamRes.body)).toContain("Live run not found or expired"); + }); + + it("defaults manual ai-prompt runs to all tools when allowedTools is omitted", async () => { + vi.mocked(createFnAgent).mockClear(); + const mockStore = createMockAutomationStore(); + mockStore.getSchedule.mockResolvedValue({ + ...FAKE_SCHEDULE, + command: "", + steps: [ + { + id: "step-ai", + type: "ai-prompt", + name: "AI analysis", + prompt: "Summarize repository status", + }, + ], + }); + + const { app } = buildApp(mockStore); + const res = await REQUEST(app, "POST", "/api/automations/sched-001/run"); + + expect(res.status).toBe(200); + expect(vi.mocked(createFnAgent)).toHaveBeenCalledWith(expect.objectContaining({ + tools: "coding", + toolsAllowlist: undefined, + })); }); it("executes create-task steps during manual runs", async () => { @@ -1428,14 +1527,21 @@ describe("Routine routes", () => { function createMockRoutineRunner() { return { - triggerManual: vi.fn().mockResolvedValue({ - routineId: "routine-001", - success: true, - output: "", - triggerType: "cron" as const, - startedAt: new Date().toISOString(), - completedAt: new Date().toISOString(), - } satisfies RoutineExecutionResult), + triggerManual: vi.fn().mockImplementation(async (_id: string, liveCallbacks?: { onStep?: (data: Record) => void; onText?: (delta: string) => void; onToolStart?: (name: string) => void; onToolEnd?: (name: string, isError: boolean, result?: unknown) => void }) => { + liveCallbacks?.onStep?.({ stepIndex: 0, stepId: "step-1", stepName: "Mock step", status: "started" }); + liveCallbacks?.onToolStart?.("Read"); + liveCallbacks?.onText?.("routine-live-output"); + liveCallbacks?.onToolEnd?.("Read", false, "ok"); + liveCallbacks?.onStep?.({ stepIndex: 0, stepId: "step-1", stepName: "Mock step", status: "completed", success: true }); + return { + routineId: "routine-001", + success: true, + output: "routine-live-output", + triggerType: "cron" as const, + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + } satisfies RoutineExecutionResult; + }), triggerWebhook: vi.fn().mockResolvedValue({ routineId: "routine-001", success: true, @@ -1720,11 +1826,30 @@ describe("Routine routes", () => { expect(res.body.result).toBeDefined(); expect(res.body.result.triggerType).toBe("cron"); // Verify triggerManual was called (persistence handled by RoutineRunner) - expect(routineRunner.triggerManual).toHaveBeenCalledWith("routine-001"); + expect(routineRunner.triggerManual).toHaveBeenCalledWith("routine-001", expect.any(Object)); // Verify recordRun was NOT called (double-persist fix) expect(mockStore.recordRun).not.toHaveBeenCalled(); }); + it("streams buffered live events for completed routine manual runs", async () => { + const mockStore = createMockRoutineStore(); + const { app } = buildRoutineApp(mockStore); + + const runRes = await REQUEST(app, "POST", "/api/routines/routine-001/run"); + expect(runRes.status).toBe(200); + expect(runRes.body.liveRunId).toBeTruthy(); + + const streamRes = await performRequest(app, "GET", `/api/routines/routine-001/run/stream?runId=${runRes.body.liveRunId}`); + expect(streamRes.status).toBe(200); + const body = String(streamRes.body); + expect(body).toContain("event: step"); + expect(body).toContain("event: output"); + expect(body).toContain("routine-live-output"); + expect(body).toContain("event: tool"); + expect(body).toContain("event: complete"); + expect(runRes.body.result.output).toBe("routine-live-output"); + }); + it("returns 404 for missing routine", async () => { const mockStore = createMockRoutineStore(); mockStore.getRoutine.mockRejectedValue(Object.assign(new Error("not found"), { code: "ENOENT" })); @@ -1773,7 +1898,7 @@ describe("Routine routes", () => { expect(res.status).toBe(200); expect(res.body.routine).toBeDefined(); expect(res.body.result).toBeDefined(); - expect(routineRunner.triggerManual).toHaveBeenCalledWith("routine-001"); + expect(routineRunner.triggerManual).toHaveBeenCalledWith("routine-001", expect.any(Object)); }); it("returns 404 for missing routine (ENOENT)", async () => { @@ -2203,7 +2328,7 @@ describe("Routine routes", () => { expect(res.status).toBe(200); expect(res.body.routine).toBeDefined(); expect(res.body.result).toBeDefined(); - expect(routineRunner.triggerManual).toHaveBeenCalledWith("routine-001"); + expect(routineRunner.triggerManual).toHaveBeenCalledWith("routine-001", expect.any(Object)); }); it("POST /routines/:id/trigger with scope mismatch returns 404", async () => { diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index a7ee52c44e..5a09413f1d 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -19,6 +19,7 @@ import { type PiExtensionEntry, type PiExtensionSettings, AutomationStore, + AUTOMATION_SELECTABLE_TOOLS, MemoryBackendError, RoutineStore, discoverPiExtensions, @@ -43,7 +44,7 @@ import { getSession as getPlanningSession, cleanupSession as cleanupPlanningSess import { getSubtaskSession, cleanupSubtaskSession } from "./subtask-breakdown.js"; import { getMissionInterviewSession, cleanupMissionInterviewSession } from "./mission-interview.js"; import { getTargetInterviewSession, cleanupTargetInterviewSession } from "./milestone-slice-interview.js"; -import { writeSSEEvent } from "./sse-buffer.js"; +import { SessionEventBuffer, writeSSEEvent } from "./sse-buffer.js"; import { ApiError, badRequest, @@ -2383,6 +2384,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout 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; @@ -2393,21 +2395,105 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout 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); + result = await executeScheduleSteps(schedule, startedAt, scopedStore, liveCallbacks); } else { // Legacy single-command execution + liveCallbacks.onStep?.({ stepIndex: 0, stepId: "command", stepName: schedule.name, stepType: "command", status: "started" }); result = await executeSingleCommand(schedule.command, schedule.timeoutMs, startedAt); + 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); - res.json({ schedule: updated, 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); + } + }); + + // GET /automations/:id/run/stream — stream live manual-run output. + router.get("/automations/:id/run/stream", async (req: Request, res: Response) => { + const scope = parseScopeParam(req); + const automationStore = resolveAutomationStore(req, scope); + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + + try { + const schedule = await automationStore.getSchedule(id); + if (scope && schedule.scope !== scope) { + throw notFound("Schedule not found"); + } + + 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(); + } + }); + }; + + const existingRun = automationLiveRuns.get(requestedRunId, schedule.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(schedule.id, (run) => { + unsubscribeStart?.(); + attachRun(run); + }); + } + + req.on("close", () => { + unsubscribeRun?.(); + unsubscribeStart?.(); + }); } catch (err: unknown) { if (err instanceof ApiError) { throw err; @@ -2766,10 +2852,18 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout throw badRequest("Routine is disabled"); } - // Execute via RoutineRunner (persistence handled by RoutineRunner.completeRoutineExecution) - const result = await routineRunner.triggerManual(id); - const updated = await routineStore.getRoutine(id); - res.json({ routine: updated, result }); + 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; @@ -2802,10 +2896,92 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout throw badRequest("Routine is disabled"); } - // Execute via RoutineRunner (persistence handled by RoutineRunner.completeRoutineExecution) - const result = await routineRunner.triggerManual(id); - const updated = await routineStore.getRoutine(id); - res.json({ routine: updated, result }); + 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", async (req: Request, res: Response) => { + const scope = parseScopeParam(req); + const routineStore = resolveRoutineStore(req, scope); + const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + + try { + const routine = await routineStore.getRoutine(id); + if (scope && routine.scope !== scope) { + throw notFound("Routine not found"); + } + + 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(); + } + }); + }; + + const existingRun = automationLiveRuns.get(requestedRunId, routine.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(routine.id, (run) => { + unsubscribeStart?.(); + attachRun(run); + }); + } + + req.on("close", () => { + unsubscribeRun?.(); + unsubscribeStart?.(); + }); } catch (err: unknown) { if (err instanceof ApiError) { throw err; @@ -5092,6 +5268,17 @@ function validateAutomationSteps(steps: unknown[]): string | null { 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()) { @@ -5111,9 +5298,168 @@ function validateAutomationSteps(steps: unknown[]): string | 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; +}; + +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>>(); + + start(scheduleId: string, runId = createAutomationRunId()): AutomationLiveRunRecord { + const run: AutomationLiveRunRecord = { + runId, + scheduleId, + status: "running", + buffer: new SessionEventBuffer(AUTOMATION_LIVE_EVENT_CAPACITY), + listeners: new Set(), + output: "", + }; + 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; + } + + 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 have read-only access to the project files.", + "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"); @@ -5130,6 +5476,21 @@ function truncateAutomationOutput(stdout: string, stderr: string): string { 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). */ @@ -5180,6 +5541,7 @@ async function executeAiPromptStep( timeoutMs: number, startedAt: string, taskStore: TaskStore, + liveCallbacks?: AutomationLiveRunCallbacks, ): Promise { if (!step.prompt?.trim()) { return { @@ -5220,12 +5582,16 @@ async function executeAiPromptStep( const { session } = await createFnAgent({ cwd: process.cwd(), systemPrompt: MANUAL_RUN_AI_SYSTEM_PROMPT, - tools: "readonly", + tools: "coding", + toolsAllowlist: step.allowedTools, defaultProvider: modelProvider, defaultModelId: modelId, onText: (delta: string) => { responseText += delta; + liveCallbacks?.onText?.(delta); }, + onToolStart: liveCallbacks?.onToolStart, + onToolEnd: liveCallbacks?.onToolEnd, }); try { @@ -5327,6 +5693,7 @@ 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[] = []; @@ -5339,6 +5706,7 @@ async function executeScheduleSteps( 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); @@ -5353,7 +5721,7 @@ async function executeScheduleSteps( completedAt: cmdResult.completedAt, }; } else if (step.type === "ai-prompt") { - stepResult = await executeAiPromptStep(step, timeoutMs, stepStartedAt, taskStore); + stepResult = await executeAiPromptStep(step, timeoutMs, stepStartedAt, taskStore, liveCallbacks); stepResult.stepIndex = i; } else if (step.type === "create-task") { stepResult = await executeCreateTaskStep(step, stepStartedAt, taskStore); @@ -5372,6 +5740,10 @@ async function executeScheduleSteps( } 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; diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts index 91ff671f46..5a931558cf 100644 --- a/packages/dashboard/src/server.ts +++ b/packages/dashboard/src/server.ts @@ -258,7 +258,12 @@ export interface ServerOptions { routineStore?: RoutineStore; /** Optional RoutineRunner for triggering routine execution via heartbeat */ routineRunner?: { - triggerManual(routineId: string): Promise; + triggerManual(routineId: string, liveCallbacks?: { + onStep?: (data: Record) => void; + onText?: (delta: string) => void; + onToolStart?: (name: string, args?: Record) => void; + onToolEnd?: (name: string, isError: boolean, result?: unknown) => void; + }): Promise; triggerWebhook(routineId: string, payload: Record, signature?: string): Promise; }; /** Optional AiSessionStore — if not provided, one is created from the default store's database */ diff --git a/packages/engine/src/__tests__/cron-runner.test.ts b/packages/engine/src/__tests__/cron-runner.test.ts index cf87725ac5..edaf36a982 100644 --- a/packages/engine/src/__tests__/cron-runner.test.ts +++ b/packages/engine/src/__tests__/cron-runner.test.ts @@ -183,6 +183,34 @@ describe("CronRunner", () => { }); }); + it("requests coding tools and forwards the allowed tool list", async () => { + let capturedOptions: any; + piModuleMocks.createFnAgent.mockImplementation(async (options: any) => { + capturedOptions = options; + return { session: { dispose: vi.fn() } }; + }); + + const executor = await createAiPromptExecutor("/test/project"); + await executor("Summarize this", "anthropic", "claude-sonnet-4-5", ["Read", "Grep"]); + + expect(capturedOptions.tools).toBe("coding"); + expect(capturedOptions.toolsAllowlist).toEqual(["Read", "Grep"]); + }); + + it("leaves toolsAllowlist undefined when the step omits allowedTools", async () => { + let capturedOptions: any; + piModuleMocks.createFnAgent.mockImplementation(async (options: any) => { + capturedOptions = options; + return { session: { dispose: vi.fn() } }; + }); + + const executor = await createAiPromptExecutor("/test/project"); + await executor("Summarize this"); + + expect(capturedOptions.tools).toBe("coding"); + expect(capturedOptions.toolsAllowlist).toBeUndefined(); + }); + it("returns response text even when session disposal throws", async () => { piModuleMocks.createFnAgent.mockImplementation(async (options: { onText?: (delta: string) => void }) => { options.onText?.("hello "); @@ -834,6 +862,7 @@ describe("CronRunner", () => { "Analyze the codebase", "anthropic", "claude-sonnet-4-5", + undefined, ); }); @@ -1004,6 +1033,29 @@ describe("CronRunner", () => { expect(result.stepResults![0].output).toContain("Analysis complete: 3 findings"); }); + it("passes step allowedTools to executor", async () => { + const store = createMockStore(); + const mockExecutor = createAiMockExecutor("response"); + const schedule = createMockSchedule({ + command: "", + steps: [ + makeStep({ + type: "ai-prompt", + name: "Step with tools", + prompt: "Use selected tools", + allowedTools: ["Read", "Grep"], + command: undefined, + }), + ], + }); + const automationStore = createMockAutomationStore([schedule]); + runner = new CronRunner(store, automationStore, { aiPromptExecutor: mockExecutor }); + + await runner.executeSchedule(schedule); + + expect(mockExecutor).toHaveBeenCalledWith("Use selected tools", "anthropic", "claude-sonnet-4-5", ["Read", "Grep"]); + }); + it("passes step model provider and model ID to executor", async () => { const store = createMockStore(); const mockExecutor = createAiMockExecutor("response"); @@ -1025,7 +1077,7 @@ describe("CronRunner", () => { await runner.executeSchedule(schedule); - expect(mockExecutor).toHaveBeenCalledWith("Do something", "openai", "gpt-4o"); + expect(mockExecutor).toHaveBeenCalledWith("Do something", "openai", "gpt-4o", undefined); }); it("falls back to settings defaults when step has no model", async () => { @@ -1050,7 +1102,7 @@ describe("CronRunner", () => { await runner.executeSchedule(schedule); - expect(mockExecutor).toHaveBeenCalledWith("Use defaults", "anthropic", "claude-sonnet-4-5"); + expect(mockExecutor).toHaveBeenCalledWith("Use defaults", "anthropic", "claude-sonnet-4-5", undefined); }); it("returns configuration error when no executor is provided", async () => { diff --git a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts index acb7d55530..225eb1e0d1 100644 --- a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts +++ b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts @@ -1603,6 +1603,50 @@ describe("createFnAgent", () => { ])); }); + it("filters coding tools with a case-insensitive toolsAllowlist", async () => { + const { createFnAgent } = await import("../pi.js"); + + await createFnAgent({ + cwd: "/tmp", + systemPrompt: "test", + tools: "coding", + toolsAllowlist: [" Read ", "GREP"], + }); + + const createSessionArgs = createAgentSessionMock.mock.calls[0]?.[0] as { customTools: Array<{ name: string }>; tools?: string[] }; + expect(createSessionArgs.customTools.map((tool) => tool.name).sort()).toEqual(["grep", "read"]); + expect(createSessionArgs.tools).toEqual(["GREP", "Read", "grep", "read"]); + }); + + it("keeps all coding tools when toolsAllowlist is undefined", async () => { + const { createFnAgent } = await import("../pi.js"); + + await createFnAgent({ + cwd: "/tmp", + systemPrompt: "test", + tools: "coding", + }); + + const createSessionArgs = createAgentSessionMock.mock.calls[0]?.[0] as { customTools: Array<{ name: string }>; tools?: string[] }; + expect(createSessionArgs.customTools.map((tool) => tool.name).sort()).toEqual(["bash", "edit", "find", "grep", "ls", "read", "write"]); + expect(createSessionArgs.tools).toBeUndefined(); + }); + + it("exposes no coding tools when toolsAllowlist is empty", async () => { + const { createFnAgent } = await import("../pi.js"); + + await createFnAgent({ + cwd: "/tmp", + systemPrompt: "test", + tools: "coding", + toolsAllowlist: [], + }); + + const createSessionArgs = createAgentSessionMock.mock.calls[0]?.[0] as { customTools: Array<{ name: string }>; tools?: string[] }; + expect(createSessionArgs.customTools).toEqual([]); + expect(createSessionArgs.tools).toEqual([]); + }); + it("keeps caller customTools in coding sessions", async () => { createCodingToolsMock.mockReturnValueOnce([{ name: "read" }, { name: "write" }] as any); const customTool = { diff --git a/packages/engine/src/__tests__/routine-runner.test.ts b/packages/engine/src/__tests__/routine-runner.test.ts index 7e838954d5..8a58eaa33a 100644 --- a/packages/engine/src/__tests__/routine-runner.test.ts +++ b/packages/engine/src/__tests__/routine-runner.test.ts @@ -138,6 +138,7 @@ function createMockHeartbeatMonitor(): HeartbeatMonitor { function createRoutineRunner(options?: Partial): RoutineRunner { return new RoutineRunner({ + ...options, routineStore: options?.routineStore ?? createMockRoutineStore(), heartbeatMonitor: options?.heartbeatMonitor ?? createMockHeartbeatMonitor(), rootDir: options?.rootDir ?? "/test/root", @@ -266,6 +267,33 @@ describe("RoutineRunner", () => { expect(runner.isRoutineRunning("routine-cleanup")).toBe(false); }); + it("forwards ai-prompt allowedTools and live callbacks to the AI executor", async () => { + const routine = createMockRoutine({ + id: "routine-ai", + agentId: undefined, + steps: [ + { + id: "step-ai", + type: "ai-prompt", + name: "Analyze", + prompt: "Analyze this", + allowedTools: ["Read", "Grep"], + }, + ], + }); + const routineStore = createMockRoutineStore([routine]); + const aiPromptExecutor = vi.fn().mockResolvedValue("ai output"); + const liveCallbacks = { onText: vi.fn(), onStep: vi.fn() }; + const runner = createRoutineRunner({ routineStore, aiPromptExecutor }); + + const result = await runner.executeRoutine("routine-ai", "api", undefined, liveCallbacks); + + expect(result.success).toBe(true); + expect(aiPromptExecutor).toHaveBeenCalledWith("Analyze this", undefined, undefined, ["Read", "Grep"], liveCallbacks); + expect(liveCallbacks.onStep).toHaveBeenCalledWith(expect.objectContaining({ stepId: "step-ai", status: "started" })); + expect(liveCallbacks.onStep).toHaveBeenCalledWith(expect.objectContaining({ stepId: "step-ai", status: "completed", success: true })); + }); + it("cleans up inFlightExecutions map even on error", async () => { const routine = createMockRoutine({ id: "routine-error-cleanup", enabled: false }); const routineStore = createMockRoutineStore([routine]); diff --git a/packages/engine/src/cron-runner.ts b/packages/engine/src/cron-runner.ts index 38e223c685..340c6887b5 100644 --- a/packages/engine/src/cron-runner.ts +++ b/packages/engine/src/cron-runner.ts @@ -201,10 +201,18 @@ const MIN_POLL_INTERVAL_MS = 10 * 1000; * Function type for executing AI prompts. * Injected into CronRunner to decouple it from agent session creation. */ +export type AiPromptLiveCallbacks = { + onText?: (delta: string) => void; + onToolStart?: (name: string, args?: Record) => void; + onToolEnd?: (name: string, isError: boolean, result?: unknown) => void; +}; + export type AiPromptExecutor = ( prompt: string, modelProvider?: string, modelId?: string, + allowedTools?: string[], + liveCallbacks?: AiPromptLiveCallbacks, ) => Promise; export interface CronRunnerOptions { @@ -870,7 +878,7 @@ export class CronRunner { try { // Race between executor and timeout - const resultPromise = this.aiPromptExecutor(step.prompt, modelProvider, modelId); + const resultPromise = this.aiPromptExecutor(step.prompt, modelProvider, modelId, step.allowedTools); const timeoutPromise = new Promise((_resolve, reject) => { setTimeout(() => reject(new Error(`AI prompt step timed out after ${timeoutMs / 1000}s`)), timeoutMs); }); @@ -983,7 +991,7 @@ export class CronRunner { const AI_AUTOMATION_SYSTEM_PROMPT = [ "You are an AI automation agent executing a scheduled task.", - "You have read-only access to the project files.", + "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.", "Structure outputs with clear sections: Summary, Findings, Recommended Actions, and Risks/Unknowns when applicable.", @@ -1004,7 +1012,7 @@ const AI_AUTOMATION_SYSTEM_PROMPT = [ export async function createAiPromptExecutor(cwd: string): Promise { const disposeLog = createLogger("cron-runner"); - return async (prompt: string, modelProvider?: string, modelId?: string): Promise => { + return async (prompt: string, modelProvider?: string, modelId?: string, allowedTools?: string[], liveCallbacks?: AiPromptLiveCallbacks): Promise => { let responseText = ""; const skillContext = buildSessionSkillContextSync(null, "executor", cwd, undefined); @@ -1015,13 +1023,17 @@ export async function createAiPromptExecutor(cwd: string): Promise { responseText += delta; + liveCallbacks?.onText?.(delta); }, + onToolStart: liveCallbacks?.onToolStart, + onToolEnd: liveCallbacks?.onToolEnd, }); try { diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index acaa89d8bb..1d71c88b20 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -958,6 +958,13 @@ export interface AgentOptions { systemPromptLayers?: SystemPromptLayers; tools?: "coding" | "readonly"; customTools?: ToolDefinition[]; + /** + * Optional resolved tool-name allowlist. Undefined preserves the selected tool mode; an empty array deliberately exposes no matched tools. + * + * FNXC:AutomationTools 2026-06-26-00:00: + * Automation AI steps can narrow coding sessions by tool name while legacy steps keep all tools. Normalize names case-insensitively at the engine boundary so dashboard labels like "Read" match pi tool names like "read". + */ + toolsAllowlist?: string[]; /** Optional allowlist of builtin runtime web tools to keep enabled. */ builtinToolsAllowlist?: BuiltinWebToolName[]; onText?: (delta: string) => void; @@ -2045,6 +2052,10 @@ export async function createFnAgent(options: AgentOptions): Promise : undefined; const isReadonly = options.tools === "readonly"; + const normalizedToolsAllowlist = options.toolsAllowlist === undefined + ? undefined + : new Set(options.toolsAllowlist.map((name) => name.trim().toLowerCase()).filter(Boolean)); + const isAllowedByToolAllowlist = (toolName: string): boolean => normalizedToolsAllowlist === undefined || normalizedToolsAllowlist.has(toolName.trim().toLowerCase()); const builtins = [ createReadTool(options.cwd), createBashTool(options.cwd, bashToolOptions), @@ -2054,9 +2065,10 @@ export async function createFnAgent(options: AgentOptions): Promise createFindTool(options.cwd), createLsTool(options.cwd), ] as ToolDefinition[]; - const tools = isReadonly + const modeFilteredTools = isReadonly ? builtins.filter((tool) => isReadonlyAllowed(tool.name)) : builtins; + const tools = modeFilteredTools.filter((tool) => isAllowedByToolAllowlist(tool.name)); // Suppress lint about unused presets — kept in scope for incremental migration. void createCodingTools; void createReadOnlyTools; @@ -2201,6 +2213,10 @@ export async function createFnAgent(options: AgentOptions): Promise const readonlyFilteredCustomTools = isReadonly ? filterCustomToolsForReadonly(options.customTools ?? []) : { allowed: options.customTools ?? [], denied: [] }; + const allowlistFilteredCustomTools = { + ...readonlyFilteredCustomTools, + allowed: readonlyFilteredCustomTools.allowed.filter((tool) => isAllowedByToolAllowlist(tool.name)), + }; if (isReadonly && readonlyFilteredCustomTools.denied.length > 0) { piLog.warn( `[pi] readonly mode: dropped ${readonlyFilteredCustomTools.denied.length} denied custom tool(s): ${readonlyFilteredCustomTools.denied.join(", ")}`, @@ -2209,7 +2225,7 @@ export async function createFnAgent(options: AgentOptions): Promise const toolChainStart: ToolDefinition[] = [ ...(tools as ToolDefinition[]), - ...readonlyFilteredCustomTools.allowed, + ...allowlistFilteredCustomTools.allowed, ]; const toolsWithRtkRewrite = wrapToolsWithRtkRewrite(toolChainStart); const toolsWithPermanentGating = wrapToolsWithPermanentAgentGating( @@ -2258,9 +2274,9 @@ export async function createFnAgent(options: AgentOptions): Promise }; if (options.builtinToolsAllowlist && options.builtinToolsAllowlist.length > 0) { - const safeBuiltinAllowlist = isReadonly + const safeBuiltinAllowlist = (isReadonly ? options.builtinToolsAllowlist.filter((name) => READONLY_ALLOWLIST.includes(name as (typeof READONLY_ALLOWLIST)[number])) - : options.builtinToolsAllowlist; + : options.builtinToolsAllowlist).filter(isAllowedByToolAllowlist); createSessionOptions.tools = [ ...new Set([ ...customToolList.map((tool) => tool.name), @@ -2268,6 +2284,14 @@ export async function createFnAgent(options: AgentOptions): Promise ]), ].sort(); } + if (normalizedToolsAllowlist !== undefined) { + createSessionOptions.tools = [ + ...new Set([ + ...customToolList.map((tool) => tool.name), + ...options.toolsAllowlist!.map((name) => name.trim()).filter(Boolean), + ]), + ].sort(); + } return createAgentSession(createSessionOptions); }; diff --git a/packages/engine/src/routine-runner.ts b/packages/engine/src/routine-runner.ts index da4b5ab7b3..e21a3e26e4 100644 --- a/packages/engine/src/routine-runner.ts +++ b/packages/engine/src/routine-runner.ts @@ -21,7 +21,7 @@ import type { TaskStore, } from "@fusion/core"; import type { HeartbeatMonitor } from "./agent-heartbeat.js"; -import type { AiPromptExecutor } from "./cron-runner.js"; +import type { AiPromptExecutor, AiPromptLiveCallbacks } from "./cron-runner.js"; import { createLogger } from "./logger.js"; import { defaultShell } from "./shell-utils.js"; import { resolveSandboxBackend } from "./sandbox/index.js"; @@ -36,6 +36,14 @@ const MAX_OUTPUT_LENGTH = 10 * 1024; /** Options for RoutineRunner constructor */ +/* +FNXC:AutomationLiveOutput 2026-06-26-00:00: +Routine manual triggers share the automation live-output contract. Thread optional callbacks through the runner so routes can stream step boundaries, AI text/tool events, and final output without changing scheduled/background execution behavior. +*/ +export type RoutineLiveRunCallbacks = AiPromptLiveCallbacks & { + onStep?: (data: Record) => void; +}; + export interface RoutineRunnerOptions { /** RoutineStore for querying and updating routines */ routineStore: RoutineStore; @@ -84,6 +92,7 @@ export class RoutineRunner { routineId: string, triggerType: "cron" | "webhook" | "api", context?: Record, + liveCallbacks?: RoutineLiveRunCallbacks, ): Promise { // 1. Load routine let routine: Routine; @@ -131,7 +140,7 @@ export class RoutineRunner { const startedAt = new Date().toISOString(); // Set in-flight BEFORE starting execution to prevent race conditions - const executionPromise = this.runExecution(routine, triggerType, context, startedAt); + const executionPromise = this.runExecution(routine, triggerType, context, startedAt, liveCallbacks); this.inFlightExecutions.set(routineId, executionPromise); try { @@ -155,12 +164,13 @@ export class RoutineRunner { triggerType: string, context: Record | undefined, startedAt: string, + liveCallbacks?: RoutineLiveRunCallbacks, ): Promise { const routineId = routine.id; try { const actionResult = this.hasRoutineAction(routine) - ? await this.executeRoutineAction(routine, startedAt) + ? await this.executeRoutineAction(routine, startedAt, liveCallbacks) : await this.executeAgentRoutine(routine, triggerType, context); await this.options.routineStore.completeRoutineExecution(routineId, { @@ -252,11 +262,16 @@ export class RoutineRunner { private async executeRoutineAction( routine: Routine, startedAt: string, + liveCallbacks?: RoutineLiveRunCallbacks, ): Promise { if (routine.steps && routine.steps.length > 0) { - return this.executeSteps(routine, startedAt); + return this.executeSteps(routine, startedAt, liveCallbacks); } - return this.executeCommand(routine, routine.command ?? "", routine.timeoutMs, startedAt); + liveCallbacks?.onStep?.({ stepIndex: 0, stepId: "command", stepName: routine.name, stepType: "command", status: "started" }); + const result = await this.executeCommand(routine, routine.command ?? "", routine.timeoutMs, startedAt); + liveCallbacks?.onStep?.({ stepIndex: 0, stepId: "command", stepName: routine.name, stepType: "command", status: "completed", success: result.success, error: result.error }); + if (result.output) liveCallbacks?.onText?.(result.output); + return result; } private getRoutineCommandAuditor(routine: Routine): RunAuditor | undefined { @@ -368,7 +383,7 @@ export class RoutineRunner { }; } - private async executeSteps(routine: Routine, startedAt: string): Promise { + private async executeSteps(routine: Routine, startedAt: string, liveCallbacks?: RoutineLiveRunCallbacks): Promise { const steps = routine.steps ?? []; const stepResults: AutomationStepResult[] = []; let overallSuccess = true; @@ -376,7 +391,10 @@ export class RoutineRunner { for (let i = 0; i < steps.length; i++) { const step = steps[i]; - const result = await this.executeStep(routine, step, i); + liveCallbacks?.onStep?.({ stepIndex: i, stepId: step.id, stepName: step.name, stepType: step.type, status: "started" }); + const result = await this.executeStep(routine, step, i, liveCallbacks); + liveCallbacks?.onStep?.({ stepIndex: i, stepId: step.id, stepName: step.name, stepType: step.type, status: "completed", success: result.success, error: result.error }); + if (step.type !== "ai-prompt" && result.output) liveCallbacks?.onText?.(result.output); stepResults.push(result); if (!result.success) { @@ -412,6 +430,7 @@ export class RoutineRunner { routine: Routine, step: AutomationStep, stepIndex: number, + liveCallbacks?: RoutineLiveRunCallbacks, ): Promise { const startedAt = new Date().toISOString(); const timeoutMs = step.timeoutMs ?? routine.timeoutMs ?? DEFAULT_TIMEOUT_MS; @@ -439,7 +458,7 @@ export class RoutineRunner { } try { const output = await Promise.race([ - this.options.aiPromptExecutor(step.prompt, step.modelProvider, step.modelId), + this.options.aiPromptExecutor(step.prompt, step.modelProvider, step.modelId, step.allowedTools, liveCallbacks), new Promise((_resolve, reject) => setTimeout(() => reject(new Error(`AI prompt step timed out after ${timeoutMs / 1000}s`)), timeoutMs)), ]); return { stepId: step.id, stepName: step.name, stepIndex, success: true, output: truncateOutput(output, ""), startedAt, completedAt: new Date().toISOString() }; @@ -560,14 +579,14 @@ export class RoutineRunner { * @returns The execution result * @throws Error if routine not found or disabled */ - async triggerManual(routineId: string): Promise { + async triggerManual(routineId: string, liveCallbacks?: RoutineLiveRunCallbacks): Promise { const routine = await this.options.routineStore.getRoutine(routineId); if (!routine.enabled) { throw new Error(`Routine '${routineId}' is disabled`); } - return this.executeRoutine(routineId, "api"); + return this.executeRoutine(routineId, "api", undefined, liveCallbacks); } /** diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index ea3628877a..9146809915 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -5220,6 +5220,8 @@ "advancedMode": "Multi-Step", "advancedModeHelp": "Run multiple steps sequentially (commands and AI prompts)", "aiPromptType": "AI Prompt", + "allowedToolsHint": "AI prompt steps use all tools by default. Clear tools only when this automation should run without tool access.", + "allowedToolsLabel": "Allowed tools", "andMore_one": "…and {{count}} more", "andMore_other": "…and {{count}} more", "apiEndpointHint": "API endpoint path that triggers this routine", @@ -5265,6 +5267,7 @@ "cronPresetWeekly": "Every week (Monday)", "cronRequired": "Cron expression is required for custom schedules", "crontabGuru": "crontab.guru", + "clearTools": "Clear", "delete": "Delete", "deleteError": "Failed to delete routine", "deleteMessage": "Delete schedule {{name}}? This cannot be undone.", @@ -5320,6 +5323,11 @@ "globalScoped": "This schedule will be created at global scope.", "globalScopeTitle": "Global scope", "lastLabel": "Last:", + "liveOutput": "Live output", + "liveOutputRunning": "Live output — running", + "liveRunComplete": "Run complete", + "liveRunError": "Run failed", + "liveRunStarting": "Starting run…", "loadRoutinesError": "Failed to load routines", "manualTriggerInfo": "This routine will be triggered manually via the dashboard or API.", "modeAriaLabel": "Execution mode", @@ -5386,6 +5394,7 @@ "scopeLabel": "Scope", "scopeLocked": "Scope is locked to {{scope}} for existing schedules", "scopeLockedTitle": "Scope is locked to {{scope}} for existing routines", + "selectAllTools": "Select all", "selectProjectTitle": "Select a project to enable project scope", "simpleMode": "Simple", "simpleModeHelp": "Run a single shell command or AI prompt", diff --git a/packages/i18n/src/resources.d.ts b/packages/i18n/src/resources.d.ts index bc6b73a3e5..3b952c754f 100644 --- a/packages/i18n/src/resources.d.ts +++ b/packages/i18n/src/resources.d.ts @@ -2277,6 +2277,16 @@ export default interface Resources { "failedToLoadFile": "Failed to load file", "failedToSaveFile": "Failed to save file" }, + "engineBanner": { + "body": "The engine for this project is not running, so task automation and live updates may be paused. Start it now to reconnect the dashboard.", + "dashboardOnly": "This dashboard cannot start engines from the current process. Run `fn serve` for this project to enable task execution and live automation.", + "dashboardOnlyPrefix": "This dashboard cannot start engines from the current process. Run", + "dashboardOnlySuffix": "for this project to enable task execution and live automation.", + "error": "Start failed: {{message}}", + "startCta": "Start engine", + "starting": "Starting…", + "title": "Project engine is not connected" + }, "errorBoundary": { "genericError": "Something went wrong", "reloadPage": "Reload page", @@ -2374,6 +2384,8 @@ export default interface Resources { "newFolder": "New Folder", "operationFailed": "Operation failed", "operationSuffix": "ing...", + "previewOnly": "Preview only", + "previewTitle": "Preview for {{file}}", "rename": "Rename", "renamePlaceholder": "New name", "renameTitle": "Rename", @@ -5210,6 +5222,8 @@ export default interface Resources { "advancedMode": "Multi-Step", "advancedModeHelp": "Run multiple steps sequentially (commands and AI prompts)", "aiPromptType": "AI Prompt", + "allowedToolsHint": "AI prompt steps use all tools by default. Clear tools only when this automation should run without tool access.", + "allowedToolsLabel": "Allowed tools", "andMore_one": "…and {{count}} more", "andMore_other": "…and {{count}} more", "apiEndpointHint": "API endpoint path that triggers this routine", @@ -5223,6 +5237,7 @@ export default interface Resources { "catchUpPolicyRunAll": "Run all missed runs", "catchUpPolicyRunOne": "Run the most recent missed run", "catchUpPolicySkip": "Skip missed runs", + "clearTools": "Clear", "columnTodo": "To Do", "columnTriage": "Triage", "command": "Command", @@ -5310,6 +5325,11 @@ export default interface Resources { "globalScopeTitle": "Global scope", "globalScoped": "This schedule will be created at global scope.", "lastLabel": "Last:", + "liveOutput": "Live output", + "liveOutputRunning": "Live output — running", + "liveRunComplete": "Run complete", + "liveRunError": "Run failed", + "liveRunStarting": "Starting run…", "loadRoutinesError": "Failed to load routines", "manualTriggerInfo": "This routine will be triggered manually via the dashboard or API.", "modeAriaLabel": "Execution mode", @@ -5376,6 +5396,7 @@ export default interface Resources { "scopeLabel": "Scope", "scopeLocked": "Scope is locked to {{scope}} for existing schedules", "scopeLockedTitle": "Scope is locked to {{scope}} for existing routines", + "selectAllTools": "Select all", "selectProjectTitle": "Select a project to enable project scope", "simpleMode": "Simple", "simpleModeHelp": "Run a single shell command or AI prompt",