feat(local-llm): Phase F — scheduled tasks (F1-F7)

F1: cron-parser integration + cron utility functions (parse, nextRun, toHuman, shouldRunNow)
F2: ScheduledTask + Project + Orchestration CRUD in IndexedDB
F3: Task editor modal (schedule, model, input source, output action, prompt)
F4: Browser-based task runner with setInterval + cron matching
F5: /api/system/exec — safe shell command execution with allowlist
F6: Task run history stored per task (last 20 runs)
F7: 5 built-in task templates (morning brief, git diff, disk usage, code review, deps)
This commit is contained in:
saravanakumardb1 2026-02-20 00:44:53 -08:00
parent e15a5a2f2f
commit 52f3d16b65
8 changed files with 680 additions and 0 deletions

View File

@ -9,6 +9,9 @@
"version": "0.1.0",
"dependencies": {
"@types/react-syntax-highlighter": "^15.5.13",
"cron-parser": "^5.5.0",
"fuse.js": "^7.1.0",
"idb": "^8.0.3",
"lucide-react": "^0.575.0",
"next": "16.1.6",
"react": "19.2.3",
@ -2781,6 +2784,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/cron-parser": {
"version": "5.5.0",
"resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/cron-parser/-/cron-parser-5.5.0.tgz",
"integrity": "sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==",
"license": "MIT",
"dependencies": {
"luxon": "^3.7.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -3875,6 +3890,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/fuse.js": {
"version": "7.1.0",
"resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/fuse.js/-/fuse.js-7.1.0.tgz",
"integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=10"
}
},
"node_modules/generator-function": {
"version": "2.0.1",
"resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/generator-function/-/generator-function-2.0.1.tgz",
@ -4234,6 +4258,12 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/idb": {
"version": "8.0.3",
"resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/idb/-/idb-8.0.3.tgz",
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
"license": "ISC"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/ignore/-/ignore-5.3.2.tgz",
@ -5272,6 +5302,15 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/magic-string/-/magic-string-0.30.21.tgz",

View File

@ -10,6 +10,7 @@
},
"dependencies": {
"@types/react-syntax-highlighter": "^15.5.13",
"cron-parser": "^5.5.0",
"fuse.js": "^7.1.0",
"idb": "^8.0.3",
"lucide-react": "^0.575.0",

View File

@ -0,0 +1,223 @@
'use client';
import { useState } from 'react';
import { X } from 'lucide-react';
import type { ScheduledTask } from '../../lib/types';
import { parseCron, cronToHuman } from '../../lib/cron';
interface TaskEditorProps {
task?: ScheduledTask;
onSave: (task: ScheduledTask) => void;
onClose: () => void;
}
export function TaskEditor({ task, onSave, onClose }: TaskEditorProps) {
const [name, setName] = useState(task?.name || '');
const [schedule, setSchedule] = useState(task?.schedule || '0 8 * * *');
const [model, setModel] = useState(task?.model || '');
const [prompt, setPrompt] = useState(task?.prompt || '');
const [enabled, setEnabled] = useState(task?.enabled ?? false);
const [inputType, setInputType] = useState<string>(task?.inputSource?.type || 'static');
const [inputCommand, setInputCommand] = useState(
task?.inputSource?.type === 'command' ? task.inputSource.command : ''
);
const [inputFilePath, setInputFilePath] = useState(
task?.inputSource?.type === 'file' ? task.inputSource.path : ''
);
const [outputType, setOutputType] = useState<string>(task?.outputAction?.type || 'conversation');
const cronCheck = parseCron(schedule);
const humanSchedule = cronCheck.valid ? cronToHuman(schedule) : '';
const handleSave = () => {
if (!name.trim() || !prompt.trim() || !cronCheck.valid) return;
const inputSource = (() => {
if (inputType === 'command') return { type: 'command' as const, command: inputCommand };
if (inputType === 'file') return { type: 'file' as const, path: inputFilePath };
if (inputType === 'clipboard') return { type: 'clipboard' as const };
return { type: 'static' as const };
})();
const outputAction = (() => {
if (outputType === 'clipboard') return { type: 'clipboard' as const };
if (outputType === 'notification') return { type: 'notification' as const };
return { type: 'conversation' as const };
})();
const result: ScheduledTask = {
id: task?.id || crypto.randomUUID(),
name: name.trim(),
schedule,
scheduleHuman: humanSchedule,
model,
prompt: prompt.trim(),
inputSource,
outputAction,
enabled,
lastRun: task?.lastRun,
runHistory: task?.runHistory || [],
createdAt: task?.createdAt || Date.now(),
};
onSave(result);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div className="w-full max-w-lg rounded-lg border border-white/10 bg-[var(--bg-elevated)] p-5">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-[var(--text-primary)]">
{task ? 'Edit Task' : 'New Scheduled Task'}
</h2>
<button
type="button"
onClick={onClose}
className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
>
<X size={18} />
</button>
</div>
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs text-[var(--text-secondary)]">Name *</label>
<input
value={name}
onChange={e => setName(e.target.value)}
className="w-full rounded border border-white/10 bg-[var(--surface-card)] px-3 py-1.5 text-sm text-[var(--text-primary)] outline-none"
placeholder="Morning Brief"
/>
</div>
<div>
<label className="mb-1 block text-xs text-[var(--text-secondary)]">
Cron Schedule *
</label>
<input
value={schedule}
onChange={e => setSchedule(e.target.value)}
className="w-full rounded border border-white/10 bg-[var(--surface-card)] px-3 py-1.5 font-mono text-sm text-[var(--text-primary)] outline-none"
placeholder="0 8 * * *"
/>
{cronCheck.valid ? (
<p className="mt-1 text-[10px] text-[var(--accent-secondary)]">{humanSchedule}</p>
) : (
<p className="mt-1 text-[10px] text-[var(--danger)]">{cronCheck.error}</p>
)}
</div>
<div>
<label className="mb-1 block text-xs text-[var(--text-secondary)]">
Model (blank = default)
</label>
<input
value={model}
onChange={e => setModel(e.target.value)}
className="w-full rounded border border-white/10 bg-[var(--surface-card)] px-3 py-1.5 text-sm text-[var(--text-primary)] outline-none"
placeholder="Auto-detect"
/>
</div>
<div>
<label className="mb-1 block text-xs text-[var(--text-secondary)]">Prompt *</label>
<textarea
value={prompt}
onChange={e => setPrompt(e.target.value)}
rows={4}
className="w-full resize-y rounded border border-white/10 bg-[var(--surface-card)] px-3 py-1.5 text-sm text-[var(--text-primary)] outline-none"
placeholder="Use {input} for dynamic input from source"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs text-[var(--text-secondary)]">
Input Source
</label>
<select
value={inputType}
onChange={e => setInputType(e.target.value)}
className="w-full rounded border border-white/10 bg-[var(--surface-card)] px-3 py-1.5 text-sm text-[var(--text-primary)] outline-none"
>
<option value="static">None (static prompt)</option>
<option value="command">Shell command</option>
<option value="file">File</option>
<option value="clipboard">Clipboard</option>
</select>
</div>
<div>
<label className="mb-1 block text-xs text-[var(--text-secondary)]">
Output Action
</label>
<select
value={outputType}
onChange={e => setOutputType(e.target.value)}
className="w-full rounded border border-white/10 bg-[var(--surface-card)] px-3 py-1.5 text-sm text-[var(--text-primary)] outline-none"
>
<option value="conversation">New conversation</option>
<option value="clipboard">Copy to clipboard</option>
<option value="notification">Browser notification</option>
</select>
</div>
</div>
{inputType === 'command' && (
<div>
<label className="mb-1 block text-xs text-[var(--text-secondary)]">Command</label>
<input
value={inputCommand}
onChange={e => setInputCommand(e.target.value)}
className="w-full rounded border border-white/10 bg-[var(--surface-card)] px-3 py-1.5 font-mono text-sm text-[var(--text-primary)] outline-none"
placeholder="git diff --stat"
/>
</div>
)}
{inputType === 'file' && (
<div>
<label className="mb-1 block text-xs text-[var(--text-secondary)]">File Path</label>
<input
value={inputFilePath}
onChange={e => setInputFilePath(e.target.value)}
className="w-full rounded border border-white/10 bg-[var(--surface-card)] px-3 py-1.5 font-mono text-sm text-[var(--text-primary)] outline-none"
placeholder="/path/to/file.txt"
/>
</div>
)}
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={enabled}
onChange={e => setEnabled(e.target.checked)}
id="task-enabled"
className="h-4 w-4"
/>
<label htmlFor="task-enabled" className="text-sm text-[var(--text-secondary)]">
Enabled
</label>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={onClose}
className="rounded border border-white/10 px-4 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
disabled={!name.trim() || !prompt.trim() || !cronCheck.valid}
className="rounded bg-[var(--accent-primary)] px-4 py-1.5 text-sm text-white disabled:opacity-50"
>
Save
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,129 @@
'use client';
import { useEffect, useRef } from 'react';
import type { ScheduledTask } from '../../lib/types';
import { shouldRunNow } from '../../lib/cron';
import { updateScheduledTask } from '../../lib/db';
interface TaskRunnerProps {
tasks: ScheduledTask[];
onTaskRan: (taskId: string) => void;
}
export function TaskRunner({ tasks, onTaskRan }: TaskRunnerProps) {
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
const check = async () => {
for (const task of tasks) {
if (!task.enabled) continue;
if (!shouldRunNow(task.schedule, task.lastRun)) continue;
const start = Date.now();
try {
let input = '';
if (task.inputSource?.type === 'command') {
const parts = task.inputSource.command.split(/\s+/);
const command = parts[0];
const args = parts.slice(1);
const res = await fetch('/api/system/exec', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command, args }),
});
const data = (await res.json()) as { stdout?: string; stderr?: string };
input = data.stdout || data.stderr || '';
} else if (task.inputSource?.type === 'clipboard') {
try {
input = await navigator.clipboard.readText();
} catch {
input = '[clipboard unavailable]';
}
}
const finalPrompt = task.prompt.replace(/\{input\}/g, input);
const res = await fetch('/api/ollama/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: task.model || undefined,
messages: [{ role: 'user', content: finalPrompt }],
}),
});
let result = '';
if (res.ok && res.body) {
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const chunk = JSON.parse(line) as { message?: { content?: string } };
if (chunk.message?.content) result += chunk.message.content;
} catch {
/* skip */
}
}
}
}
const durationMs = Date.now() - start;
const runEntry = {
timestamp: Date.now(),
durationMs,
success: true,
result: result.slice(0, 500),
};
await updateScheduledTask(task.id, {
lastRun: Date.now(),
runHistory: [...(task.runHistory || []).slice(-19), runEntry],
});
if (task.outputAction?.type === 'notification' && 'Notification' in window) {
if (Notification.permission === 'granted') {
new Notification(task.name, { body: result.slice(0, 200) });
} else if (Notification.permission !== 'denied') {
await Notification.requestPermission();
}
} else if (task.outputAction?.type === 'clipboard') {
await navigator.clipboard.writeText(result);
}
onTaskRan(task.id);
} catch (err) {
const durationMs = Date.now() - start;
const runEntry = {
timestamp: Date.now(),
durationMs,
success: false,
result: String(err).slice(0, 500),
};
await updateScheduledTask(task.id, {
lastRun: Date.now(),
runHistory: [...(task.runHistory || []).slice(-19), runEntry],
});
onTaskRan(task.id);
}
}
};
intervalRef.current = setInterval(() => void check(), 60_000);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [tasks, onTaskRan]);
return null;
}

