From 39baf2f1d68cdf60fe5a42741f6dcbdc26eb1396 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Thu, 10 Sep 2026 00:08:18 +0000 Subject: [PATCH] fix(cos): align workflow cadence editor (#6763) --- .../src/components/cos/tabs/WorkflowTab.jsx | 10 +-- .../cos/tabs/WorkflowTab.providers.test.jsx | 20 +++++ .../cos/tabs/workflow/ScheduleEditor.jsx | 61 ++++++-------- .../cos/tabs/workflow/ScheduleEditor.test.jsx | 82 +++++++++++++++++++ 4 files changed, 131 insertions(+), 42 deletions(-) create mode 100644 client/src/components/cos/tabs/workflow/ScheduleEditor.test.jsx diff --git a/client/src/components/cos/tabs/WorkflowTab.jsx b/client/src/components/cos/tabs/WorkflowTab.jsx index 8e2c5e27d3..4386196474 100644 --- a/client/src/components/cos/tabs/WorkflowTab.jsx +++ b/client/src/components/cos/tabs/WorkflowTab.jsx @@ -32,12 +32,6 @@ function describeSchedule(node) { if (schedule.cronSchedule) return describeRecurrence(schedule.cronSchedule); if (schedule.cronExpression) return describeCron(schedule.cronExpression) || schedule.cronExpression; if (node.kind === 'job' && schedule.scheduledTime) return `${schedule.type} at ${schedule.scheduledTime}`; - if (schedule.type === 'custom' && schedule.intervalMs) { - const intervalHours = schedule.intervalMs / 3_600_000; - if (intervalHours >= 24) return `every ${Math.round(intervalHours / 24)}d`; - if (intervalHours >= 1) return `every ${Math.round(intervalHours)}h`; - return `every ${Math.round(schedule.intervalMs / 60_000)}m`; - } return schedule.type?.replaceAll('-', ' ') || 'flexible'; } @@ -323,7 +317,7 @@ export default function WorkflowTab({ apps, providers, providersLoaded }) { if (!windowsByNode.has(window.nodeId)) windowsByNode.set(window.nodeId, []); windowsByNode.get(window.nodeId).push(window); } - const isFlexible = node => node.kind === 'task' && ['rotation', 'on-demand'].includes(node.schedule?.type); + const isFlexible = node => node.kind === 'task' && node.schedule?.type === 'on-demand' && !node.schedule?.perpetual; const scheduled = graph.nodes .filter(node => node.enabled && !isFlexible(node)) .sort((a, b) => { @@ -431,7 +425,7 @@ export default function WorkflowTab({ apps, providers, providersLoaded }) { {model.flexible.length > 0 && (
Unpinned runner queue
-

These are active, but rotation and on-demand schedules do not promise a clock time.

+

These active on-demand tasks do not promise a clock time.

{model.flexible.map(node => { const canExpand = node.kind === 'task' && (node.totalAppCount || 0) > 0; diff --git a/client/src/components/cos/tabs/WorkflowTab.providers.test.jsx b/client/src/components/cos/tabs/WorkflowTab.providers.test.jsx index 50631014d0..90460a7b37 100644 --- a/client/src/components/cos/tabs/WorkflowTab.providers.test.jsx +++ b/client/src/components/cos/tabs/WorkflowTab.providers.test.jsx @@ -66,6 +66,26 @@ const renderTab = async (providers) => { }; describe('WorkflowTab per-app override rows', () => { + it('keeps perpetual on-demand tasks on the clocked track', async () => { + api.getCosWorkflow.mockResolvedValue({ + ...GRAPH, + timeline: { ...GRAPH.timeline, occurrences: [] }, + nodes: [ + { ...GRAPH.nodes[0], id: 'task:flexible', label: 'Flexible', schedule: { type: 'on-demand', perpetual: false }, totalAppCount: 0 }, + { ...GRAPH.nodes[0], id: 'task:drain', label: 'Drain', schedule: { type: 'on-demand', perpetual: true, recheckCron: '0 9 * * *' }, totalAppCount: 0 }, + ], + }); + + await act(async () => { + render(); + }); + + expect((await screen.findByText('Active schedules')).parentElement).toHaveTextContent('1'); + expect(screen.getByText('Drain')).toBeInTheDocument(); + expect(screen.getByText('Flexible')).toBeInTheDocument(); + expect(screen.getByText('These active on-demand tasks do not promise a clock time.')).toBeInTheDocument(); + }); + it('renders the app pin with the provider display name, not the raw id', async () => { await renderTab(PROVIDERS); const pin = screen.getByLabelText('Provider for Acme'); diff --git a/client/src/components/cos/tabs/workflow/ScheduleEditor.jsx b/client/src/components/cos/tabs/workflow/ScheduleEditor.jsx index 26ba15e04e..3037021c0b 100644 --- a/client/src/components/cos/tabs/workflow/ScheduleEditor.jsx +++ b/client/src/components/cos/tabs/workflow/ScheduleEditor.jsx @@ -4,16 +4,11 @@ import toast from '../../../ui/Toast'; import * as api from '../../../../services/api'; import { DEFAULT_CRON, buildCronFromRecurrence, parseCronToRecurrence } from '../../../../utils/cronHelpers'; import CronSchedulePicker from '../../../CronSchedulePicker'; +import { PERPETUAL_DESCRIPTION } from '../schedule/scheduleConstants'; const TASK_MODES = [ - ['cron', 'Pinned time (cron)'], - ['perpetual', 'Perpetual drain'], - ['daily', 'Daily interval'], - ['weekly', 'Weekly interval'], - ['rotation', 'Runner rotation'], - ['custom', 'Custom interval'], - ['once', 'Once'], - ['on-demand', 'On demand'] + ['on-demand', 'On Demand'], + ['cron', 'Scheduled'] ]; const JOB_INTERVALS = [ @@ -47,13 +42,13 @@ export default function ScheduleEditor({ node, allNodes, timezone, onClose, onSa // here would silently override that cadence on any unrelated save, // because recheckCron takes precedence over recheckIntervalMs. recheckCron: schedule.recheckCron || '', + perpetual: !!schedule.perpetual, interval: node.kind === 'job' ? (schedule.type || 'daily') : 'daily', - intervalHours: Math.max(1, Math.round((schedule.intervalMs || 3_600_000) / 3_600_000)), scheduledTime: schedule.scheduledTime || '', weekdaysOnly: !!schedule.weekdaysOnly, runAfter: [...(node.runAfter || [])] }); - }, [node, schedule.cronExpression, schedule.cronSchedule, schedule.intervalMs, schedule.recheckCron, schedule.scheduledTime, schedule.type, schedule.weekdaysOnly]); + }, [node, schedule.cronExpression, schedule.cronSchedule, schedule.perpetual, schedule.recheckCron, schedule.scheduledTime, schedule.type, schedule.weekdaysOnly]); const dependencyOptions = useMemo(() => { if (!node) return []; @@ -88,15 +83,10 @@ export default function ScheduleEditor({ node, allNodes, timezone, onClose, onSa toast.error('Cron schedules need five fields'); return; } - if (node.kind === 'task' && form.mode === 'perpetual' && form.recheckCron && !validateCron(form.recheckCron)) { + if (node.kind === 'task' && form.perpetual && form.mode === 'on-demand' && form.recheckCron && !validateCron(form.recheckCron)) { toast.error('The perpetual recheck schedule needs five fields'); return; } - const intervalHours = Number(form.intervalHours); - if (node.kind === 'task' && form.mode === 'custom' && (!Number.isFinite(intervalHours) || intervalHours <= 0)) { - toast.error('Custom intervals need a positive number of hours'); - return; - } setSaving(true); let result; @@ -105,10 +95,10 @@ export default function ScheduleEditor({ node, allNodes, timezone, onClose, onSa enabled: form.enabled, type: form.mode, cronExpression: form.mode === 'cron' ? String(form.cronExpression || '').trim() || null : null, + perpetual: form.perpetual, + recheckCron: form.recheckCron.trim() || null, runAfter: form.runAfter }; - if (form.mode === 'custom') payload.intervalMs = intervalHours * 3_600_000; - if (form.mode === 'perpetual') payload.recheckCron = form.recheckCron.trim() || null; result = await api.updateCosTaskInterval(node.id.slice(5), payload, { silent: true }).catch(error => { toast.error(error.message); return null; @@ -169,9 +159,9 @@ export default function ScheduleEditor({ node, allNodes, timezone, onClose, onSa {node.kind === 'task' ? ( -
)} - {node.kind === 'task' && form.mode === 'perpetual' && ( + {node.kind === 'task' && ( + + )} + + {node.kind === 'task' && form.perpetual && form.mode === 'on-demand' && (

Drains work back-to-back. Once parked, this is its reset/recheck time — leave blank to keep the default interval-based recheck cadence. @@ -217,13 +223,6 @@ export default function ScheduleEditor({ node, allNodes, timezone, onClose, onSa

)} - {node.kind === 'task' && form.mode === 'custom' && ( - - )} - {node.kind === 'job' && form.mode === 'interval' && (
)} - {(form.mode === 'daily' || form.mode === 'weekly') && ( -

- Interval schedules float with the last run. Choose “Pinned time” when its position relative to other tasks must stay fixed. -

- )} -