Files
fusion/packages/dashboard/app/components/RoutineCard.tsx
gsxdsm 8131d541ca FN-7038: add live automation run output
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
2026-06-26 02:45:29 -07:00

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