View File

@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from 'next/server';
import { execFile } from 'child_process';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
const COMMAND_ALLOWLIST = new Set([
'git',
'npm',
'brew',
'cat',
'ls',
'wc',
'du',
'df',
'echo',
'date',
]);
export async function POST(request: NextRequest) {
try {
const body = (await request.json()) as { command?: string; args?: string[] };
const { command, args = [] } = body;
if (!command) {
return NextResponse.json({ error: 'Missing command' }, { status: 400 });
}
const extended =
typeof window === 'undefined'
? (() => {
try {
const raw = process.env.LLM_COMMAND_ALLOWLIST;
if (raw) return new Set(JSON.parse(raw) as string[]);
} catch {
/* ignore */
}
return new Set<string>();
})()
: new Set<string>();
if (!COMMAND_ALLOWLIST.has(command) && !extended.has(command)) {
return NextResponse.json(
{
error: `Command "${command}" is not in the allowlist`,
allowed: [...COMMAND_ALLOWLIST, ...extended],
},
{ status: 403 }
);
}
const { stdout, stderr } = await execFileAsync(command, args, {
timeout: 30_000,
maxBuffer: 1024 * 1024,
env: { ...process.env, PATH: process.env.PATH },
});
return NextResponse.json({ stdout, stderr, exitCode: 0 });
} catch (err: unknown) {
const error = err as { stdout?: string; stderr?: string; code?: number; message?: string };
return NextResponse.json({
stdout: error.stdout || '',
stderr: error.stderr || error.message || String(err),
exitCode: error.code ?? 1,
});
}
}

