Add configurable automation tool access with live manual-run streaming for schedules and routines. - Add an automation tool allowlist model and dashboard selectors for AI prompt steps. - Stream manual automation and routine run progress, output, step updates, and tool events over SSE. - Surface live run transcripts in automation/routine cards and document the operator workflow. - Cover validation, runner callbacks, API streaming, and UI behavior with targeted tests. Files changed: .changeset/fn-7038-automation-tools-live-output.md | 7 + docs/dashboard-guide.md | 9 + packages/core/src/__tests__/automation.test.ts | 27 +- packages/core/src/automation.ts | 18 + packages/core/src/index.ts | 4 +- packages/core/src/types.ts | 6 + packages/dashboard/app/api/legacy.ts | 48 +++ packages/dashboard/app/components/RoutineCard.tsx | 16 +- packages/dashboard/app/components/ScheduleForm.tsx | 53 ++- .../app/components/ScheduleStepsEditor.tsx | 49 ++- .../app/components/ScheduledTasksModal.tsx | 72 +++- packages/dashboard/app/components/ScriptsModal.css | 79 ++++ .../app/components/__tests__/RoutineCard.test.tsx | 45 +++ .../app/components/__tests__/ScheduleForm.test.tsx | 100 +++++- .../__tests__/ScheduleStepsEditor.test.tsx | 67 +++- .../__tests__/ScheduledTasksModal.test.tsx | 36 +- .../src/__tests__/routes-automation.test.ts | 151 +++++++- packages/dashboard/src/routes.ts | 400 ++++++++++++++++++++- packages/dashboard/src/server.ts | 7 +- packages/engine/src/__tests__/cron-runner.test.ts | 56 ++- .../src/__tests__/pi-create-fn-agent.test.ts | 44 +++ .../engine/src/__tests__/routine-runner.test.ts | 28 ++ packages/engine/src/cron-runner.ts | 20 +- packages/engine/src/pi.ts | 32 +- packages/engine/src/routine-runner.ts | 39 +- packages/i18n/locales/en/app.json | 9 + packages/i18n/src/resources.d.ts | 21 ++ 27 files changed, 1374 insertions(+), 69 deletions(-) Fusion-Task-Id: FN-7038 Fusion-Task-Lineage: 7f5871d0-1de0-4d22-a9c9-ee9418658677
356 lines
14 KiB
TypeScript
356 lines
14 KiB
TypeScript
import { useState, useCallback } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Play, Pause, Pencil, Trash2, Clock, CheckCircle, XCircle, ChevronDown, ChevronUp, Calendar, Webhook, Code, Zap, Globe, Folder, Layers, Loader2 } from "lucide-react";
|
|
import type { Routine, RoutineExecutionResult, RoutineTriggerType, RoutineCatchUpPolicy, RoutineExecutionPolicy } from "@fusion/core";
|
|
import { useConfirm } from "../hooks/useConfirm";
|
|
|
|
/**
|
|
* Format a duration in milliseconds to a human-readable string.
|
|
*/
|
|
function formatDurationMs(ms: number): string {
|
|
if (ms < 1000) return `${ms}ms`;
|
|
const seconds = Math.floor(ms / 1000);
|
|
if (seconds < 60) return `${seconds}s`;
|
|
const minutes = Math.floor(seconds / 60);
|
|
const remainingSeconds = seconds % 60;
|
|
if (minutes < 60) return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
|
|
const hours = Math.floor(minutes / 60);
|
|
const remainingMinutes = minutes % 60;
|
|
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
|
|
}
|
|
|
|
/**
|
|
* Format an ISO timestamp to a relative time string.
|
|
*
|
|
* FNXC:RelativeTime 2026-06-17-20:48:
|
|
* FN-6618 deliberately keeps RoutineCard separate from getRelativeTimeBucket because scheduled routines expose future-time copy (`in a moment`, `in Xm`, `in Xh`, `in Xd`) that the shared helper represents as null.
|
|
*/
|
|
function relativeTime(iso: string): string {
|
|
const now = Date.now();
|
|
const then = new Date(iso).getTime();
|
|
const diffMs = now - then;
|
|
|
|
// Future
|
|
if (diffMs < 0) {
|
|
const absDiff = Math.abs(diffMs);
|
|
if (absDiff < 60_000) return "in a moment";
|
|
if (absDiff < 3_600_000) return `in ${Math.floor(absDiff / 60_000)}m`;
|
|
if (absDiff < 86_400_000) return `in ${Math.floor(absDiff / 3_600_000)}h`;
|
|
return `in ${Math.floor(absDiff / 86_400_000)}d`;
|
|
}
|
|
|
|
// Past
|
|
if (diffMs < 60_000) return "just now";
|
|
if (diffMs < 3_600_000) return `${Math.floor(diffMs / 60_000)}m ago`;
|
|
if (diffMs < 86_400_000) return `${Math.floor(diffMs / 3_600_000)}h ago`;
|
|
return `${Math.floor(diffMs / 86_400_000)}d ago`;
|
|
}
|
|
|
|
/*
|
|
FNXC:Automations 2026-06-22-12:00:
|
|
Trigger-type badge colors must use the design system's THEME COLOR TOKENS so the Automations screen follows the
|
|
active theme (including light theme) instead of fixed hex literals. The previous values referenced undefined tokens
|
|
(--color-blue/-purple/-green/-gray) with hardcoded hex fallbacks that never resolved to a real token and never
|
|
adapted to the theme. Mapped to the closest defined semantic tokens from styles.css: cron→--todo (blue status),
|
|
webhook→--accent (brand purple), api→--color-success (green), manual→--text-muted (neutral). The badge applies this
|
|
to both border and text via inline style on .routine-trigger-badge.
|
|
*/
|
|
const TRIGGER_TYPE_COLORS: Record<RoutineTriggerType, string> = {
|
|
cron: "var(--todo)",
|
|
webhook: "var(--accent)",
|
|
api: "var(--color-success)",
|
|
manual: "var(--text-muted)",
|
|
};
|
|
|
|
const TRIGGER_TYPE_LABELS: Record<RoutineTriggerType, string> = {
|
|
cron: "Cron",
|
|
webhook: "Webhook",
|
|
api: "API",
|
|
manual: "Manual",
|
|
};
|
|
|
|
const TRIGGER_TYPE_ICONS: Record<RoutineTriggerType, React.ComponentType<{ size: number }>> = {
|
|
cron: Calendar,
|
|
webhook: Webhook,
|
|
api: Code,
|
|
manual: Zap,
|
|
};
|
|
|
|
const EXECUTION_POLICY_LABELS: Record<RoutineExecutionPolicy, string> = {
|
|
parallel: "Concurrent",
|
|
queue: "Queued",
|
|
reject: "Exclusive",
|
|
};
|
|
|
|
const CATCH_UP_POLICY_LABELS: Record<RoutineCatchUpPolicy, string> = {
|
|
run: "Catch up",
|
|
skip: "Skip missed",
|
|
run_one: "Catch up (latest)",
|
|
};
|
|
|
|
interface RoutineCardProps {
|
|
routine: Routine;
|
|
onEdit: (routine: Routine) => void;
|
|
onDelete: (routine: Routine) => void;
|
|
onRun: (routine: Routine) => void;
|
|
onToggle: (routine: Routine) => void;
|
|
/** Whether a manual run is currently in progress. */
|
|
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 }) {
|
|
const { t } = useTranslation("app");
|
|
const duration = result.completedAt && result.startedAt
|
|
? new Date(result.completedAt).getTime() - new Date(result.startedAt).getTime()
|
|
: 0;
|
|
return (
|
|
<span className={`schedule-run-badge ${result.success ? "success" : "failure"}`}>
|
|
{result.success ? (
|
|
<CheckCircle size={12} />
|
|
) : (
|
|
<XCircle size={12} />
|
|
)}
|
|
<span>{result.success ? t("routine.resultSuccess", "Success") : t("routine.resultFailed", "Failed")}</span>
|
|
{duration > 0 && (
|
|
<span className="schedule-run-duration">{formatDurationMs(duration)}</span>
|
|
)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function RunHistoryItem({ result, index }: { result: RoutineExecutionResult; index: number }) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const duration = result.completedAt && result.startedAt
|
|
? new Date(result.completedAt).getTime() - new Date(result.startedAt).getTime()
|
|
: 0;
|
|
|
|
return (
|
|
<div className="schedule-history-item">
|
|
<button
|
|
className="schedule-history-header"
|
|
onClick={() => setExpanded(!expanded)}
|
|
aria-expanded={expanded}
|
|
aria-label={`Run #${index + 1}: ${result.success ? "succeeded" : "failed"} ${relativeTime(result.startedAt)}`}
|
|
>
|
|
<span className={`schedule-history-status ${result.success ? "success" : "failure"}`}>
|
|
{result.success ? <CheckCircle size={12} /> : <XCircle size={12} />}
|
|
</span>
|
|
<span className="schedule-history-time">{relativeTime(result.startedAt)}</span>
|
|
{result.triggerType && (
|
|
<span className="routine-history-trigger-type">{TRIGGER_TYPE_LABELS[result.triggerType]}</span>
|
|
)}
|
|
<span className="schedule-history-duration">{formatDurationMs(duration)}</span>
|
|
{expanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
|
</button>
|
|
{expanded && (
|
|
<div className="schedule-history-detail">
|
|
{result.output && (
|
|
<pre className="schedule-history-output">{result.output}</pre>
|
|
)}
|
|
{result.error && (
|
|
<div className="schedule-history-error">{result.error}</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function RoutineCard({ routine, onEdit, onDelete, onRun, onToggle, running, lastRunOutput, liveRunOutput }: RoutineCardProps) {
|
|
const { t } = useTranslation("app");
|
|
const [showHistory, setShowHistory] = useState(false);
|
|
const { confirm } = useConfirm();
|
|
|
|
const handleDelete = useCallback(async () => {
|
|
const shouldDelete = await confirm({
|
|
title: t("routine.deleteTitle", "Delete Routine"),
|
|
message: t("routine.deleteMessage", "Delete routine \"{{name}}\"? This cannot be undone.", { name: routine.name }),
|
|
danger: true,
|
|
});
|
|
if (shouldDelete) {
|
|
onDelete(routine);
|
|
}
|
|
}, [routine, onDelete, confirm, t]);
|
|
|
|
const triggerColor = TRIGGER_TYPE_COLORS[routine.trigger.type];
|
|
const TriggerIcon = TRIGGER_TYPE_ICONS[routine.trigger.type];
|
|
|
|
// Get cron expression if available (from trigger or direct field)
|
|
const cronExpression = routine.trigger.type === "cron"
|
|
? (("cronExpression" in routine.trigger ? routine.trigger.cronExpression : undefined) as string | undefined) || routine.cronExpression || ""
|
|
: routine.cronExpression || "";
|
|
|
|
return (
|
|
<div className={`routine-card${routine.enabled ? "" : " disabled"}${running ? " running" : ""}`}>
|
|
<div className="routine-card-header">
|
|
<div className="routine-card-info">
|
|
<div className="routine-card-name-row">
|
|
<span className="routine-card-name">{routine.name}</span>
|
|
<span
|
|
className="routine-trigger-badge"
|
|
style={{ borderColor: triggerColor, color: triggerColor }}
|
|
>
|
|
<TriggerIcon size={10} />
|
|
{TRIGGER_TYPE_LABELS[routine.trigger.type]}
|
|
</span>
|
|
{routine.scope && (
|
|
<span
|
|
className={`routine-scope-badge${routine.scope === "global" ? " global" : " project"}`}
|
|
title={`${routine.scope === "global" ? "Global" : "Project"}-scoped routine`}
|
|
>
|
|
{routine.scope === "global" ? <Globe size={10} /> : <Folder size={10} />}
|
|
{routine.scope}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{routine.description && (
|
|
<p className="routine-card-description">{routine.description}</p>
|
|
)}
|
|
</div>
|
|
<div className="routine-card-actions">
|
|
<button
|
|
className="btn-icon"
|
|
onClick={() => onRun(routine)}
|
|
disabled={running}
|
|
title={running ? t("routine.running", "Running…") : t("routine.runNow", "Run now")}
|
|
aria-label={running ? t("routine.running", "Running…") : t("routine.runNameNow", "Run {{name}} now", { name: routine.name })}
|
|
>
|
|
{running ? <Loader2 className="spinner" /> : <Play />}
|
|
</button>
|
|
<button
|
|
className="btn-icon"
|
|
onClick={() => onToggle(routine)}
|
|
title={routine.enabled ? t("routine.disable", "Disable") : t("routine.enable", "Enable")}
|
|
aria-label={routine.enabled ? t("routine.disableName", "Disable {{name}}", { name: routine.name }) : t("routine.enableName", "Enable {{name}}", { name: routine.name })}
|
|
aria-pressed={routine.enabled}
|
|
>
|
|
{routine.enabled ? <Pause /> : <Play />}
|
|
</button>
|
|
<button
|
|
className="btn-icon"
|
|
onClick={() => onEdit(routine)}
|
|
title={t("routine.edit", "Edit")}
|
|
aria-label={t("routine.editName", "Edit {{name}}", { name: routine.name })}
|
|
>
|
|
<Pencil />
|
|
</button>
|
|
<button
|
|
className="btn-icon"
|
|
onClick={handleDelete}
|
|
title={t("routine.delete", "Delete")}
|
|
aria-label={t("routine.deleteName", "Delete {{name}}", { name: routine.name })}
|
|
>
|
|
<Trash2 />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="routine-card-meta">
|
|
{routine.steps && routine.steps.length > 0 ? (
|
|
<div className="routine-meta-item">
|
|
<Layers size={12} />
|
|
<span className="routine-policy-badge">{t("routine.stepCount", "{{count}} step", { count: routine.steps.length, defaultValue_one: "{{count}} step", defaultValue_other: "{{count}} steps" })}</span>
|
|
</div>
|
|
) : routine.command ? (
|
|
<div className="routine-meta-item routine-meta-command-preview" title={routine.command}>
|
|
<code className="routine-cron">{routine.command}</code>
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Cron expression for cron triggers */}
|
|
{routine.trigger.type === "cron" && cronExpression && (
|
|
<div className="routine-meta-item">
|
|
<Clock size={12} />
|
|
<code className="routine-cron">{cronExpression}</code>
|
|
</div>
|
|
)}
|
|
|
|
{/* Policy badges */}
|
|
<div className="routine-meta-item">
|
|
<span className="routine-policy-badge" title={`Execution policy: ${routine.executionPolicy}`}>
|
|
{EXECUTION_POLICY_LABELS[routine.executionPolicy]}
|
|
</span>
|
|
</div>
|
|
<div className="routine-meta-item">
|
|
<span className="routine-policy-badge" title={`Catch-up policy: ${routine.catchUpPolicy}`}>
|
|
{CATCH_UP_POLICY_LABELS[routine.catchUpPolicy]}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Timing info */}
|
|
{routine.nextRunAt && routine.enabled && (
|
|
<div className="routine-meta-item">
|
|
<span className="routine-meta-label">{t("schedule.nextLabel", "Next:")}</span>
|
|
<span title={routine.nextRunAt}>{relativeTime(routine.nextRunAt)}</span>
|
|
</div>
|
|
)}
|
|
{routine.lastRunAt && (
|
|
<div className="routine-meta-item">
|
|
<span className="routine-meta-label">{t("schedule.lastLabel", "Last:")}</span>
|
|
<span title={routine.lastRunAt}>{relativeTime(routine.lastRunAt)}</span>
|
|
</div>
|
|
)}
|
|
{routine.lastRunResult && (
|
|
<RunResultBadge result={routine.lastRunResult} />
|
|
)}
|
|
<div className="routine-meta-item">
|
|
<span className="routine-meta-label">{t("schedule.runsLabel", "Runs:")}</span>
|
|
<span>{routine.runCount}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{liveRunOutput && (
|
|
<div className={`routine-live-output ${liveRunOutput.status}`} aria-live="polite">
|
|
<div className="routine-live-output-header">
|
|
<span className="status-dot" />
|
|
<span>{liveRunOutput.status === "running" ? t("schedule.liveOutputRunning", "Live output — running") : t("schedule.liveOutput", "Live output")}</span>
|
|
</div>
|
|
{liveRunOutput.output && (
|
|
<pre className="routine-run-output-text">{liveRunOutput.output}</pre>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{lastRunOutput && (
|
|
<div className={`routine-run-output ${lastRunOutput.success ? "success" : "failure"}`}>
|
|
{lastRunOutput.output && (
|
|
<pre className="routine-run-output-text">{lastRunOutput.output}</pre>
|
|
)}
|
|
{lastRunOutput.error && (
|
|
<div className="routine-run-output-error">{lastRunOutput.error}</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{routine.runHistory.length > 0 && (
|
|
<div className="routine-card-history">
|
|
<button
|
|
className="schedule-history-toggle"
|
|
onClick={() => setShowHistory((h) => !h)}
|
|
aria-expanded={showHistory}
|
|
>
|
|
{showHistory ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
|
<span>{t("routine.runHistory", "Run History ({{count}})", { count: routine.runHistory.length })}</span>
|
|
</button>
|
|
{showHistory && (
|
|
<div className="schedule-history-list">
|
|
{routine.runHistory.slice(0, 10).map((result, i) => (
|
|
<RunHistoryItem key={`${result.startedAt}-${i}`} result={result} index={i} />
|
|
))}
|
|
{routine.runHistory.length > 10 && (
|
|
<div className="schedule-history-more">
|
|
{t("routine.andMore", "…and {{count}} more", { count: routine.runHistory.length - 10 })}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|