Skip to content

Commit 97dc4ea

Browse files
committed
feat(cos): add runAfter dependency support for task scheduling
Tasks can now declare dependencies on other task types via runAfter. A task with dependencies will wait until all specified tasks have completed within the current cycle before running. Includes UI controls for configuring dependencies and status badges showing when a task is waiting on deps.
1 parent 91605df commit 97dc4ea

3 files changed

Lines changed: 143 additions & 44 deletions

File tree

client/src/components/cos/tabs/ScheduleTab.jsx

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ function IntervalBadge({ type }) {
5757
);
5858
}
5959

60-
function GlobalConfigControls({ taskType, config, onUpdate, onTrigger, onReset, category: _category, providers, apps, updating, setUpdating }) {
60+
function GlobalConfigControls({ taskType, config, onUpdate, onTrigger, onReset, category: _category, providers, apps, updating, setUpdating, allTaskTypes }) {
6161
const [selectedType, setSelectedType] = useState(config.type);
6262
const [selectedProviderId, setSelectedProviderId] = useState(config.providerId || '');
6363
const [selectedModel, setSelectedModel] = useState(config.model || '');
@@ -289,6 +289,38 @@ function GlobalConfigControls({ taskType, config, onUpdate, onTrigger, onReset,
289289
</div>
290290
</div>
291291

292+
{allTaskTypes?.length > 1 && (
293+
<div>
294+
<label className="text-sm text-gray-400 block mb-2">Run After (dependencies)</label>
295+
<div className="flex flex-wrap gap-2">
296+
{allTaskTypes.filter(t => t !== taskType).map(dep => {
297+
const isSelected = (config.runAfter || []).includes(dep);
298+
return (
299+
<button
300+
key={dep}
301+
onClick={() => {
302+
const current = config.runAfter || [];
303+
const updated = isSelected
304+
? current.filter(d => d !== dep)
305+
: [...current, dep];
306+
onUpdate(taskType, { runAfter: updated.length > 0 ? updated : null });
307+
}}
308+
disabled={updating}
309+
className={`text-xs px-2 py-1 rounded border transition-colors ${
310+
isSelected
311+
? 'bg-port-accent/20 border-port-accent/50 text-port-accent'
312+
: 'bg-port-card border-port-border text-gray-400 hover:border-gray-500'
313+
}`}
314+
>
315+
{dep}
316+
</button>
317+
);
318+
})}
319+
</div>
320+
<p className="text-xs text-gray-500 mt-1">This task will wait for selected tasks to complete first within the same cycle</p>
321+
</div>
322+
)}
323+
292324
<div className="flex gap-2">
293325
{activeApps.length > 0 ? (
294326
<div className="relative" ref={appSelectorRef}>
@@ -493,7 +525,7 @@ function PerAppOverrideList({ taskType, config, apps, onUpdateOverride, onBulkTo
493525
);
494526
}
495527

496-
function AppTaskTypeRow({ taskType, config, onUpdate, onTrigger, onReset, providers, apps, onUpdateOverride, onBulkToggleOverride }) {
528+
function AppTaskTypeRow({ taskType, config, onUpdate, onTrigger, onReset, providers, apps, onUpdateOverride, onBulkToggleOverride, allTaskTypes }) {
497529
const [expanded, setExpanded] = useState(false);
498530
const [updating, setUpdating] = useState(false);
499531

@@ -520,12 +552,20 @@ function AppTaskTypeRow({ taskType, config, onUpdate, onTrigger, onReset, provid
520552
{!config.enabled && (
521553
<span className="text-xs px-2 py-0.5 bg-gray-600/50 text-gray-400 rounded">Disabled</span>
522554
)}
555+
{config.status?.reason === 'waiting-on-dependencies' && (
556+
<span className="text-xs px-2 py-0.5 bg-port-warning/20 text-port-warning rounded" title={`Waiting for: ${config.status.pendingDeps?.join(', ')}`}>
557+
Waiting on deps
558+
</span>
559+
)}
560+
</div>
561+
<div className="flex items-center gap-2 text-xs text-gray-500 flex-wrap">
562+
{config.globalLastRun && (
563+
<span>Last run: {new Date(config.globalLastRun).toLocaleDateString()} ({config.globalRunCount || 0} total)</span>
564+
)}
565+
{config.runAfter?.length > 0 && (
566+
<span className="text-gray-500">after: {config.runAfter.join(', ')}</span>
567+
)}
523568
</div>
524-
{config.globalLastRun && (
525-
<div className="text-xs text-gray-500">
526-
Last run: {new Date(config.globalLastRun).toLocaleDateString()} ({config.globalRunCount || 0} total)
527-
</div>
528-
)}
529569
</div>
530570

531571
<div className="flex items-center gap-2">
@@ -557,6 +597,7 @@ function AppTaskTypeRow({ taskType, config, onUpdate, onTrigger, onReset, provid
557597
apps={apps}
558598
updating={updating}
559599
setUpdating={setUpdating}
600+
allTaskTypes={allTaskTypes}
560601
/>
561602
</div>
562603

@@ -578,6 +619,7 @@ function AppTaskTypeSection({ tasks, onUpdate, onTrigger, onReset, providers, ap
578619
if (taskEntries.length === 0) return null;
579620

580621
const enabledCount = taskEntries.filter(([, config]) => config.enabled).length;
622+
const allTaskTypes = taskEntries.map(([taskType]) => taskType);
581623

582624
return (
583625
<div className="space-y-3">
@@ -603,6 +645,7 @@ function AppTaskTypeSection({ tasks, onUpdate, onTrigger, onReset, providers, ap
603645
apps={apps}
604646
onUpdateOverride={onUpdateOverride}
605647
onBulkToggleOverride={onBulkToggleOverride}
648+
allTaskTypes={allTaskTypes}
606649
/>
607650
))}
608651
</div>

server/routes/cos.js

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ const cosConfigSchema = z.object({
6363
}).optional()
6464
}).strict();
6565

66-
const SCHEDULE_FIELDS = ['type', 'enabled', 'intervalMs', 'providerId', 'model', 'prompt', 'taskMetadata'];
66+
const SCHEDULE_FIELDS = ['type', 'enabled', 'intervalMs', 'providerId', 'model', 'prompt', 'taskMetadata', 'runAfter'];
6767

6868
/**
6969
* Pick only defined values from body for schedule settings updates
@@ -89,6 +89,17 @@ function pickScheduleSettings(body) {
8989
}
9090
settings.taskMetadata = sanitized;
9191
}
92+
if (settings.runAfter !== undefined && settings.runAfter !== null) {
93+
if (!Array.isArray(settings.runAfter)) {
94+
throw new ServerError('runAfter must be an array of task type strings or null', { status: 400, code: 'VALIDATION_ERROR' });
95+
}
96+
if (!settings.runAfter.every(v => typeof v === 'string')) {
97+
throw new ServerError('runAfter entries must be strings', { status: 400, code: 'VALIDATION_ERROR' });
98+
}
99+
if (settings.runAfter.length === 0) {
100+
settings.runAfter = null;
101+
}
102+
}
92103
return settings;
93104
}
94105

@@ -691,7 +702,13 @@ router.get('/schedule/task/:taskType', asyncHandler(async (req, res) => {
691702
// PUT /api/cos/schedule/task/:taskType - Update interval for a task type (unified)
692703
router.put('/schedule/task/:taskType', asyncHandler(async (req, res) => {
693704
const { taskType } = req.params;
694-
const result = await taskSchedule.updateTaskInterval(taskType, pickScheduleSettings(req.body));
705+
const settings = pickScheduleSettings(req.body);
706+
// Filter self-references from runAfter to prevent permanent blocking
707+
if (Array.isArray(settings.runAfter)) {
708+
settings.runAfter = settings.runAfter.filter(dep => dep !== taskType);
709+
if (settings.runAfter.length === 0) settings.runAfter = null;
710+
}
711+
const result = await taskSchedule.updateTaskInterval(taskType, settings);
695712
res.json({ success: true, taskType, interval: result });
696713
}));
697714

server/services/taskSchedule.js

Lines changed: 74 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { writeFile } from 'fs/promises';
1717
import { existsSync } from 'fs';
1818
import { join } from 'path';
1919
import { cosEvents, emitLog } from './cos.js';
20-
import { DAY, ensureDir, HOUR, readJSONFile, PATHS } from '../lib/fileUtils.js';
20+
import { DAY, ensureDir, HOUR, readJSONFile, PATHS, safeDate } from '../lib/fileUtils.js';
2121
import { getAdaptiveCooldownMultiplier } from './taskLearning.js';
2222
import { isTaskTypeEnabledForApp, getAppTaskTypeInterval, getActiveApps, getAppTaskTypeOverrides } from './apps.js';
2323
import { PORTOS_UI_URL } from '../lib/ports.js';
@@ -1174,6 +1174,34 @@ export async function getExecutionHistory(taskType) {
11741174
return schedule.executions[key] || { lastRun: null, count: 0, perApp: {} };
11751175
}
11761176

1177+
/**
1178+
* Check if all runAfter dependencies have completed since this task's last run.
1179+
* Returns { satisfied, pending } where pending lists unfinished dependency task types.
1180+
*/
1181+
function checkRunAfterDeps(schedule, taskType, appId = null) {
1182+
const interval = schedule.tasks[taskType];
1183+
const deps = interval?.runAfter;
1184+
if (!deps || deps.length === 0) return { satisfied: true, pending: [] };
1185+
1186+
const key = `task:${taskType}`;
1187+
const execution = schedule.executions[key] || { lastRun: null, perApp: {} };
1188+
const ownLastRun = safeDate(appId ? execution.perApp[appId]?.lastRun : execution.lastRun);
1189+
1190+
const pending = [];
1191+
for (const dep of deps) {
1192+
const depKey = `task:${dep}`;
1193+
const depExec = schedule.executions[depKey] || { lastRun: null, perApp: {} };
1194+
const depLastRun = safeDate(appId ? depExec.perApp[appId]?.lastRun : depExec.lastRun);
1195+
1196+
// Dependency must have run after this task's last run (i.e., within the current cycle)
1197+
if (depLastRun <= ownLastRun) {
1198+
pending.push(dep);
1199+
}
1200+
}
1201+
1202+
return { satisfied: pending.length === 0, pending };
1203+
}
1204+
11771205
/**
11781206
* Check if a task type should run for a specific app (or globally)
11791207
*/
@@ -1227,71 +1255,82 @@ export async function shouldRunTask(taskType, appId = null) {
12271255
return result;
12281256
};
12291257

1258+
let result;
1259+
12301260
switch (effectiveType) {
12311261
case INTERVAL_TYPES.ROTATION:
1232-
return { shouldRun: true, reason: 'rotation' };
1262+
result = { shouldRun: true, reason: 'rotation' };
1263+
break;
12331264

12341265
case INTERVAL_TYPES.DAILY: {
12351266
const learningAdjustment = await getPerformanceAdjustedInterval(taskType, DAY);
12361267
const adjustedInterval = learningAdjustment.adjustedIntervalMs;
1237-
12381268
if (timeSinceLastRun >= adjustedInterval) {
1239-
return buildResult(true, learningAdjustment.adjusted ? 'daily-due-adjusted' : 'daily-due', DAY, { learningAdjustment });
1269+
result = buildResult(true, learningAdjustment.adjusted ? 'daily-due-adjusted' : 'daily-due', DAY, { learningAdjustment });
1270+
} else {
1271+
result = buildResult(false, learningAdjustment.adjusted ? 'daily-cooldown-adjusted' : 'daily-cooldown', DAY, {
1272+
learningAdjustment, nextRunIn: adjustedInterval - timeSinceLastRun,
1273+
nextRunAt: new Date(lastRun + adjustedInterval).toISOString(),
1274+
baseIntervalMs: DAY, adjustedIntervalMs: adjustedInterval
1275+
});
12401276
}
1241-
return buildResult(false, learningAdjustment.adjusted ? 'daily-cooldown-adjusted' : 'daily-cooldown', DAY, {
1242-
learningAdjustment,
1243-
nextRunIn: adjustedInterval - timeSinceLastRun,
1244-
nextRunAt: new Date(lastRun + adjustedInterval).toISOString(),
1245-
baseIntervalMs: DAY,
1246-
adjustedIntervalMs: adjustedInterval
1247-
});
1277+
break;
12481278
}
12491279

12501280
case INTERVAL_TYPES.WEEKLY: {
12511281
const learningAdjustment = await getPerformanceAdjustedInterval(taskType, WEEK);
12521282
const adjustedInterval = learningAdjustment.adjustedIntervalMs;
1253-
12541283
if (timeSinceLastRun >= adjustedInterval) {
1255-
return buildResult(true, learningAdjustment.adjusted ? 'weekly-due-adjusted' : 'weekly-due', WEEK, { learningAdjustment });
1284+
result = buildResult(true, learningAdjustment.adjusted ? 'weekly-due-adjusted' : 'weekly-due', WEEK, { learningAdjustment });
1285+
} else {
1286+
result = buildResult(false, learningAdjustment.adjusted ? 'weekly-cooldown-adjusted' : 'weekly-cooldown', WEEK, {
1287+
learningAdjustment, nextRunIn: adjustedInterval - timeSinceLastRun,
1288+
nextRunAt: new Date(lastRun + adjustedInterval).toISOString(),
1289+
baseIntervalMs: WEEK, adjustedIntervalMs: adjustedInterval
1290+
});
12561291
}
1257-
return buildResult(false, learningAdjustment.adjusted ? 'weekly-cooldown-adjusted' : 'weekly-cooldown', WEEK, {
1258-
learningAdjustment,
1259-
nextRunIn: adjustedInterval - timeSinceLastRun,
1260-
nextRunAt: new Date(lastRun + adjustedInterval).toISOString(),
1261-
baseIntervalMs: WEEK,
1262-
adjustedIntervalMs: adjustedInterval
1263-
});
1292+
break;
12641293
}
12651294

12661295
case INTERVAL_TYPES.ONCE:
1267-
if (appExecution.count === 0) {
1268-
return { shouldRun: true, reason: 'once-first-run' };
1269-
}
1270-
return { shouldRun: false, reason: 'once-completed', completedAt: appExecution.lastRun };
1296+
result = appExecution.count === 0
1297+
? { shouldRun: true, reason: 'once-first-run' }
1298+
: { shouldRun: false, reason: 'once-completed', completedAt: appExecution.lastRun };
1299+
break;
12711300

12721301
case INTERVAL_TYPES.ON_DEMAND:
1273-
return { shouldRun: false, reason: 'on-demand-only' };
1302+
result = { shouldRun: false, reason: 'on-demand-only' };
1303+
break;
12741304

12751305
case INTERVAL_TYPES.CUSTOM: {
12761306
const baseInterval = interval.intervalMs || DAY;
12771307
const learningAdjustment = await getPerformanceAdjustedInterval(taskType, baseInterval);
12781308
const adjustedInterval = learningAdjustment.adjustedIntervalMs;
1279-
12801309
if (timeSinceLastRun >= adjustedInterval) {
1281-
return buildResult(true, learningAdjustment.adjusted ? 'custom-due-adjusted' : 'custom-due', baseInterval, { learningAdjustment });
1310+
result = buildResult(true, learningAdjustment.adjusted ? 'custom-due-adjusted' : 'custom-due', baseInterval, { learningAdjustment });
1311+
} else {
1312+
result = buildResult(false, learningAdjustment.adjusted ? 'custom-cooldown-adjusted' : 'custom-cooldown', baseInterval, {
1313+
learningAdjustment, nextRunIn: adjustedInterval - timeSinceLastRun,
1314+
nextRunAt: new Date(lastRun + adjustedInterval).toISOString(),
1315+
baseIntervalMs: baseInterval, adjustedIntervalMs: adjustedInterval
1316+
});
12821317
}
1283-
return buildResult(false, learningAdjustment.adjusted ? 'custom-cooldown-adjusted' : 'custom-cooldown', baseInterval, {
1284-
learningAdjustment,
1285-
nextRunIn: adjustedInterval - timeSinceLastRun,
1286-
nextRunAt: new Date(lastRun + adjustedInterval).toISOString(),
1287-
baseIntervalMs: baseInterval,
1288-
adjustedIntervalMs: adjustedInterval
1289-
});
1318+
break;
12901319
}
12911320

12921321
default:
1293-
return { shouldRun: true, reason: 'unknown-default-rotation' };
1322+
result = { shouldRun: true, reason: 'unknown-default-rotation' };
12941323
}
1324+
1325+
// If the task would run, check runAfter dependencies — blocked until all deps have run since our last run
1326+
if (result.shouldRun && interval.runAfter?.length > 0) {
1327+
const depCheck = checkRunAfterDeps(schedule, taskType, appId);
1328+
if (!depCheck.satisfied) {
1329+
return { shouldRun: false, reason: 'waiting-on-dependencies', pendingDeps: depCheck.pending };
1330+
}
1331+
}
1332+
1333+
return result;
12951334
}
12961335

12971336
/**

0 commit comments

Comments
 (0)