Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 2 additions & 8 deletions client/src/components/cos/tabs/WorkflowTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -431,7 +425,7 @@ export default function WorkflowTab({ apps, providers, providersLoaded }) {
{model.flexible.length > 0 && (
<section className="rounded-lg border border-dashed border-port-border/60 bg-port-card/20 p-3">
<div className="flex items-center gap-2 text-xs font-medium text-gray-400"><RotateCcw className="h-3.5 w-3.5" /> Unpinned runner queue</div>
<p className="mt-1 text-[11px] text-gray-600">These are active, but rotation and on-demand schedules do not promise a clock time.</p>
<p className="mt-1 text-[11px] text-gray-600">These active on-demand tasks do not promise a clock time.</p>
<div className="mt-2 flex flex-wrap gap-2">
{model.flexible.map(node => {
const canExpand = node.kind === 'task' && (node.totalAppCount || 0) > 0;
Expand Down
20 changes: 20 additions & 0 deletions client/src/components/cos/tabs/WorkflowTab.providers.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<MemoryRouter><WorkflowTab apps={[]} providers={[]} /></MemoryRouter>);
});

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');
Expand Down
61 changes: 27 additions & 34 deletions client/src/components/cos/tabs/workflow/ScheduleEditor.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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 [];
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -169,9 +159,9 @@ export default function ScheduleEditor({ node, allNodes, timezone, onClose, onSa
</label>

{node.kind === 'task' ? (
<label className="block text-xs text-gray-400">
<label htmlFor="workflow-task-cadence" className="block text-xs text-gray-400">
Scheduling behavior
<select value={form.mode} onChange={event => setMode(event.target.value)} className="mt-1.5 w-full rounded border border-port-border bg-port-bg px-3 py-2 text-sm text-white">
<select id="workflow-task-cadence" value={form.mode} onChange={event => setMode(event.target.value)} className="mt-1.5 w-full rounded border border-port-border bg-port-bg px-3 py-2 text-sm text-white">
{TASK_MODES.map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
</label>
Expand Down Expand Up @@ -208,7 +198,23 @@ export default function ScheduleEditor({ node, allNodes, timezone, onClose, onSa
</div>
)}

{node.kind === 'task' && form.mode === 'perpetual' && (
{node.kind === 'task' && (
<label htmlFor="workflow-task-perpetual" className="flex items-center justify-between gap-3 text-sm text-gray-300">
<span>
<span className="block">Perpetual</span>
<span className="mt-0.5 block text-[11px] text-gray-500">{PERPETUAL_DESCRIPTION}</span>
</span>
<input
id="workflow-task-perpetual"
type="checkbox"
checked={form.perpetual}
onChange={event => set('perpetual', event.target.checked)}
className="h-4 w-4 shrink-0 accent-port-accent"
/>
</label>
)}

{node.kind === 'task' && form.perpetual && form.mode === 'on-demand' && (
<div className="space-y-2 rounded border border-port-warning/20 bg-port-warning/5 p-3">
<p className="text-xs text-gray-400">
Drains work back-to-back. Once parked, this is its reset/recheck time — leave blank to keep the default interval-based recheck cadence.
Expand All @@ -217,13 +223,6 @@ export default function ScheduleEditor({ node, allNodes, timezone, onClose, onSa
</div>
)}

{node.kind === 'task' && form.mode === 'custom' && (
<label className="block text-xs text-gray-400">
Repeat every (hours)
<input type="number" min="1" value={form.intervalHours} onChange={event => set('intervalHours', event.target.value)} className="mt-1.5 w-full rounded border border-port-border bg-port-bg px-3 py-2 text-sm text-white" />
</label>
)}

{node.kind === 'job' && form.mode === 'interval' && (
<div className="grid grid-cols-2 gap-2">
<label className="block text-xs text-gray-400">
Expand Down Expand Up @@ -269,12 +268,6 @@ export default function ScheduleEditor({ node, allNodes, timezone, onClose, onSa
</div>
)}

{(form.mode === 'daily' || form.mode === 'weekly') && (
<p className="rounded border border-port-border/50 bg-port-bg/50 p-2 text-xs leading-relaxed text-gray-500">
Interval schedules float with the last run. Choose “Pinned time” when its position relative to other tasks must stay fixed.
</p>
)}

<button type="button" onClick={handleSave} disabled={saving} className="flex w-full items-center justify-center gap-2 rounded bg-port-accent px-3 py-2 text-sm font-medium text-white hover:bg-port-accent/80 disabled:opacity-50">
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Save schedule
Expand Down
82 changes: 82 additions & 0 deletions client/src/components/cos/tabs/workflow/ScheduleEditor.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const api = {
updateCosJob: vi.fn(),
updateCosTaskInterval: vi.fn(),
};
vi.mock('../../../../services/api', () => api);

const ScheduleEditor = (await import('./ScheduleEditor')).default;

const taskNode = (schedule = {}) => ({
id: 'task:review',
kind: 'task',
label: 'Review',
enabled: true,
runAfter: [],
schedule: {
type: 'cron',
cronExpression: '0 9 * * *',
perpetual: false,
recheckCron: null,
...schedule,
},
});

beforeEach(() => {
vi.clearAllMocks();
api.updateCosTaskInterval.mockResolvedValue({ success: true });
api.updateCosJob.mockResolvedValue({ success: true });
});
afterEach(cleanup);

function renderEditor(node = taskNode()) {
const onSaved = vi.fn();
render(
<ScheduleEditor
node={node}
allNodes={[node]}
timezone="UTC"
onClose={vi.fn()}
onSaved={onSaved}
/>
);
return onSaved;
}

describe('ScheduleEditor task cadence', () => {
it('offers the two current cadence variants and saves perpetual independently of cron', async () => {
const onSaved = renderEditor();
const cadence = screen.getByLabelText('Scheduling behavior');

expect([...cadence.options].map(option => option.textContent)).toEqual(['On Demand', 'Scheduled']);

fireEvent.click(screen.getByRole('checkbox', { name: /Perpetual/ }));
await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Save schedule' })));

expect(api.updateCosTaskInterval).toHaveBeenCalledWith('review', {
enabled: true,
type: 'cron',
cronExpression: '0 9 * * *',
perpetual: true,
recheckCron: null,
runAfter: [],
}, { silent: true });
await waitFor(() => expect(onSaved).toHaveBeenCalled());
});

it('clears the cron expression only when switching to on demand', async () => {
renderEditor(taskNode({ perpetual: true, recheckCron: '0 11 * * *' }));

fireEvent.change(screen.getByLabelText('Scheduling behavior'), { target: { value: 'on-demand' } });
await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Save schedule' })));

expect(api.updateCosTaskInterval).toHaveBeenCalledWith('review', expect.objectContaining({
type: 'on-demand',
cronExpression: null,
perpetual: true,
recheckCron: '0 11 * * *',
}), { silent: true });
});
});