import type { SuperagentToolCall } from '../types'; import { getStringArg } from './toolWidgetUtils'; /** * Pure logic for the create_or_update_workflow widget — native port of the web * CreateWorkflowWidget helpers plus the pieces of the workflow trigger * registry / cron humanizer it uses (frontend/apps/builder/.../Components/ * CreateWorkflowWidget.tsx, components/workflows/registries/triggerRegistry.ts, * components/workflows/utils/cronHumanizer.ts). English strings are the values * of the web's dashboard-workflows translation keys. */ export type WorkflowTriggerType = 'scheduled' | 'entity' | 'connector' | 'in_app_agent'; const TRIGGER_TYPES = new Set(['scheduled', 'entity', 'connector', 'in_app_agent']); // ── Instant / cron formatting ──────────────────────────────────────────────── /** * Format a backend UTC instant in the user's locale. The backend serializes * naive-UTC datetimes without a 'Z' suffix, which `new Date()` would otherwise * parse as local time — append it when no offset is present. */ export function formatUtcInstant(value: string): string { const iso = /(Z|[+-]\d{2}:?\d{2})$/.test(value) ? value : `${value}Z`; const date = new Date(iso); if (Number.isNaN(date.getTime())) return value; return date.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', }); } const DOW_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; function formatTime(hour: number, minute: number): string | null { if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null; if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null; const d = new Date(); d.setHours(hour, minute, 0, 0); return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); } function dowName(idx: number): string | null { if (!Number.isFinite(idx)) return null; // Both 0 and 7 mean Sunday in cron. const normalized = idx === 7 ? 0 : idx; return normalized >= 0 && normalized <= 6 ? DOW_NAMES[normalized] : null; } /** "1-5" → "Mon–Fri"; "1,3,5" → "Mon, Wed & Fri"; "1" → "Mon". Null when unparseable. */ function formatDaysOfWeek(dow: string): string | null { const rangeMatch = dow.match(/^(\d+)-(\d+)$/); if (rangeMatch) { // Reversed/degenerate ranges (5-1, 3-3) have implementation-defined cron // semantics — don't guess, let the caller fall back to the raw expression. if (Number(rangeMatch[1]) >= Number(rangeMatch[2])) return null; const from = dowName(Number(rangeMatch[1])); const to = dowName(Number(rangeMatch[2])); return from && to ? `${from}–${to}` : null; } if (/^\d+(?:,\d+)*$/.test(dow)) { const names = dow.split(',').map((d) => dowName(Number(d))); if (names.some((n) => !n)) return null; if (names.length === 1) return names[0]; return `${names.slice(0, -1).join(', ')} & ${names[names.length - 1]}`; } return null; } function withTz(summary: string, timezone: string | null | undefined): string { return timezone ? `${summary} (${timezone})` : summary; } /** * Cron expression → human schedule label ("0 8 * * 1-5" → "Mon–Fri at 8:00 AM"). * Falls back to the raw expression (with timezone qualifier) when the pattern * isn't recognized — never throws. */ export function humanizeCron( expr: string | null | undefined, timezone?: string | null, ): string { if (!expr) return 'On schedule'; const parts = expr.trim().split(/\s+/); if (parts.length !== 5) return withTz(expr, timezone); const [minute, hour, dom, month, dow] = parts; // Interval patterns: */N minutes, or fixed-minute */N hours. A zero step // ("*/0") is invalid cron — fall through to the raw-expression fallback. const minuteStep = minute.match(/^\*\/(\d+)$/); if (minuteStep && Number(minuteStep[1]) > 0 && hour === '*' && dom === '*' && month === '*' && dow === '*') { return withTz(`Every ${Number(minuteStep[1])} minutes`, timezone); } const hourStep = hour.match(/^\*\/(\d+)$/); if (hourStep && Number(hourStep[1]) > 0 && /^\d+$/.test(minute) && dom === '*' && month === '*' && dow === '*') { return withTz(`Every ${Number(hourStep[1])} hours`, timezone); } // All remaining patterns need a concrete wall-clock time. if (!/^\d+$/.test(minute) || !/^\d+$/.test(hour)) return withTz(expr, timezone); const time = formatTime(Number(hour), Number(minute)); if (!time) return withTz(expr, timezone); if (dow === '*' && /^\d+$/.test(dom) && month === '*') { return withTz(`Day ${Number(dom)} of every month at ${time}`, timezone); } if (dow === '*' && dom === '*' && month === '*') { return withTz(`Daily at ${time}`, timezone); } if (dom === '*' && month === '*') { const days = formatDaysOfWeek(dow); if (days) return withTz(`${days} at ${time}`, timezone); } return withTz(expr, timezone); } // ── Workflow trigger summary ──────────────────────────────────────────────── /** "Every 2 days" / "Every 90 minutes" — '' when the interval fields are incomplete. */ function humanizeInterval(config: Record): string { const value = Number(config.interval_value); const unit = String(config.interval_unit ?? ''); if (!Number.isFinite(value) || value < 1) return ''; if (unit === 'minutes') return value === 1 ? 'Every minute' : `Every ${value} minutes`; if (unit === 'hours') return value === 1 ? 'Every hour' : `Every ${value} hours`; if (unit === 'days') return value === 1 ? 'Every day' : `Every ${value} days`; return ''; } /** * Trigger config from create_or_update_workflow args: the trigger dict may * carry the fields at the top level or nested under `config` (web * normalizeWorkflowTrigger). Null when absent or of unknown type. */ export function getWorkflowTriggerConfig( args: Record | null, ): { type: WorkflowTriggerType; config: Record } | null { const rawTrigger = args?.trigger; if (!rawTrigger || typeof rawTrigger !== 'object') return null; const trigger = rawTrigger as Record; const config = (trigger.config && typeof trigger.config === 'object' ? trigger.config : trigger) as Record; const type = String(config.trigger_type ?? ''); return TRIGGER_TYPES.has(type) ? { type: type as WorkflowTriggerType, config } : null; } /** English port of the trigger registry's formatDescription per trigger type. */ export function describeWorkflowTrigger( type: WorkflowTriggerType, config: Record, ): string { if (type === 'scheduled') { const oneTimeDate = getStringArg(config, ['one_time_date']); if (config.schedule_mode === 'one_time' && oneTimeDate) { return `Runs once on ${formatUtcInstant(oneTimeDate)}`; } if (config.schedule_mode === 'interval') { const summary = humanizeInterval(config); if (summary) return summary; } return humanizeCron(getStringArg(config, ['cron_expression']) || null, getStringArg(config, ['timezone']) || null); } if (type === 'entity') { return `${getStringArg(config, ['entity_name'])} — ${joinList(config.events)}`; } if (type === 'connector') { // The web resolves integration_type to the connector's display name via a // web-only registry; native shows the raw id (e.g. "gmail"). return `${getStringArg(config, ['integration_type'])} — ${joinList(config.events)}`; } return `${getStringArg(config, ['agent_name']) || 'Any agent'} — ${joinList(config.event_types)}`; } function joinList(value: unknown): string { return Array.isArray(value) ? value.map(String).join(', ') : ''; } // ── Status badge + name ───────────────────────────────────────────────────── export type WorkflowBadgeStatus = 'active' | 'inactive'; function normalizeWorkflowBadgeStatus(value: unknown): WorkflowBadgeStatus | null { if (value === true) return 'active'; if (value === false) return 'inactive'; if (typeof value !== 'string') return null; const normalized = value.toLowerCase(); if (normalized === 'active') return 'active'; if (normalized === 'inactive' || normalized === 'paused') return 'inactive'; return null; } function asRecord(value: unknown): Record | null { return value && typeof value === 'object' ? (value as Record) : null; } export function getWorkflowBadgeStatus( args: Record | null, results: SuperagentToolCall['results'], ): WorkflowBadgeStatus | null { let output: unknown = results; if (typeof output === 'string') { try { output = JSON.parse(output); } catch { output = null; } } const record = asRecord(output); return normalizeWorkflowBadgeStatus(asRecord(record?.workflow)?.status) ?? normalizeWorkflowBadgeStatus(asRecord(record?.result)?.status) ?? normalizeWorkflowBadgeStatus(record?.status) ?? normalizeWorkflowBadgeStatus(args?.status) ?? normalizeWorkflowBadgeStatus(args?.is_active); } export function getWorkflowName(args: Record | null): string { const documentName = asRecord(asRecord(args?.definition)?.document)?.name; return getStringArg(args, ['name']) || (typeof documentName === 'string' ? documentName : '') || 'Workflow'; }