diff --git a/desktop/src/api/tasks.ts b/desktop/src/api/tasks.ts index 98921770..dd8cf2d2 100644 --- a/desktop/src/api/tasks.ts +++ b/desktop/src/api/tasks.ts @@ -22,6 +22,10 @@ export const tasksApi = { return api.delete<{ ok: true }>(`/api/scheduled-tasks/${id}`) }, + runTask(id: string) { + return api.post<{ ok: true }>(`/api/scheduled-tasks/${id}/run`, {}) + }, + getRecentRuns(limit = 50) { return api.get(`/api/scheduled-tasks/runs?limit=${limit}`) }, diff --git a/desktop/src/components/tasks/DayOfWeekPicker.tsx b/desktop/src/components/tasks/DayOfWeekPicker.tsx new file mode 100644 index 00000000..6a9ef7ea --- /dev/null +++ b/desktop/src/components/tasks/DayOfWeekPicker.tsx @@ -0,0 +1,57 @@ +import { useTranslation } from '../../i18n' + +type Props = { + selected: number[] + onChange: (days: number[]) => void +} + +// Display order: Mon(1) → Sun(0), matching Chinese convention +const DAY_ORDER = [1, 2, 3, 4, 5, 6, 0] + +const DAY_KEYS = [ + 'newTask.daySun', + 'newTask.dayMon', + 'newTask.dayTue', + 'newTask.dayWed', + 'newTask.dayThu', + 'newTask.dayFri', + 'newTask.daySat', +] as const + +export function DayOfWeekPicker({ selected, onChange }: Props) { + const t = useTranslation() + + const toggle = (day: number) => { + if (selected.includes(day)) { + // Don't allow deselecting the last day + if (selected.length <= 1) return + onChange(selected.filter((d) => d !== day)) + } else { + onChange([...selected, day]) + } + } + + return ( +
+ {DAY_ORDER.map((day) => { + const isActive = selected.includes(day) + return ( + + ) + })} +
+ ) +} diff --git a/desktop/src/components/tasks/NewTaskModal.tsx b/desktop/src/components/tasks/NewTaskModal.tsx index 2b65ff95..de3b0b32 100644 --- a/desktop/src/components/tasks/NewTaskModal.tsx +++ b/desktop/src/components/tasks/NewTaskModal.tsx @@ -5,100 +5,146 @@ import { Modal } from '../shared/Modal' import { Input } from '../shared/Input' import { Button } from '../shared/Button' import { PromptEditor } from './PromptEditor' +import { DayOfWeekPicker } from './DayOfWeekPicker' import { useTranslation } from '../../i18n' +import { describeCron, isValidCron, parseCron, type FrequencyKey } from '../../lib/cronDescribe' import type { PermissionMode } from '../../types/settings' +import type { CronTask } from '../../types/task' type Props = { open: boolean onClose: () => void + editTask?: CronTask } -type FrequencyKey = 'hourly' | 'daily' | 'weekdays' | 'weekly' | 'monthly' +const MINUTE_INTERVALS = [5, 10, 15, 20, 30] +const HOUR_INTERVALS = [1, 2, 3, 4, 6, 8, 12] +const MINUTE_OFFSETS = [0, 15, 30, 45] -function buildCron(freq: FrequencyKey, time: string): string { +function buildCron( + freq: FrequencyKey, + time: string, + opts: { + minuteInterval: number + hourInterval: number + minuteOffset: number + selectedDays: number[] + monthDay: number + customCron: string + }, +): string { const [hours, minutes] = time.split(':').map(Number) switch (freq) { - case 'hourly': return '0 * * * *' - case 'daily': return `${minutes} ${hours} * * *` - case 'weekdays': return `${minutes} ${hours} * * 1-5` - case 'weekly': return `${minutes} ${hours} * * 1` - case 'monthly': return `${minutes} ${hours} 1 * *` + case 'everyNMinutes': + return `*/${opts.minuteInterval} * * * *` + case 'everyNHours': + return `${opts.minuteOffset} */${opts.hourInterval} * * *` + case 'daily': + return `${minutes} ${hours} * * *` + case 'weekdays': + return `${minutes} ${hours} * * 1-5` + case 'specificDays': + return `${minutes} ${hours} * * ${[...opts.selectedDays].sort((a, b) => a - b).join(',')}` + case 'monthly': + return `${minutes} ${hours} ${opts.monthDay} * *` + case 'customCron': + return opts.customCron.trim() } } -export function NewTaskModal({ open, onClose }: Props) { +export function NewTaskModal({ open, onClose, editTask }: Props) { const t = useTranslation() - const { createTask } = useTaskStore() + const { createTask, updateTask } = useTaskStore() const sessions = useSessionStore((s) => s.sessions) const activeSessionId = useSessionStore((s) => s.activeSessionId) const activeSession = sessions.find((s) => s.id === activeSessionId) const defaultWorkDir = activeSession?.workDir || '' - const FREQUENCY_OPTIONS: Array<{ value: FrequencyKey; label: string; showTime: boolean }> = [ - { value: 'hourly', label: t('newTask.hourly'), showTime: false }, - { value: 'daily', label: t('newTask.daily'), showTime: true }, - { value: 'weekdays', label: t('newTask.weekdays'), showTime: true }, - { value: 'weekly', label: t('newTask.weekly'), showTime: true }, - { value: 'monthly', label: t('newTask.monthly'), showTime: true }, + const isEdit = !!editTask + const parsed = editTask ? parseCron(editTask.cron) : null + + const FREQUENCY_OPTIONS: Array<{ value: FrequencyKey; label: string }> = [ + { value: 'everyNMinutes', label: t('newTask.everyNMinutes') }, + { value: 'everyNHours', label: t('newTask.everyNHours') }, + { value: 'daily', label: t('newTask.daily') }, + { value: 'weekdays', label: t('newTask.weekdays') }, + { value: 'specificDays', label: t('newTask.specificDays') }, + { value: 'monthly', label: t('newTask.monthly') }, + { value: 'customCron', label: t('newTask.customCron') }, ] - const [name, setName] = useState('') - const [description, setDescription] = useState('') - const [prompt, setPrompt] = useState('') - const [frequency, setFrequency] = useState('daily') - const [time, setTime] = useState('09:00') - const [model, setModel] = useState('') - const [permissionMode, setPermissionMode] = useState('default') - const [folderPath, setFolderPath] = useState(defaultWorkDir) - const [useWorktree, setUseWorktree] = useState(false) + const [name, setName] = useState(editTask?.name || '') + const [description, setDescription] = useState(editTask?.description || '') + const [prompt, setPrompt] = useState(editTask?.prompt || '') + const [frequency, setFrequency] = useState(parsed?.frequency || 'daily') + const [time, setTime] = useState(parsed?.time || '09:00') + const [model, setModel] = useState(editTask?.model || '') + const [permissionMode, setPermissionMode] = useState((editTask?.permissionMode as PermissionMode) || 'default') + const [folderPath, setFolderPath] = useState(editTask?.folderPath || defaultWorkDir) + const [useWorktree, setUseWorktree] = useState(editTask?.useWorktree || false) const [isSubmitting, setIsSubmitting] = useState(false) - const canSubmit = name.trim() && description.trim() && prompt.trim() - const showTime = FREQUENCY_OPTIONS.find((o) => o.value === frequency)?.showTime ?? false + // Enhanced scheduling state + const [minuteInterval, setMinuteInterval] = useState(parsed?.minuteInterval || 15) + const [hourInterval, setHourInterval] = useState(parsed?.hourInterval || 1) + const [minuteOffset, setMinuteOffset] = useState(parsed?.minuteOffset || 0) + const [selectedDays, setSelectedDays] = useState(parsed?.selectedDays || [1]) + const [monthDay, setMonthDay] = useState(parsed?.monthDay || 1) + const [customCron, setCustomCron] = useState(parsed?.customCron || '0 9 * * *') + + const showTime = ['daily', 'weekdays', 'specificDays', 'monthly'].includes(frequency) + + const cronValue = buildCron(frequency, time, { + minuteInterval, hourInterval, minuteOffset, selectedDays, monthDay, customCron, + }) + + const canSubmit = + name.trim() && + description.trim() && + prompt.trim() && + (frequency !== 'customCron' || isValidCron(customCron)) && + (frequency !== 'specificDays' || selectedDays.length > 0) const handleSubmit = async () => { if (!canSubmit) return setIsSubmitting(true) try { - await createTask({ + const payload = { name: name.trim(), description: description.trim(), - cron: buildCron(frequency, time), + cron: cronValue, prompt: prompt.trim(), - enabled: true, - recurring: true, model: model || undefined, permissionMode: permissionMode !== 'default' ? permissionMode : undefined, folderPath: folderPath.trim() || undefined, useWorktree: useWorktree || undefined, - }) - // Reset form - setName('') - setDescription('') - setPrompt('') - setFrequency('daily') - setTime('09:00') - setModel('') - setPermissionMode('default') - setFolderPath('') - setUseWorktree(false) + } + if (isEdit) { + await updateTask(editTask!.id, payload) + } else { + await createTask({ ...payload, enabled: true, recurring: true }) + } onClose() } catch (err) { - console.error('Failed to create task:', err) + console.error(`Failed to ${isEdit ? 'update' : 'create'} task:`, err) } finally { setIsSubmitting(false) } } + const selectClass = 'w-full h-10 px-3 pr-8 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-sm text-[var(--color-text-primary)] outline-none focus:border-[var(--color-border-focus)] appearance-none cursor-pointer' + return ( - + } > @@ -149,7 +195,7 @@ export function NewTaskModal({ open, onClose }: Props) { setMinuteInterval(Number(e.target.value))} + className={selectClass} + > + {MINUTE_INTERVALS.map((n) => ( + + ))} + + + expand_more + + + )} + + {frequency === 'everyNHours' && ( +
+
+ + + expand_more + +
+
+ + + expand_more + +
+
+ )} + + {frequency === 'specificDays' && ( + + )} + + {frequency === 'monthly' && ( +
+ + + expand_more + +
+ )} + + {frequency === 'customCron' && ( +
+ setCustomCron(e.target.value)} + placeholder={t('newTask.cronFormatHint')} + className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-sm text-[var(--color-text-primary)] font-[var(--font-mono)] outline-none focus:border-[var(--color-border-focus)]" + /> + {t('newTask.cronFormatHint')} + {customCron.trim() && !isValidCron(customCron) && ( + {t('newTask.invalidCron')} + )} +
+ )} + + {/* Time picker — shown for daily, weekdays, specificDays, monthly */} {showTime && (
)} + {/* Cron preview */} +
+ schedule + + {frequency === 'customCron' && customCron.trim() && !isValidCron(customCron) + ? t('newTask.invalidCron') + : describeCron(cronValue, t) + } + +
+

{t('newTask.delayNote')}

diff --git a/desktop/src/components/tasks/TaskList.tsx b/desktop/src/components/tasks/TaskList.tsx index 26c251c4..09c9bb26 100644 --- a/desktop/src/components/tasks/TaskList.tsx +++ b/desktop/src/components/tasks/TaskList.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import type { CronTask } from '../../types/task' import { TaskRow } from './TaskRow' import { useTranslation } from '../../i18n' @@ -9,6 +10,7 @@ type Props = { export function TaskList({ tasks }: Props) { const t = useTranslation() const enabledCount = tasks.filter((task) => task.enabled).length + const [expandedLogsId, setExpandedLogsId] = useState(null) return (
@@ -19,10 +21,15 @@ export function TaskList({ tasks }: Props) {
- {/* Task rows */} + {/* Task rows — accordion: only one logs panel open at a time */}
{tasks.map((task) => ( - + setExpandedLogsId(expandedLogsId === task.id ? null : task.id)} + /> ))}
diff --git a/desktop/src/components/tasks/TaskRow.tsx b/desktop/src/components/tasks/TaskRow.tsx index 4c92c8fb..6874dfdc 100644 --- a/desktop/src/components/tasks/TaskRow.tsx +++ b/desktop/src/components/tasks/TaskRow.tsx @@ -1,52 +1,250 @@ +import { useState, useRef, useEffect } from 'react' import type { CronTask } from '../../types/task' import { useTaskStore } from '../../stores/taskStore' import { useTranslation } from '../../i18n' +import { describeCron } from '../../lib/cronDescribe' +import { TaskRunsPanel } from './TaskRunsPanel' +import { NewTaskModal } from './NewTaskModal' type Props = { task: CronTask + showLogs: boolean + onToggleLogs: () => void } -export function TaskRow({ task }: Props) { - const { deleteTask, updateTask } = useTaskStore() - const t = useTranslation() +type ConfirmAction = 'run' | 'toggle' | 'delete' | null - const toggleEnabled = () => { +export function TaskRow({ task, showLogs, onToggleLogs }: Props) { + const { deleteTask, updateTask, runTask } = useTaskStore() + const t = useTranslation() + const [showEdit, setShowEdit] = useState(false) + const [showMenu, setShowMenu] = useState(false) + const [isRunning, setIsRunning] = useState(false) + const [confirmAction, setConfirmAction] = useState(null) + const [logsRefreshKey, setLogsRefreshKey] = useState(0) + const menuRef = useRef(null) + const confirmRef = useRef(null) + + // Close menu / confirm on outside click + useEffect(() => { + if (!showMenu && !confirmAction) return + const handler = (e: MouseEvent) => { + const target = e.target as Node + if (showMenu && menuRef.current && !menuRef.current.contains(target)) { + setShowMenu(false) + } + if (confirmAction && confirmRef.current && !confirmRef.current.contains(target)) { + setConfirmAction(null) + } + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [showMenu, confirmAction]) + + const handleRunNow = async () => { + setConfirmAction(null) + setIsRunning(true) + if (!showLogs) onToggleLogs() // open logs panel (accordion will close others) + try { + await runTask(task.id) + setLogsRefreshKey((k) => k + 1) + } catch (err) { + console.error('Failed to run task:', err) + } finally { + setIsRunning(false) + } + } + + const handleToggle = () => { + setConfirmAction(null) + setShowMenu(false) updateTask(task.id, { enabled: !task.enabled }) } - return ( -
-
- {/* Status indicator */} - + const handleDelete = () => { + setConfirmAction(null) + setShowMenu(false) + deleteTask(task.id) + } -
-
{task.name}
- {task.description && ( -
{task.description}
- )} + const iconBtn = 'p-1.5 rounded-[var(--radius-sm)] transition-colors' + const menuItem = 'flex items-center gap-2.5 w-full px-3 py-2 text-xs text-left rounded-[var(--radius-sm)] transition-colors' + + return ( +
+
+ {/* Left: status + info */} +
+ +
+
{task.name}
+ {task.description && ( +
{task.description}
+ )} +
+ {t('tasks.createdAt')}{new Date(task.createdAt).toLocaleDateString()} + {task.lastFiredAt && ( + {t('tasks.lastRunAt')}{new Date(task.lastFiredAt).toLocaleDateString()} + )} +
+
+
+ + {/* Right: cron + actions */} +
+ + {describeCron(task.cron, t)} + + +
+ {/* Run Now */} +
+ + {confirmAction === 'run' && ( + setConfirmAction(null)} + cancelLabel={t('common.cancel')} + /> + )} +
+ + {/* View Logs */} + + + {/* More menu */} +
+ + + {showMenu && !confirmAction && ( +
+ {/* Edit */} + + + {/* Toggle */} + + +
+ + {/* Delete */} + +
+ )} + + {/* Confirm popovers for menu actions */} + {confirmAction === 'toggle' && ( +
+ { setConfirmAction(null); setShowMenu(false) }} + cancelLabel={t('common.cancel')} + /> +
+ )} + {confirmAction === 'delete' && ( +
+ { setConfirmAction(null); setShowMenu(false) }} + cancelLabel={t('common.cancel')} + variant="error" + /> +
+ )} +
+
-
- {/* Cron expression */} - {task.cron} - - {/* Actions */} -
- - + {/* Runs panel */} + {showLogs && ( +
+
+ )} + + {/* Edit modal */} + {showEdit && ( + setShowEdit(false)} /> + )} +
+ ) +} + +// ─── Confirm Popover ───────────────────────────────────────────────────────── + +function ConfirmPopover({ message, confirmLabel, onConfirm, onCancel, cancelLabel, variant = 'brand' }: { + message: string + confirmLabel: string + onConfirm: () => void + onCancel: () => void + cancelLabel: string + variant?: 'brand' | 'error' +}) { + return ( +
+

{message}

+
+ +
) diff --git a/desktop/src/components/tasks/TaskRunsPanel.tsx b/desktop/src/components/tasks/TaskRunsPanel.tsx new file mode 100644 index 00000000..026871b0 --- /dev/null +++ b/desktop/src/components/tasks/TaskRunsPanel.tsx @@ -0,0 +1,198 @@ +import { useEffect, useState } from 'react' +import { useTaskStore } from '../../stores/taskStore' +import { useSessionStore } from '../../stores/sessionStore' +import { useChatStore } from '../../stores/chatStore' +import { useUIStore } from '../../stores/uiStore' +import { useTranslation } from '../../i18n' +import { parseRunOutput } from '../../lib/parseRunOutput' +import type { TaskRun } from '../../types/task' + +function RunOutput({ run }: { run: TaskRun }) { + const t = useTranslation() + + // Show error prominently if present + if (run.error) { + return ( +
+ {run.error} +
+ ) + } + + const text = parseRunOutput(run.output || '') + + if (!text) { + return ( +
+ {run.sessionId ? t('tasks.outputHintSession') : t('tasks.noOutputText')} +
+ ) + } + + // Render AI text response with proper formatting (not monospace
)
+  return (
+    
+ {text} +
+ ) +} + +type Props = { + taskId: string + onClose: () => void + refreshKey?: number +} + +const STATUS_CONFIG: Record = { + running: { icon: 'sync', color: 'var(--color-warning)' }, + completed: { icon: 'check_circle', color: 'var(--color-success)' }, + failed: { icon: 'error', color: 'var(--color-error)' }, + timeout: { icon: 'timer_off', color: 'var(--color-error)' }, +} + +export function TaskRunsPanel({ taskId, onClose, refreshKey }: Props) { + const t = useTranslation() + const { fetchTaskRuns } = useTaskStore() + const setActiveSession = useSessionStore((s) => s.setActiveSession) + const connectToSession = useChatStore((s) => s.connectToSession) + const setActiveView = useUIStore((s) => s.setActiveView) + const [runs, setRuns] = useState([]) + const [loading, setLoading] = useState(true) + const [expandedId, setExpandedId] = useState(null) + + const openSession = (sessionId: string) => { + setActiveView('code') + setActiveSession(sessionId) + connectToSession(sessionId) + } + + const refresh = () => { + fetchTaskRuns(taskId).then((r) => { + setRuns(r) + setLoading(false) + }).catch(() => setLoading(false)) + } + + // Initial fetch + re-fetch when refreshKey changes + useEffect(() => { + setLoading(true) + refresh() + }, [taskId, fetchTaskRuns, refreshKey]) + + // Auto-poll while any run is "running" or shortly after a manual trigger. + // Uses faster 1s polling for the first 10s after refreshKey changes, then 3s. + const hasRunning = runs.some((r) => r.status === 'running') + useEffect(() => { + if (!hasRunning && refreshKey === 0) return // no reason to poll initially + // Start with fast polling (1s) to give snappy feedback after "Run Now" + let interval = 1000 + let timer = setInterval(refresh, interval) + // After 10s, switch to slower 3s polling if still running + const slowDown = setTimeout(() => { + clearInterval(timer) + if (hasRunning) { + timer = setInterval(refresh, 3000) + } + }, 10000) + // If nothing is running and initial window passes, stop entirely + const stopTimer = hasRunning ? undefined : setTimeout(() => clearInterval(timer), 12000) + return () => { + clearInterval(timer) + clearTimeout(slowDown) + if (stopTimer) clearTimeout(stopTimer) + } + }, [hasRunning, taskId, refreshKey]) + + return ( +
+ {/* Header */} +
+ {t('tasks.logsTitle')} + +
+ + {/* Content */} +
+ {loading ? ( +
+
+
+ ) : runs.length === 0 ? ( +
+ {t('tasks.noLogs')} +
+ ) : ( +
+ {runs.map((run) => { + const cfg = STATUS_CONFIG[run.status] || STATUS_CONFIG.failed! + const isExpanded = expandedId === run.id + return ( +
+
+ {/* Status icon */} + + {cfg.icon} + + + {/* Status text */} + + {t(`tasks.runStatus.${run.status}` as any)} + + + {/* Time */} + + {new Date(run.startedAt).toLocaleString()} + + + {/* Duration */} + {run.durationMs != null && ( + + {t('tasks.duration', { s: Math.round(run.durationMs / 1000) })} + + )} + +
+ {/* Open session — only after run completes (session is empty while running) */} + {run.sessionId && run.status !== 'running' && ( + + )} + + {/* Summary toggle */} + {(run.output || run.error) && ( + + )} +
+
+ + {/* Expanded output */} + {isExpanded && ( + + )} +
+ ) + })} +
+ )} +
+
+ ) +} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 7b8460bb..ba39e30a 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -95,6 +95,35 @@ export const en = { 'settings.permissions.bypass': 'Bypass all', 'settings.permissions.bypassDesc': 'Skip all permission checks (dangerous)', + // Settings > Adapters + 'settings.tab.adapters': 'IM Adapters', + 'settings.adapters.description': 'Configure IM adapters to chat with Claude Code via Telegram or Feishu.', + 'settings.adapters.telegram': 'Telegram', + 'settings.adapters.feishu': 'Feishu', + 'settings.adapters.botToken': 'Bot Token', + 'settings.adapters.botTokenPlaceholder': 'Paste token from @BotFather', + 'settings.adapters.appId': 'App ID', + 'settings.adapters.appIdPlaceholder': 'e.g. cli_xxx', + 'settings.adapters.appSecret': 'App Secret', + 'settings.adapters.appSecretPlaceholder': 'From Feishu Open Platform', + 'settings.adapters.encryptKey': 'Encrypt Key', + 'settings.adapters.encryptKeyPlaceholder': 'Optional', + 'settings.adapters.verificationToken': 'Verification Token', + 'settings.adapters.verificationTokenPlaceholder': 'Optional', + 'settings.adapters.allowedUsers': 'Allowed Users', + 'settings.adapters.allowedUsersHint': 'Comma-separated. Leave empty to allow everyone.', + 'settings.adapters.tgAllowedUsersPlaceholder': 'e.g. 123456789, 987654321', + 'settings.adapters.fsAllowedUsersPlaceholder': 'e.g. ou_xxx, ou_yyy', + 'settings.adapters.workDir': 'Working Directory', + 'settings.adapters.streamingCard': 'Streaming Card Mode', + 'settings.adapters.streamingCardDesc': 'Real-time card updates for better experience', + 'settings.adapters.serverUrl': 'Server URL', + 'settings.adapters.serverUrlPlaceholder': 'ws://127.0.0.1:3456', + 'settings.adapters.save': 'Save', + 'settings.adapters.saved': 'Saved', + 'settings.adapters.saving': 'Saving...', + 'settings.adapters.startHint': 'After saving, run adapter: cd adapters && bun run {platform}', + // Settings > General 'settings.general.languageTitle': 'Language', 'settings.general.languageDescription': 'Choose the display language for the application.', @@ -207,15 +236,76 @@ export const en = { 'newTask.description': 'Description', 'newTask.descPlaceholder': "Review yesterday's commits and flag anything concerning", 'newTask.frequency': 'Frequency', - 'newTask.hourly': 'Hourly', + 'newTask.everyNMinutes': 'Every N minutes', + 'newTask.everyNHours': 'Every N hours', 'newTask.daily': 'Daily', - 'newTask.weekdays': 'Weekdays', - 'newTask.weekly': 'Weekly', + 'newTask.weekdays': 'Weekdays (Mon–Fri)', + 'newTask.specificDays': 'Specific days of week', 'newTask.monthly': 'Monthly', + 'newTask.customCron': 'Custom cron expression', + 'newTask.intervalMinutes': 'Every {n} minutes', + 'newTask.intervalHours': 'Every {n} hours', + 'newTask.atMinute': 'at minute :{m}', + 'newTask.onMonthDay': 'On day {d} of each month', + 'newTask.cronFormatHint': 'Format: minute hour day-of-month month day-of-week', + 'newTask.invalidCron': 'Invalid cron expression', + 'newTask.daySun': 'Sun', + 'newTask.dayMon': 'Mon', + 'newTask.dayTue': 'Tue', + 'newTask.dayWed': 'Wed', + 'newTask.dayThu': 'Thu', + 'newTask.dayFri': 'Fri', + 'newTask.daySat': 'Sat', 'newTask.delayNote': 'Scheduled tasks use a randomized delay of several minutes for server performance.', 'newTask.create': 'Create task', 'newTask.promptPlaceholder': 'Look at the commits from the last 24 hours. Summarize what changed, call out any risky patterns or missing tests, and note anything worth following up on.', + // ─── Cron Descriptions ────────────────────────────────────── + 'cron.everyMinute': 'Runs every minute', + 'cron.everyNMinutes': 'Runs every {n} minutes', + 'cron.everyHour': 'Runs every hour', + 'cron.everyNHours': 'Runs every {n} hours', + 'cron.everyNHoursAtMinute': 'Runs every {n} hours at :{m}', + 'cron.dailyAt': 'Runs daily at {time}', + 'cron.weekdaysAt': 'Runs on weekdays at {time}', + 'cron.specificDaysAt': 'Runs every {days} at {time}', + 'cron.monthlyAt': 'Runs on the {day}th of every month at {time}', + 'cron.customSchedule': 'Custom: {cron}', + 'cron.dow.0': 'Sun', + 'cron.dow.1': 'Mon', + 'cron.dow.2': 'Tue', + 'cron.dow.3': 'Wed', + 'cron.dow.4': 'Thu', + 'cron.dow.5': 'Fri', + 'cron.dow.6': 'Sat', + + // ─── Task Actions & Logs ────────────────────────────────────── + 'tasks.runNow': 'Run now', + 'tasks.confirmRun': 'Execute this task immediately?', + 'tasks.confirmDisable': 'Disable this scheduled task?', + 'tasks.confirmEnable': 'Enable this scheduled task?', + 'tasks.confirmDelete': 'Permanently delete this task and all its logs?', + 'tasks.running': 'Running...', + 'tasks.viewLogs': 'Logs', + 'tasks.logsTitle': 'Execution logs', + 'tasks.noLogs': 'No execution logs yet', + 'tasks.runStatus.running': 'Running', + 'tasks.runStatus.completed': 'Completed', + 'tasks.runStatus.failed': 'Failed', + 'tasks.runStatus.timeout': 'Timeout', + 'tasks.duration': '{s}s', + 'tasks.viewOutput': 'Summary', + 'tasks.hideOutput': 'Hide', + 'tasks.close': 'Close', + 'tasks.edit': 'Edit', + 'tasks.editTitle': 'Edit scheduled task', + 'tasks.saveChanges': 'Save changes', + 'tasks.openSession': 'View conversation', + 'tasks.createdAt': 'Created: ', + 'tasks.lastRunAt': 'Last run: ', + 'tasks.outputHintSession': 'Click "View conversation" to see the full output in session view.', + 'tasks.noOutputText': 'No output available.', + // ─── Prompt Editor ────────────────────────────────────── 'promptEditor.worktree': 'worktree', 'promptEditor.bypassWarning': 'Bypass mode grants full system access', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index e3d899ff..f1f25af6 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -97,6 +97,35 @@ export const zh: Record = { 'settings.permissions.bypass': '跳过全部', 'settings.permissions.bypassDesc': '跳过所有权限检查(危险)', + // Settings > Adapters + 'settings.tab.adapters': 'IM 接入', + 'settings.adapters.description': '配置即时通讯适配器,通过 Telegram 或飞书与 Claude Code 对话。', + 'settings.adapters.telegram': 'Telegram', + 'settings.adapters.feishu': '飞书 (Feishu)', + 'settings.adapters.botToken': 'Bot Token', + 'settings.adapters.botTokenPlaceholder': '粘贴从 @BotFather 获取的 Token', + 'settings.adapters.appId': 'App ID', + 'settings.adapters.appIdPlaceholder': '如 cli_xxx', + 'settings.adapters.appSecret': 'App Secret', + 'settings.adapters.appSecretPlaceholder': '从飞书开放平台获取', + 'settings.adapters.encryptKey': 'Encrypt Key', + 'settings.adapters.encryptKeyPlaceholder': '可选', + 'settings.adapters.verificationToken': 'Verification Token', + 'settings.adapters.verificationTokenPlaceholder': '可选', + 'settings.adapters.allowedUsers': '允许的用户', + 'settings.adapters.allowedUsersHint': '逗号分隔。留空允许所有人。', + 'settings.adapters.tgAllowedUsersPlaceholder': '如 123456789, 987654321', + 'settings.adapters.fsAllowedUsersPlaceholder': '如 ou_xxx, ou_yyy', + 'settings.adapters.workDir': '工作目录', + 'settings.adapters.streamingCard': '流式卡片模式', + 'settings.adapters.streamingCardDesc': '实时更新消息内容,体验更好', + 'settings.adapters.serverUrl': '服务器地址', + 'settings.adapters.serverUrlPlaceholder': 'ws://127.0.0.1:3456', + 'settings.adapters.save': '保存', + 'settings.adapters.saved': '已保存', + 'settings.adapters.saving': '保存中...', + 'settings.adapters.startHint': '保存后运行适配器:cd adapters && bun run {platform}', + // Settings > General 'settings.general.languageTitle': '语言', 'settings.general.languageDescription': '选择应用程序的显示语言。', @@ -209,15 +238,76 @@ export const zh: Record = { 'newTask.description': '描述', 'newTask.descPlaceholder': '审查昨天的提交并标记可疑之处', 'newTask.frequency': '频率', - 'newTask.hourly': '每小时', + 'newTask.everyNMinutes': '每 N 分钟', + 'newTask.everyNHours': '每 N 小时', 'newTask.daily': '每天', - 'newTask.weekdays': '工作日', - 'newTask.weekly': '每周', + 'newTask.weekdays': '工作日(周一至周五)', + 'newTask.specificDays': '指定星期几', 'newTask.monthly': '每月', + 'newTask.customCron': '自定义 Cron 表达式', + 'newTask.intervalMinutes': '每 {n} 分钟', + 'newTask.intervalHours': '每 {n} 小时', + 'newTask.atMinute': '在第 :{m} 分钟', + 'newTask.onMonthDay': '每月第 {d} 天', + 'newTask.cronFormatHint': '格式:分 时 日 月 星期', + 'newTask.invalidCron': '无效的 Cron 表达式', + 'newTask.daySun': '日', + 'newTask.dayMon': '一', + 'newTask.dayTue': '二', + 'newTask.dayWed': '三', + 'newTask.dayThu': '四', + 'newTask.dayFri': '五', + 'newTask.daySat': '六', 'newTask.delayNote': '定时任务会使用随机延迟以优化服务器性能。', 'newTask.create': '创建任务', 'newTask.promptPlaceholder': '查看过去 24 小时的提交。总结变更内容,标记有风险的模式或缺失的测试,并记录值得跟进的事项。', + // ─── Cron 描述 ────────────────────────────────────── + 'cron.everyMinute': '每分钟执行', + 'cron.everyNMinutes': '每 {n} 分钟执行', + 'cron.everyHour': '每小时执行', + 'cron.everyNHours': '每 {n} 小时执行', + 'cron.everyNHoursAtMinute': '每 {n} 小时在第 :{m} 分钟执行', + 'cron.dailyAt': '每天 {time} 执行', + 'cron.weekdaysAt': '工作日 {time} 执行', + 'cron.specificDaysAt': '每{days} {time} 执行', + 'cron.monthlyAt': '每月 {day} 日 {time} 执行', + 'cron.customSchedule': '自定义:{cron}', + 'cron.dow.0': '周日', + 'cron.dow.1': '周一', + 'cron.dow.2': '周二', + 'cron.dow.3': '周三', + 'cron.dow.4': '周四', + 'cron.dow.5': '周五', + 'cron.dow.6': '周六', + + // ─── 任务操作与日志 ────────────────────────────────────── + 'tasks.runNow': '立即执行', + 'tasks.confirmRun': '确定要立即执行此任务吗?', + 'tasks.confirmDisable': '确定要禁用此定时任务吗?', + 'tasks.confirmEnable': '确定要启用此定时任务吗?', + 'tasks.confirmDelete': '确定要永久删除此任务及其所有日志吗?', + 'tasks.running': '执行中...', + 'tasks.viewLogs': '日志', + 'tasks.logsTitle': '执行记录', + 'tasks.noLogs': '暂无执行记录', + 'tasks.runStatus.running': '运行中', + 'tasks.runStatus.completed': '已完成', + 'tasks.runStatus.failed': '失败', + 'tasks.runStatus.timeout': '超时', + 'tasks.duration': '{s}秒', + 'tasks.viewOutput': '摘要', + 'tasks.hideOutput': '收起', + 'tasks.close': '关闭', + 'tasks.edit': '编辑', + 'tasks.editTitle': '编辑定时任务', + 'tasks.saveChanges': '保存修改', + 'tasks.openSession': '查看完整对话', + 'tasks.createdAt': '创建于 ', + 'tasks.lastRunAt': '上次执行 ', + 'tasks.outputHintSession': '点击「查看完整对话」可在会话中查看完整输出。', + 'tasks.noOutputText': '暂无输出内容。', + // ─── Prompt Editor ────────────────────────────────────── 'promptEditor.worktree': '工作树', 'promptEditor.bypassWarning': '跳过模式将授予完整系统访问权限', diff --git a/desktop/src/lib/__tests__/cronDescribe.test.ts b/desktop/src/lib/__tests__/cronDescribe.test.ts new file mode 100644 index 00000000..e6e3d254 --- /dev/null +++ b/desktop/src/lib/__tests__/cronDescribe.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest' +import { describeCron, isValidCron } from '../cronDescribe' + +// Simple mock t() that returns the key with params interpolated +const t = (key: string, params?: Record) => { + let text = key + if (params) { + for (const [k, v] of Object.entries(params)) { + text = text.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v)) + } + } + return text +} + +describe('describeCron', () => { + it('every minute', () => { + expect(describeCron('* * * * *', t)).toBe('cron.everyMinute') + }) + + it('every N minutes', () => { + expect(describeCron('*/15 * * * *', t)).toBe('cron.everyNMinutes') + expect(describeCron('*/5 * * * *', t)).toBe('cron.everyNMinutes') + }) + + it('*/1 is treated as every minute', () => { + expect(describeCron('*/1 * * * *', t)).toBe('cron.everyMinute') + }) + + it('every hour', () => { + expect(describeCron('0 */1 * * *', t)).toBe('cron.everyHour') + }) + + it('every N hours', () => { + expect(describeCron('0 */4 * * *', t)).toBe('cron.everyNHours') + }) + + it('every N hours at minute offset', () => { + expect(describeCron('30 */4 * * *', t)).toBe('cron.everyNHoursAtMinute') + }) + + it('daily at time', () => { + expect(describeCron('30 9 * * *', t)).toBe('cron.dailyAt') + }) + + it('weekdays at time', () => { + expect(describeCron('30 9 * * 1-5', t)).toBe('cron.weekdaysAt') + }) + + it('specific days of week', () => { + const result = describeCron('0 9 * * 1,3,5', t) + expect(result).toBe('cron.specificDaysAt') + }) + + it('monthly on specific day', () => { + expect(describeCron('0 9 15 * *', t)).toBe('cron.monthlyAt') + }) + + it('unrecognized pattern falls back to custom', () => { + expect(describeCron('0 9 1 6 *', t)).toBe('cron.customSchedule') + }) + + it('invalid field count falls back to custom', () => { + expect(describeCron('0 9 *', t)).toBe('cron.customSchedule') + }) +}) + +describe('isValidCron', () => { + it('accepts valid expressions', () => { + expect(isValidCron('0 9 * * *')).toBe(true) + expect(isValidCron('*/15 * * * *')).toBe(true) + expect(isValidCron('30 14 * * 1-5')).toBe(true) + expect(isValidCron('0 9 * * 1,3,5')).toBe(true) + expect(isValidCron('0 9 15 * *')).toBe(true) + expect(isValidCron('0 */2 * * *')).toBe(true) + }) + + it('rejects invalid expressions', () => { + expect(isValidCron('')).toBe(false) + expect(isValidCron('hello')).toBe(false) + expect(isValidCron('0 9 *')).toBe(false) // too few fields + expect(isValidCron('0 9 * * * *')).toBe(false) // too many fields + expect(isValidCron('60 9 * * *')).toBe(false) // minute > 59 + expect(isValidCron('0 25 * * *')).toBe(false) // hour > 23 + expect(isValidCron('0 9 32 * *')).toBe(false) // day > 31 + expect(isValidCron('0 9 * 13 *')).toBe(false) // month > 12 + expect(isValidCron('0 9 * * 8')).toBe(false) // dow > 7 + }) + + it('accepts edge case values', () => { + expect(isValidCron('0 0 1 1 0')).toBe(true) + expect(isValidCron('59 23 31 12 7')).toBe(true) + }) +}) diff --git a/desktop/src/lib/cronDescribe.ts b/desktop/src/lib/cronDescribe.ts new file mode 100644 index 00000000..1365f6db --- /dev/null +++ b/desktop/src/lib/cronDescribe.ts @@ -0,0 +1,187 @@ +/** + * Cron expression utilities: human-readable description & validation. + * Works with standard 5-field cron: minute hour day-of-month month day-of-week + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type TFunc = (key: any, params?: Record) => string + +function pad(n: number): string { + return n.toString().padStart(2, '0') +} + +function formatTime(hour: number, minute: number): string { + return `${pad(hour)}:${pad(minute)}` +} + +function describeDow(field: string, t: TFunc): string { + const parts = field.split(',') + const days: number[] = [] + for (const part of parts) { + const range = part.match(/^(\d+)-(\d+)$/) + if (range) { + const start = parseInt(range[1]!) + const end = parseInt(range[2]!) + for (let i = start; i <= end; i++) days.push(i) + } else { + days.push(parseInt(part)) + } + } + return days.map((d) => t(`cron.dow.${d % 7}`)).join(', ') +} + +export function describeCron(cron: string, t: TFunc): string { + const fields = cron.trim().split(/\s+/) + if (fields.length !== 5) return t('cron.customSchedule', { cron }) + + const min = fields[0]! + const hour = fields[1]! + const dom = fields[2]! + const month = fields[3]! + const dow = fields[4]! + + // */N * * * * → every N minutes + if (hour === '*' && dom === '*' && month === '*' && dow === '*') { + const stepMatch = min.match(/^\*\/(\d+)$/) + if (stepMatch) { + const n = parseInt(stepMatch[1]!) + if (n === 1) return t('cron.everyMinute') + return t('cron.everyNMinutes', { n }) + } + if (min === '*') return t('cron.everyMinute') + } + + // M */N * * * → every N hours (optionally at :M) + if (/^\d+$/.test(min) && dom === '*' && month === '*' && dow === '*') { + const hourStep = hour.match(/^\*\/(\d+)$/) + if (hourStep) { + const n = parseInt(hourStep[1]!) + const m = parseInt(min) + if (m === 0) { + if (n === 1) return t('cron.everyHour') + return t('cron.everyNHours', { n }) + } + return t('cron.everyNHoursAtMinute', { n, m: pad(m) }) + } + } + + // M H * * → daily / weekdays / specific days + if (/^\d+$/.test(min) && /^\d+$/.test(hour) && dom === '*' && month === '*') { + const time = formatTime(parseInt(hour), parseInt(min)) + + if (dow === '*') { + return t('cron.dailyAt', { time }) + } + if (dow === '1-5') { + return t('cron.weekdaysAt', { time }) + } + if (/^[\d,\-]+$/.test(dow)) { + const days = describeDow(dow, t) + return t('cron.specificDaysAt', { days, time }) + } + } + + // M H D * * → monthly on day D + if (/^\d+$/.test(min) && /^\d+$/.test(hour) && /^\d+$/.test(dom) && month === '*' && dow === '*') { + const time = formatTime(parseInt(hour), parseInt(min)) + return t('cron.monthlyAt', { day: parseInt(dom), time }) + } + + return t('cron.customSchedule', { cron }) +} + +/** + * Reverse-parse a cron expression back into UI-friendly state + * for the task edit modal. + */ +export type FrequencyKey = 'everyNMinutes' | 'everyNHours' | 'daily' | 'weekdays' | 'specificDays' | 'monthly' | 'customCron' + +export type ParsedCron = { + frequency: FrequencyKey + time: string + minuteInterval: number + hourInterval: number + minuteOffset: number + selectedDays: number[] + monthDay: number + customCron: string +} + +const DEFAULTS: ParsedCron = { + frequency: 'customCron', + time: '09:00', + minuteInterval: 15, + hourInterval: 1, + minuteOffset: 0, + selectedDays: [1], + monthDay: 1, + customCron: '', +} + +export function parseCron(cron: string): ParsedCron { + const fields = cron.trim().split(/\s+/) + if (fields.length !== 5) return { ...DEFAULTS, customCron: cron } + + const min = fields[0]! + const hour = fields[1]! + const dom = fields[2]! + const month = fields[3]! + const dow = fields[4]! + + // */N * * * * → everyNMinutes + if (/^\*\/\d+$/.test(min) && hour === '*' && dom === '*' && month === '*' && dow === '*') { + return { ...DEFAULTS, frequency: 'everyNMinutes', minuteInterval: parseInt(min.split('/')[1]!) } + } + + // M */N * * * → everyNHours + if (/^\d+$/.test(min) && /^\*\/\d+$/.test(hour) && dom === '*' && month === '*' && dow === '*') { + return { ...DEFAULTS, frequency: 'everyNHours', minuteOffset: parseInt(min), hourInterval: parseInt(hour.split('/')[1]!) } + } + + // M H ... patterns need time + if (/^\d+$/.test(min) && /^\d+$/.test(hour)) { + const time = formatTime(parseInt(hour), parseInt(min)) + + // M H * * * → daily + if (dom === '*' && month === '*' && dow === '*') { + return { ...DEFAULTS, frequency: 'daily', time } + } + // M H * * 1-5 → weekdays + if (dom === '*' && month === '*' && dow === '1-5') { + return { ...DEFAULTS, frequency: 'weekdays', time } + } + // M H * * → specificDays + if (dom === '*' && month === '*' && /^[\d,]+$/.test(dow)) { + return { ...DEFAULTS, frequency: 'specificDays', time, selectedDays: dow.split(',').map(Number) } + } + // M H D * * → monthly + if (/^\d+$/.test(dom) && month === '*' && dow === '*') { + return { ...DEFAULTS, frequency: 'monthly', time, monthDay: parseInt(dom) } + } + } + + return { ...DEFAULTS, customCron: cron } +} + +export function isValidCron(cron: string): boolean { + const fields = cron.trim().split(/\s+/) + if (fields.length !== 5) return false + + const fieldPattern = /^(\*|(\d+(-\d+)?(\/\d+)?)(,(\d+(-\d+)?(\/\d+)?))*)$/ + const maxValues = [59, 23, 31, 12, 7] + const minValues = [0, 0, 1, 1, 0] + + for (let i = 0; i < 5; i++) { + const field = fields[i]! + if (/^\*\/\d+$/.test(field)) continue + if (field === '*') continue + if (!fieldPattern.test(field)) return false + const nums = field.replace(/\/\d+/g, '').split(/[,\-]/).filter((s) => /^\d+$/.test(s)) + for (const num of nums) { + const n = parseInt(num) + if (n < minValues[i]! || n > maxValues[i]!) return false + } + } + + return true +} diff --git a/desktop/src/lib/parseRunOutput.ts b/desktop/src/lib/parseRunOutput.ts new file mode 100644 index 00000000..410f64b8 --- /dev/null +++ b/desktop/src/lib/parseRunOutput.ts @@ -0,0 +1,79 @@ +/** + * Parse task run output into displayable text. + * + * The output may be in one of two formats: + * + * 1. **Extracted text** (new runs) — The server's `extractAssistantText` has + * already parsed the raw NDJSON and stored only the AI's text response. + * This is plain text / markdown that should be returned as-is. + * + * 2. **Raw NDJSON** (old runs before the server-side extraction was added) — + * Each line is a JSON object from the CLI's stream-json output. We parse + * these and extract assistant text blocks + result messages. + * + * Detection: if at least one line parses as JSON with a recognized `type` + * field, treat as NDJSON. Otherwise return as-is. + */ +export function parseRunOutput(raw: string): string { + if (!raw || !raw.trim()) return '' + + const lines = raw.trim().split('\n') + + // Quick check: does this look like NDJSON? (first non-empty line starts with '{') + const firstLine = lines.find((l) => l.trim()) + if (!firstLine || !firstLine.trim().startsWith('{')) { + // Already extracted plain text — return as-is + return raw.trim() + } + + // Try to parse as NDJSON (legacy format) + const textParts: string[] = [] + let anyRecognized = false + + for (const line of lines) { + if (!line.trim()) continue + + let parsed: any + try { + parsed = JSON.parse(line) + } catch { + continue + } + + const type = parsed?.type + + if (type === 'assistant') { + anyRecognized = true + const content = parsed?.message?.content + if (!Array.isArray(content)) continue + for (const block of content) { + if (block.type === 'text' && block.text?.trim()) { + textParts.push(block.text.trim()) + } + } + } + + if (type === 'result') { + anyRecognized = true + const result = parsed?.result + if (typeof result === 'string' && result.trim()) { + textParts.push(result.trim()) + } else if (result?.message?.trim()) { + textParts.push(result.message.trim()) + } + } + + if (type === 'system' || type === 'user') { + anyRecognized = true + // Skip these — not useful to display + } + } + + // If we recognized NDJSON structure, return extracted text + if (anyRecognized) { + return textParts.join('\n\n') + } + + // Fallback: the JSON lines didn't have recognized types — return raw + return raw.trim() +} diff --git a/desktop/src/stores/taskStore.ts b/desktop/src/stores/taskStore.ts index 359ebd01..9078bcaf 100644 --- a/desktop/src/stores/taskStore.ts +++ b/desktop/src/stores/taskStore.ts @@ -12,6 +12,7 @@ type TaskStore = { createTask: (input: CreateTaskInput) => Promise updateTask: (id: string, updates: Partial) => Promise deleteTask: (id: string) => Promise + runTask: (taskId: string) => Promise fetchRecentRuns: () => Promise fetchTaskRuns: (taskId: string) => Promise } @@ -47,6 +48,10 @@ export const useTaskStore = create((set) => ({ set((s) => ({ tasks: s.tasks.filter((t) => t.id !== id) })) }, + runTask: async (taskId) => { + await tasksApi.runTask(taskId) + }, + fetchRecentRuns: async () => { try { const { runs } = await tasksApi.getRecentRuns() diff --git a/desktop/src/types/task.ts b/desktop/src/types/task.ts index 2de91859..60d83069 100644 --- a/desktop/src/types/task.ts +++ b/desktop/src/types/task.ts @@ -45,4 +45,5 @@ export type TaskRun = { error?: string exitCode?: number durationMs?: number + sessionId?: string } diff --git a/src/server/api/scheduled-tasks.ts b/src/server/api/scheduled-tasks.ts index da7ea778..7316f9f5 100644 --- a/src/server/api/scheduled-tasks.ts +++ b/src/server/api/scheduled-tasks.ts @@ -5,6 +5,7 @@ * POST /api/scheduled-tasks — 创建任务 * GET /api/scheduled-tasks/runs — 获取所有任务的最近执行记录 * GET /api/scheduled-tasks/:id/runs — 获取指定任务的执行记录 + * POST /api/scheduled-tasks/:id/run — 立即执行指定任务 * PUT /api/scheduled-tasks/:id — 更新任务 * DELETE /api/scheduled-tasks/:id — 删除任务 */ @@ -64,6 +65,21 @@ export async function handleScheduledTasksApi( return Response.json({ task }, { status: 201 }) } + // ── POST /api/scheduled-tasks/:id/run ────────────────────────────────── + // Fire-and-forget: start execution in background, return immediately. + // The frontend polls GET /:id/runs to track progress. + if (method === 'POST' && taskId && subResource === 'run') { + const tasks = await cronService.listTasks() + const task = tasks.find((t) => t.id === taskId) + if (!task) throw ApiError.notFound(`Task ${taskId} not found`) + cronScheduler.executeTask(task, { createSession: true }).catch((err) => { + console.error(`[ScheduledTasks] Manual run failed for task ${taskId}:`, err) + }) + // Small delay to let appendRun() write the "running" entry to disk + await new Promise((r) => setTimeout(r, 200)) + return Response.json({ ok: true }) + } + // ── PUT /api/scheduled-tasks/:id ────────────────────────────────────── if (method === 'PUT' && taskId && !subResource) { const body = await parseJsonBody(req) diff --git a/src/server/services/cronScheduler.ts b/src/server/services/cronScheduler.ts index bc5172af..f09272f1 100644 --- a/src/server/services/cronScheduler.ts +++ b/src/server/services/cronScheduler.ts @@ -12,6 +12,7 @@ import * as path from 'path' import * as os from 'os' import * as crypto from 'crypto' import { CronService, type CronTask } from './cronService.js' +import { SessionService } from './sessionService.js' // ─── Types ───────────────────────────────────────────────────────────────────── @@ -27,6 +28,59 @@ export type TaskRun = { error?: string exitCode?: number durationMs?: number + sessionId?: string // links to a session for rich output rendering +} + +// ─── Output extraction ──────────────────────────────────────────────────────── + +/** + * Extract meaningful assistant text from raw CLI stream-json (NDJSON) output. + * + * The raw stdout contains system/init messages, tool_use blocks, tool_result + * echoes, and thinking blocks — all of which are noise to the end user. The + * actual AI answer (assistant text blocks + final result) is what matters. + * + * By extracting server-side we avoid the 10K naive truncation problem where + * the useful content sits well past the first 10K characters. + */ +function extractAssistantText(raw: string): string { + if (!raw) return '' + const lines = raw.split('\n') + const parts: string[] = [] + + for (const line of lines) { + if (!line.trim()) continue + let parsed: any + try { + parsed = JSON.parse(line) + } catch { + continue // skip non-JSON lines and truncated lines + } + + const type = parsed?.type + + if (type === 'assistant') { + const content = parsed?.message?.content + if (!Array.isArray(content)) continue + for (const block of content) { + if (block.type === 'text' && block.text?.trim()) { + parts.push(block.text.trim()) + } + // Skip tool_use, thinking blocks + } + } + + if (type === 'result') { + const result = parsed?.result + if (typeof result === 'string' && result.trim()) { + parts.push(result.trim()) + } else if (result?.message?.trim()) { + parts.push(result.message.trim()) + } + } + } + + return parts.join('\n\n') } // ─── Cron expression matching ────────────────────────────────────────────────── @@ -193,9 +247,11 @@ export class CronScheduler { { proc: ReturnType; startedAt: number; runId: string } >() private cronService: CronService + private sessionService: SessionService constructor(cronService?: CronService) { this.cronService = cronService || new CronService() + this.sessionService = new SessionService() } /** Start the scheduler (called on server boot). */ @@ -252,10 +308,30 @@ export class CronScheduler { } } - /** Execute a single task by spawning a CLI subprocess. */ - async executeTask(task: CronTask): Promise { + /** + * Execute a single task by spawning a CLI subprocess. + * @param task The task to execute + * @param options.createSession When true, creates a Session for rich output viewing (used for manual "Run Now") + */ + async executeTask(task: CronTask, options?: { createSession?: boolean }): Promise { const runId = crypto.randomBytes(6).toString('hex') const startedAt = new Date().toISOString() + const workDir = task.folderPath || os.homedir() + + // Only create a session when explicitly requested (manual "Run Now"), + // not for automatic cron runs — avoids flooding the sidebar. + let sessionId: string | undefined + if (options?.createSession) { + try { + const result = await this.sessionService.createSession(workDir) + sessionId = result.sessionId + // Delete the placeholder JSONL file so the CLI can create it fresh + // with actual content. Same pattern as conversationService.ts. + await this.sessionService.deleteSessionFile(sessionId) + } catch { + // Fall back to no session if creation fails + } + } const run: TaskRun = { id: runId, @@ -264,13 +340,16 @@ export class CronScheduler { startedAt, status: 'running', prompt: task.prompt, + sessionId, } // Persist the "running" state await appendRun(run) - // Resolve CLI entry point relative to this file - const cliPath = path.resolve(import.meta.dir, '../../entrypoints/cli.tsx') + // Resolve paths relative to project root + const projectRoot = path.resolve(import.meta.dir, '../../..') + const cliPath = path.join(projectRoot, 'src/entrypoints/cli.tsx') + const preloadPath = path.join(projectRoot, 'preload.ts') const inputPayload = JSON.stringify({ type: 'user', @@ -279,12 +358,14 @@ export class CronScheduler { content: [{ type: 'text', text: task.prompt }], }, parent_tool_use_id: null, - session_id: '', + session_id: sessionId || '', }) + '\n' const proc = Bun.spawn( [ 'bun', + '--preload', + preloadPath, cliPath, '--print', '--verbose', @@ -292,12 +373,13 @@ export class CronScheduler { 'stream-json', '--output-format', 'stream-json', + ...(sessionId ? ['--session-id', sessionId] : []), ], { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', - cwd: task.folderPath || os.homedir(), + cwd: workDir, }, ) @@ -346,18 +428,24 @@ export class CronScheduler { this.runningTasks.delete(task.id) const completedAt = new Date().toISOString() - const output = stdoutChunks.join('') + const rawOutput = stdoutChunks.join('') const durationMs = new Date(completedAt).getTime() - new Date(startedAt).getTime() // Determine if this was a timeout const wasTimeout = durationMs >= TASK_TIMEOUT_MS + // Extract only meaningful AI text responses from raw NDJSON output. + // The raw stream contains system/init messages, tool_use blocks, and + // tool_result echoes that consume thousands of chars before any actual + // AI answer appears. A naive .slice(0, 10_000) would lose the answer. + const output = extractAssistantText(rawOutput) + const completedRun: TaskRun = { ...run, completedAt, status: wasTimeout ? 'timeout' : exitCode === 0 ? 'completed' : 'failed', - output: output.slice(0, 10_000), // cap stored output + output: output.slice(0, 50_000), // cap after extraction exitCode, durationMs, }