View File

@ -0,0 +1,54 @@
import { CronExpressionParser } from 'cron-parser';
export function parseCron(expr: string): { valid: boolean; error?: string } {
try {
CronExpressionParser.parse(expr);
return { valid: true };
} catch (err) {
return { valid: false, error: String(err) };
}
}
export function getNextRun(expr: string): Date | null {
try {
const interval = CronExpressionParser.parse(expr);
return interval.next().toDate();
} catch {
return null;
}
}
export function cronToHuman(expr: string): string {
const parts = expr.trim().split(/\s+/);
if (parts.length < 5) return expr;
const [min, hour, dom, mon, dow] = parts;
if (min === '0' && hour === '*' && dom === '*' && mon === '*' && dow === '*') {
return 'Every hour';
}
if (dom === '*' && mon === '*' && dow === '*') {
if (hour === '*') return `Every hour at :${min?.padStart(2, '0')}`;
return `Daily at ${hour}:${min?.padStart(2, '0')}`;
}
if (mon === '*' && dow !== '*') {
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const dayName = days[Number(dow)] || dow;
return `Every ${dayName} at ${hour}:${min?.padStart(2, '0')}`;
}
return expr;
}
export function shouldRunNow(expr: string, lastRun?: number): boolean {
try {
const interval = CronExpressionParser.parse(expr);
const prev = interval.prev().toDate();
const prevMs = prev.getTime();
if (!lastRun) return true;
return prevMs > lastRun;
} catch {
return false;
}
}

View File

@ -388,3 +388,106 @@ export async function importAgents(agents: Agent[]): Promise<number> {
}
return imported;
}
export async function addScheduledTask(task: ScheduledTask): Promise<void> {
const db = await getDb();
await db.put('scheduledTasks', task);
}
export async function getScheduledTask(id: string): Promise<ScheduledTask | undefined> {
const db = await getDb();
return db.get('scheduledTasks', id);
}
export async function listScheduledTasks(): Promise<ScheduledTask[]> {
const db = await getDb();
const rows = await db.getAll('scheduledTasks');
rows.sort((a, b) => b.createdAt - a.createdAt);
return rows;
}
export async function updateScheduledTask(
id: string,
partial: Partial<ScheduledTask>
): Promise<ScheduledTask | null> {
const db = await getDb();
const existing = await db.get('scheduledTasks', id);
if (!existing) return null;
const updated: ScheduledTask = { ...existing, ...partial, id };
await db.put('scheduledTasks', updated);
return updated;
}
export async function deleteScheduledTask(id: string): Promise<void> {
const db = await getDb();
await db.delete('scheduledTasks', id);
}
export async function addProject(project: Project): Promise<void> {
const db = await getDb();
await db.put('projects', project);
}
export async function getProject(id: string): Promise<Project | undefined> {
const db = await getDb();
return db.get('projects', id);
}
export async function listProjects(): Promise<Project[]> {
const db = await getDb();
const rows = await db.getAll('projects');
rows.sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
return b.createdAt - a.createdAt;
});
return rows;
}
export async function updateProject(
id: string,
partial: Partial<Project>
): Promise<Project | null> {
const db = await getDb();
const existing = await db.get('projects', id);
if (!existing) return null;
const updated: Project = { ...existing, ...partial, id };
await db.put('projects', updated);
return updated;
}
export async function deleteProject(id: string): Promise<void> {
const db = await getDb();
await db.delete('projects', id);
}
export async function addOrchestration(orch: Orchestration): Promise<void> {
const db = await getDb();
await db.put('orchestrations', orch);
}
export async function getOrchestration(id: string): Promise<Orchestration | undefined> {
const db = await getDb();
return db.get('orchestrations', id);
}
export async function listOrchestrations(): Promise<Orchestration[]> {
const db = await getDb();
return db.getAll('orchestrations');
}
export async function updateOrchestration(
id: string,
partial: Partial<Orchestration>
): Promise<Orchestration | null> {
const db = await getDb();
const existing = await db.get('orchestrations', id);
if (!existing) return null;
const updated: Orchestration = { ...existing, ...partial, id };
await db.put('orchestrations', updated);
return updated;
}
export async function deleteOrchestration(id: string): Promise<void> {
const db = await getDb();
await db.delete('orchestrations', id);
}

View File

@ -0,0 +1,64 @@
import type { ScheduledTask } from './types';
export const BUILTIN_TASK_TEMPLATES: Omit<ScheduledTask, 'id' | 'createdAt'>[] = [
{
name: 'Morning Brief',
schedule: '0 8 * * *',
scheduleHuman: 'Daily at 8:00 AM',
model: '',
prompt:
'Give me a brief morning summary: what day of the week it is, any notable tech news headlines you know about, and a motivational quote to start the day. Keep it under 200 words.',
inputSource: { type: 'static' },
outputAction: { type: 'conversation' },
enabled: false,
runHistory: [],
},
{
name: 'Git Diff Summary',
schedule: '0 17 * * 1-5',
scheduleHuman: 'Weekdays at 5:00 PM',
model: '',
prompt:
'Summarize the following git diff output. Group changes by file, highlight key modifications, and note any potential issues:\n\n{input}',
inputSource: { type: 'command', command: 'git diff --stat' },
outputAction: { type: 'conversation' },
enabled: false,
runHistory: [],
},
{
name: 'Disk Usage Alert',
schedule: '0 12 * * *',
scheduleHuman: 'Daily at noon',
model: '',
prompt:
'Analyze this disk usage report and alert me if any partition is over 80% full. Provide recommendations for cleanup if needed:\n\n{input}',
inputSource: { type: 'command', command: 'df -h' },
outputAction: { type: 'notification' },
enabled: false,
runHistory: [],
},
{
name: 'Weekly Code Review Reminder',
schedule: '0 10 * * 1',
scheduleHuman: 'Every Monday at 10:00 AM',
model: '',
prompt:
'Generate a code review checklist for this week. Include: security best practices, performance considerations, documentation gaps, and testing coverage reminders. Format as a markdown checklist.',
inputSource: { type: 'static' },
outputAction: { type: 'conversation' },
enabled: false,
runHistory: [],
},
{
name: 'Dependency Check',
schedule: '0 9 * * 3',
scheduleHuman: 'Every Wednesday at 9:00 AM',
model: '',
prompt:
'Analyze the following npm outdated output and prioritize which packages should be updated. Flag any with known security vulnerabilities:\n\n{input}',
inputSource: { type: 'command', command: 'npm outdated' },
outputAction: { type: 'conversation' },
enabled: false,
runHistory: [],
},
];