feat(tasks): manage markdown loops from scheduled tasks
This commit is contained in:
@@ -60,7 +60,7 @@ If a project loop and a user loop share a name, the project loop wins.
|
|||||||
|
|
||||||
### How loops behave
|
### How loops behave
|
||||||
|
|
||||||
- The **file is authoritative** while it exists: edits made in the UI are reverted on the next sync. The scheduled-tasks dialog marks loop tasks and disables their edit/enable/delete actions — **run now** still works. To stop a loop, delete the file (or set `enabled: false`).
|
- The **file is authoritative** while it exists. **Edit** opens it in the built-in file editor, the enabled toggle updates its frontmatter, and deleting the task deletes the markdown file after confirmation. **Run now** remains available.
|
||||||
- Runtime state (last run, next run, status) lives in the project config and is never written back into the markdown file.
|
- Runtime state (last run, next run, status) lives in the project config and is never written back into the markdown file.
|
||||||
- Renaming the `name` field renames the task in place. If a loop file temporarily fails to parse (mid-edit, merge conflict), its task is kept with the last good definition until the file is fixed.
|
- Renaming the `name` field renames the task in place. If a loop file temporarily fails to parse (mid-edit, merge conflict), its task is kept with the last good definition until the file is fixed.
|
||||||
- `daily`/`weekly`/`once` schedules and goal settings remain UI-only; loop files are always cron.
|
- `daily`/`weekly`/`once` schedules and goal settings remain UI-only; loop files are always cron.
|
||||||
|
|||||||
@@ -21,14 +21,17 @@ import { useI18n } from '@/lib/i18n';
|
|||||||
import type { ProjectEntry } from '@/lib/api/types';
|
import type { ProjectEntry } from '@/lib/api/types';
|
||||||
import {
|
import {
|
||||||
deleteScheduledTask,
|
deleteScheduledTask,
|
||||||
|
deleteScheduledTaskLoopFile,
|
||||||
fetchScheduledTasks,
|
fetchScheduledTasks,
|
||||||
runScheduledTaskNow,
|
runScheduledTaskNow,
|
||||||
|
setLoopScheduledTaskEnabled,
|
||||||
upsertScheduledTask,
|
upsertScheduledTask,
|
||||||
type ScheduledTask,
|
type ScheduledTask,
|
||||||
type ScheduledTaskStatus,
|
type ScheduledTaskStatus,
|
||||||
} from '@/lib/scheduledTasksApi';
|
} from '@/lib/scheduledTasksApi';
|
||||||
import { ScheduledTaskEditorDialog } from './ScheduledTaskEditorDialog';
|
import { ScheduledTaskEditorDialog } from './ScheduledTaskEditorDialog';
|
||||||
import { canonicalizeTimezone } from '@/lib/timezones';
|
import { canonicalizeTimezone } from '@/lib/timezones';
|
||||||
|
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
||||||
|
|
||||||
const scheduleTimes = (task: ScheduledTask): string[] => {
|
const scheduleTimes = (task: ScheduledTask): string[] => {
|
||||||
const raw = Array.isArray(task.schedule.times)
|
const raw = Array.isArray(task.schedule.times)
|
||||||
@@ -314,10 +317,11 @@ export function ScheduledTasksDialog() {
|
|||||||
setMutatingTaskID(task.id);
|
setMutatingTaskID(task.id);
|
||||||
setTasks((prev) => prev.map((item) => (item.id === task.id ? { ...item, enabled } : item)));
|
setTasks((prev) => prev.map((item) => (item.id === task.id ? { ...item, enabled } : item)));
|
||||||
try {
|
try {
|
||||||
await upsertScheduledTask(selectedProjectID, {
|
if (task.loopFile) {
|
||||||
...task,
|
await setLoopScheduledTaskEnabled(selectedProjectID, task.id, enabled);
|
||||||
enabled,
|
} else {
|
||||||
});
|
await upsertScheduledTask(selectedProjectID, { ...task, enabled });
|
||||||
|
}
|
||||||
await reloadTasks(selectedProjectID, { silent: true });
|
await reloadTasks(selectedProjectID, { silent: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : t('sessions.scheduledTasks.dialog.toast.updateFailed'));
|
toast.error(error instanceof Error ? error.message : t('sessions.scheduledTasks.dialog.toast.updateFailed'));
|
||||||
@@ -331,14 +335,20 @@ export function ScheduledTasksDialog() {
|
|||||||
if (!selectedProjectID) {
|
if (!selectedProjectID) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const confirmed = window.confirm(t('sessions.scheduledTasks.dialog.confirm.deleteTask', { taskName: task.name }));
|
const confirmed = window.confirm(task.loopFile
|
||||||
|
? t('sessions.scheduledTasks.dialog.confirm.deleteLoopFile', { taskName: task.name })
|
||||||
|
: t('sessions.scheduledTasks.dialog.confirm.deleteTask', { taskName: task.name }));
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setMutatingTaskID(task.id);
|
setMutatingTaskID(task.id);
|
||||||
try {
|
try {
|
||||||
await deleteScheduledTask(selectedProjectID, task.id);
|
if (task.loopFile) {
|
||||||
|
await deleteScheduledTaskLoopFile(selectedProjectID, task.id);
|
||||||
|
} else {
|
||||||
|
await deleteScheduledTask(selectedProjectID, task.id);
|
||||||
|
}
|
||||||
await reloadTasks(selectedProjectID, { silent: true });
|
await reloadTasks(selectedProjectID, { silent: true });
|
||||||
toast.success(t('sessions.scheduledTasks.dialog.toast.deleted'));
|
toast.success(t('sessions.scheduledTasks.dialog.toast.deleted'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -348,6 +358,24 @@ export function ScheduledTasksDialog() {
|
|||||||
}
|
}
|
||||||
}, [selectedProjectID, reloadTasks, t]);
|
}, [selectedProjectID, reloadTasks, t]);
|
||||||
|
|
||||||
|
const handleEditTask = React.useCallback((task: ScheduledTask) => {
|
||||||
|
if (!task.loopFile) {
|
||||||
|
setEditorTask(task);
|
||||||
|
setEditorOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedProject?.path) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
if (isMobile) {
|
||||||
|
useFilesViewTabsStore.getState().setSelectedPath(selectedProject.path, task.loopFile, { allowOutsideRoot: true });
|
||||||
|
useUIStore.getState().setActiveMainTab('files');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
useUIStore.getState().openContextFile(selectedProject.path, task.loopFile);
|
||||||
|
}, [isMobile, selectedProject?.path, setOpen]);
|
||||||
|
|
||||||
const handleRunNow = React.useCallback(async (task: ScheduledTask) => {
|
const handleRunNow = React.useCallback(async (task: ScheduledTask) => {
|
||||||
if (!selectedProjectID) {
|
if (!selectedProjectID) {
|
||||||
return;
|
return;
|
||||||
@@ -533,11 +561,8 @@ export function ScheduledTasksDialog() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex cursor-pointer items-center gap-2 typography-micro font-medium',
|
'inline-flex cursor-pointer items-center gap-2 typography-micro font-medium',
|
||||||
task.enabled ? 'text-foreground' : 'text-muted-foreground',
|
task.enabled ? 'text-foreground' : 'text-muted-foreground',
|
||||||
(isBusy || task.loopFile) && 'cursor-not-allowed opacity-50',
|
isBusy && 'cursor-not-allowed opacity-50',
|
||||||
)}
|
)}
|
||||||
title={task.loopFile
|
|
||||||
? t('sessions.scheduledTasks.dialog.loopFile.toggleDisabled')
|
|
||||||
: undefined}
|
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={task.enabled}
|
checked={task.enabled}
|
||||||
@@ -545,7 +570,7 @@ export function ScheduledTasksDialog() {
|
|||||||
ariaLabel={task.enabled
|
ariaLabel={task.enabled
|
||||||
? t('sessions.scheduledTasks.dialog.taskToggle.pauseAria', { taskName: task.name })
|
? t('sessions.scheduledTasks.dialog.taskToggle.pauseAria', { taskName: task.name })
|
||||||
: t('sessions.scheduledTasks.dialog.taskToggle.enableAria', { taskName: task.name })}
|
: t('sessions.scheduledTasks.dialog.taskToggle.enableAria', { taskName: task.name })}
|
||||||
disabled={isBusy || Boolean(task.loopFile)}
|
disabled={isBusy}
|
||||||
/>
|
/>
|
||||||
{task.enabled ? t('sessions.scheduledTasks.dialog.taskToggle.enabled') : t('sessions.scheduledTasks.dialog.taskToggle.paused')}
|
{task.enabled ? t('sessions.scheduledTasks.dialog.taskToggle.enabled') : t('sessions.scheduledTasks.dialog.taskToggle.paused')}
|
||||||
</label>
|
</label>
|
||||||
@@ -562,14 +587,8 @@ export function ScheduledTasksDialog() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => handleEditTask(task)}
|
||||||
setEditorTask(task);
|
disabled={isBusy}
|
||||||
setEditorOpen(true);
|
|
||||||
}}
|
|
||||||
disabled={isBusy || Boolean(task.loopFile)}
|
|
||||||
title={task.loopFile
|
|
||||||
? t('sessions.scheduledTasks.dialog.loopFile.actionsDisabled')
|
|
||||||
: undefined}
|
|
||||||
aria-label={t('sessions.scheduledTasks.dialog.actions.editAria', { taskName: task.name })}
|
aria-label={t('sessions.scheduledTasks.dialog.actions.editAria', { taskName: task.name })}
|
||||||
>
|
>
|
||||||
<Icon name="edit-2" className="h-4 w-4" /> {t('sessions.scheduledTasks.dialog.actions.edit')}
|
<Icon name="edit-2" className="h-4 w-4" /> {t('sessions.scheduledTasks.dialog.actions.edit')}
|
||||||
@@ -578,10 +597,7 @@ export function ScheduledTasksDialog() {
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => void handleDeleteTask(task)}
|
onClick={() => void handleDeleteTask(task)}
|
||||||
disabled={isBusy || Boolean(task.loopFile)}
|
disabled={isBusy}
|
||||||
title={task.loopFile
|
|
||||||
? t('sessions.scheduledTasks.dialog.loopFile.actionsDisabled')
|
|
||||||
: undefined}
|
|
||||||
aria-label={t('sessions.scheduledTasks.dialog.actions.deleteAria', { taskName: task.name })}
|
aria-label={t('sessions.scheduledTasks.dialog.actions.deleteAria', { taskName: task.name })}
|
||||||
>
|
>
|
||||||
<Icon name="delete-bin" className="h-4 w-4" />
|
<Icon name="delete-bin" className="h-4 w-4" />
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ import { useOpenInAppsStore } from '@/stores/useOpenInAppsStore';
|
|||||||
import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts';
|
import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { sessionEvents } from '@/lib/sessionEvents';
|
import { sessionEvents } from '@/lib/sessionEvents';
|
||||||
|
import { syncScheduledTaskLoops } from '@/lib/scheduledTasksApi';
|
||||||
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
|
|
||||||
type FileNode = {
|
type FileNode = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -1668,6 +1670,16 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
sessionEvents.requestGitRefresh({ directory: root, paths: [relativePath] });
|
sessionEvents.requestGitRefresh({ directory: root, paths: [relativePath] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (root && /(?:^|\/)\.agents\/loops\/[^/]+\.md$/i.test(normalizePath(selectedFile.path))) {
|
||||||
|
const project = useProjectsStore.getState().projects.find((entry) => normalizePath(entry.path) === normalizePath(root));
|
||||||
|
if (project) {
|
||||||
|
try {
|
||||||
|
await syncScheduledTaskLoops(project.id);
|
||||||
|
} catch {
|
||||||
|
toast.error(t('sessions.scheduledTasks.dialog.toast.updateFailed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (selectedFile?.path && isDrawioFile(selectedFile.path)) {
|
if (selectedFile?.path && isDrawioFile(selectedFile.path)) {
|
||||||
diagramXmlRef.current = draftContent;
|
diagramXmlRef.current = draftContent;
|
||||||
diagramSavedXmlRef.current = draftContent;
|
diagramSavedXmlRef.current = draftContent;
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ export const dict = {
|
|||||||
'sessions.scheduledTasks.dialog.toast.started': 'Aufgabe gestartet',
|
'sessions.scheduledTasks.dialog.toast.started': 'Aufgabe gestartet',
|
||||||
'sessions.scheduledTasks.dialog.toast.runFailed': 'Fehler beim Ausführen der Aufgabe',
|
'sessions.scheduledTasks.dialog.toast.runFailed': 'Fehler beim Ausführen der Aufgabe',
|
||||||
'sessions.scheduledTasks.dialog.confirm.deleteTask': 'Geplante Aufgabe "{taskName}" löschen?',
|
'sessions.scheduledTasks.dialog.confirm.deleteTask': 'Geplante Aufgabe "{taskName}" löschen?',
|
||||||
|
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': 'Loop-Aufgabe „{taskName}“ und ihre Markdown-Datei löschen?',
|
||||||
'sessions.scheduledTasks.dialog.schedule.daily': 'Täglich {time}',
|
'sessions.scheduledTasks.dialog.schedule.daily': 'Täglich {time}',
|
||||||
'sessions.scheduledTasks.dialog.schedule.dailyWithTimezone': 'Täglich {time} ({timezone})',
|
'sessions.scheduledTasks.dialog.schedule.dailyWithTimezone': 'Täglich {time} ({timezone})',
|
||||||
'sessions.scheduledTasks.dialog.schedule.weekly': 'Wöchentlich {days} {time}',
|
'sessions.scheduledTasks.dialog.schedule.weekly': 'Wöchentlich {days} {time}',
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ export const dict = {
|
|||||||
'sessions.scheduledTasks.dialog.toast.started': 'Task started',
|
'sessions.scheduledTasks.dialog.toast.started': 'Task started',
|
||||||
'sessions.scheduledTasks.dialog.toast.runFailed': 'Failed to run task',
|
'sessions.scheduledTasks.dialog.toast.runFailed': 'Failed to run task',
|
||||||
'sessions.scheduledTasks.dialog.confirm.deleteTask': 'Delete scheduled task "{taskName}"?',
|
'sessions.scheduledTasks.dialog.confirm.deleteTask': 'Delete scheduled task "{taskName}"?',
|
||||||
|
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': 'Delete loop task "{taskName}" and its markdown file?',
|
||||||
'sessions.scheduledTasks.dialog.schedule.daily': 'Daily {time}',
|
'sessions.scheduledTasks.dialog.schedule.daily': 'Daily {time}',
|
||||||
'sessions.scheduledTasks.dialog.schedule.dailyWithTimezone': 'Daily {time} ({timezone})',
|
'sessions.scheduledTasks.dialog.schedule.dailyWithTimezone': 'Daily {time} ({timezone})',
|
||||||
'sessions.scheduledTasks.dialog.schedule.weekly': 'Weekly {days} {time}',
|
'sessions.scheduledTasks.dialog.schedule.weekly': 'Weekly {days} {time}',
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"sessions.scheduledTasks.dialog.toast.started": "Tarea iniciada",
|
"sessions.scheduledTasks.dialog.toast.started": "Tarea iniciada",
|
||||||
"sessions.scheduledTasks.dialog.toast.runFailed": "No se pudo ejecutar la tarea",
|
"sessions.scheduledTasks.dialog.toast.runFailed": "No se pudo ejecutar la tarea",
|
||||||
"sessions.scheduledTasks.dialog.confirm.deleteTask": "Eliminar tarea programada \"{taskName}\"?",
|
"sessions.scheduledTasks.dialog.confirm.deleteTask": "Eliminar tarea programada \"{taskName}\"?",
|
||||||
|
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': '¿Eliminar la tarea de bucle «{taskName}» y su archivo Markdown?',
|
||||||
"sessions.scheduledTasks.dialog.schedule.daily": "Cada día {time}",
|
"sessions.scheduledTasks.dialog.schedule.daily": "Cada día {time}",
|
||||||
"sessions.scheduledTasks.dialog.schedule.dailyWithTimezone": "Cada día {time} ({timezone})",
|
"sessions.scheduledTasks.dialog.schedule.dailyWithTimezone": "Cada día {time} ({timezone})",
|
||||||
"sessions.scheduledTasks.dialog.schedule.weekly": "Cada semana {days} {time}",
|
"sessions.scheduledTasks.dialog.schedule.weekly": "Cada semana {days} {time}",
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export const dict = {
|
|||||||
'sessions.scheduledTasks.dialog.toast.started': 'Tâche démarrée',
|
'sessions.scheduledTasks.dialog.toast.started': 'Tâche démarrée',
|
||||||
'sessions.scheduledTasks.dialog.toast.runFailed': 'Échec de l\'exécution de la tâche',
|
'sessions.scheduledTasks.dialog.toast.runFailed': 'Échec de l\'exécution de la tâche',
|
||||||
'sessions.scheduledTasks.dialog.confirm.deleteTask': 'Supprimer la tâche planifiée « {taskName} » ?',
|
'sessions.scheduledTasks.dialog.confirm.deleteTask': 'Supprimer la tâche planifiée « {taskName} » ?',
|
||||||
|
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': 'Supprimer la tâche en boucle « {taskName} » et son fichier Markdown ?',
|
||||||
'sessions.scheduledTasks.dialog.schedule.daily': 'Quotidien {time}',
|
'sessions.scheduledTasks.dialog.schedule.daily': 'Quotidien {time}',
|
||||||
'sessions.scheduledTasks.dialog.schedule.dailyWithTimezone': 'Quotidien {time} ({timezone})',
|
'sessions.scheduledTasks.dialog.schedule.dailyWithTimezone': 'Quotidien {time} ({timezone})',
|
||||||
'sessions.scheduledTasks.dialog.schedule.weekly': 'Hebdomadaire {days} {time}',
|
'sessions.scheduledTasks.dialog.schedule.weekly': 'Hebdomadaire {days} {time}',
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'sessions.scheduledTasks.dialog.toast.started': 'タスクを開始しました',
|
'sessions.scheduledTasks.dialog.toast.started': 'タスクを開始しました',
|
||||||
'sessions.scheduledTasks.dialog.toast.runFailed': 'タスクの実行に失敗しました',
|
'sessions.scheduledTasks.dialog.toast.runFailed': 'タスクの実行に失敗しました',
|
||||||
'sessions.scheduledTasks.dialog.confirm.deleteTask': 'スケジュールタスク「{taskName}」を削除しますか?',
|
'sessions.scheduledTasks.dialog.confirm.deleteTask': 'スケジュールタスク「{taskName}」を削除しますか?',
|
||||||
|
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': 'ループタスク「{taskName}」とその Markdown ファイルを削除しますか?',
|
||||||
'sessions.scheduledTasks.dialog.schedule.daily': '毎日 {time}',
|
'sessions.scheduledTasks.dialog.schedule.daily': '毎日 {time}',
|
||||||
'sessions.scheduledTasks.dialog.schedule.dailyWithTimezone': '毎日 {time}({timezone})',
|
'sessions.scheduledTasks.dialog.schedule.dailyWithTimezone': '毎日 {time}({timezone})',
|
||||||
'sessions.scheduledTasks.dialog.schedule.weekly': '毎週 {days} {time}',
|
'sessions.scheduledTasks.dialog.schedule.weekly': '毎週 {days} {time}',
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'sessions.scheduledTasks.dialog.toast.started': '작업 시작됨',
|
'sessions.scheduledTasks.dialog.toast.started': '작업 시작됨',
|
||||||
'sessions.scheduledTasks.dialog.toast.runFailed': '작업 실행 실패',
|
'sessions.scheduledTasks.dialog.toast.runFailed': '작업 실행 실패',
|
||||||
'sessions.scheduledTasks.dialog.confirm.deleteTask': '예약 작업 "{taskName}" 삭제?',
|
'sessions.scheduledTasks.dialog.confirm.deleteTask': '예약 작업 "{taskName}" 삭제?',
|
||||||
|
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': '루프 작업 "{taskName}" 및 해당 Markdown 파일을 삭제할까요?',
|
||||||
'sessions.scheduledTasks.dialog.schedule.daily': '매일 {time}',
|
'sessions.scheduledTasks.dialog.schedule.daily': '매일 {time}',
|
||||||
'sessions.scheduledTasks.dialog.schedule.dailyWithTimezone': '매일 {time} ({timezone})',
|
'sessions.scheduledTasks.dialog.schedule.dailyWithTimezone': '매일 {time} ({timezone})',
|
||||||
'sessions.scheduledTasks.dialog.schedule.weekly': '매주 {days} {time}',
|
'sessions.scheduledTasks.dialog.schedule.weekly': '매주 {days} {time}',
|
||||||
|
|||||||
@@ -361,6 +361,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'sessions.scheduledTasks.dialog.toast.started': 'Zadanie uruchomione',
|
'sessions.scheduledTasks.dialog.toast.started': 'Zadanie uruchomione',
|
||||||
'sessions.scheduledTasks.dialog.toast.runFailed': 'Nie udało się uruchomić zadania',
|
'sessions.scheduledTasks.dialog.toast.runFailed': 'Nie udało się uruchomić zadania',
|
||||||
'sessions.scheduledTasks.dialog.confirm.deleteTask': 'Usunąć zaplanowane zadanie "{taskName}"?',
|
'sessions.scheduledTasks.dialog.confirm.deleteTask': 'Usunąć zaplanowane zadanie "{taskName}"?',
|
||||||
|
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': 'Usunąć zadanie pętli „{taskName}” i jego plik Markdown?',
|
||||||
'sessions.scheduledTasks.dialog.schedule.daily': 'Codziennie {time}',
|
'sessions.scheduledTasks.dialog.schedule.daily': 'Codziennie {time}',
|
||||||
'sessions.scheduledTasks.dialog.schedule.dailyWithTimezone': 'Codziennie {time} ({timezone})',
|
'sessions.scheduledTasks.dialog.schedule.dailyWithTimezone': 'Codziennie {time} ({timezone})',
|
||||||
'sessions.scheduledTasks.dialog.schedule.weekly': 'Co tydzień {days} {time}',
|
'sessions.scheduledTasks.dialog.schedule.weekly': 'Co tydzień {days} {time}',
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"sessions.scheduledTasks.dialog.toast.started": "Tarefa iniciada",
|
"sessions.scheduledTasks.dialog.toast.started": "Tarefa iniciada",
|
||||||
"sessions.scheduledTasks.dialog.toast.runFailed": "Não foi possível executar a tarefa",
|
"sessions.scheduledTasks.dialog.toast.runFailed": "Não foi possível executar a tarefa",
|
||||||
"sessions.scheduledTasks.dialog.confirm.deleteTask": "Excluir tarefa agendada \"{taskName}\"?",
|
"sessions.scheduledTasks.dialog.confirm.deleteTask": "Excluir tarefa agendada \"{taskName}\"?",
|
||||||
|
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': 'Excluir a tarefa de loop “{taskName}” e seu arquivo Markdown?',
|
||||||
"sessions.scheduledTasks.dialog.schedule.daily": "Todos os dias {time}",
|
"sessions.scheduledTasks.dialog.schedule.daily": "Todos os dias {time}",
|
||||||
"sessions.scheduledTasks.dialog.schedule.dailyWithTimezone": "Todos os dias {time} ({timezone})",
|
"sessions.scheduledTasks.dialog.schedule.dailyWithTimezone": "Todos os dias {time} ({timezone})",
|
||||||
"sessions.scheduledTasks.dialog.schedule.weekly": "Toda semana {days} {time}",
|
"sessions.scheduledTasks.dialog.schedule.weekly": "Toda semana {days} {time}",
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"sessions.scheduledTasks.dialog.toast.started": "Завдання розпочато",
|
"sessions.scheduledTasks.dialog.toast.started": "Завдання розпочато",
|
||||||
"sessions.scheduledTasks.dialog.toast.runFailed": "Не вдалося виконати завдання",
|
"sessions.scheduledTasks.dialog.toast.runFailed": "Не вдалося виконати завдання",
|
||||||
"sessions.scheduledTasks.dialog.confirm.deleteTask": "Видалити заплановане завдання \"{taskName}\"?",
|
"sessions.scheduledTasks.dialog.confirm.deleteTask": "Видалити заплановане завдання \"{taskName}\"?",
|
||||||
|
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': 'Видалити циклічне завдання «{taskName}» і його Markdown-файл?',
|
||||||
"sessions.scheduledTasks.dialog.schedule.daily": "Щодня {time}",
|
"sessions.scheduledTasks.dialog.schedule.daily": "Щодня {time}",
|
||||||
"sessions.scheduledTasks.dialog.schedule.dailyWithTimezone": "Щодня {time} ({timezone})",
|
"sessions.scheduledTasks.dialog.schedule.dailyWithTimezone": "Щодня {time} ({timezone})",
|
||||||
"sessions.scheduledTasks.dialog.schedule.weekly": "Щотижня {days} {time}",
|
"sessions.scheduledTasks.dialog.schedule.weekly": "Щотижня {days} {time}",
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'sessions.scheduledTasks.dialog.toast.started': '任务已启动',
|
'sessions.scheduledTasks.dialog.toast.started': '任务已启动',
|
||||||
'sessions.scheduledTasks.dialog.toast.runFailed': '运行任务失败',
|
'sessions.scheduledTasks.dialog.toast.runFailed': '运行任务失败',
|
||||||
'sessions.scheduledTasks.dialog.confirm.deleteTask': '删除计划任务“{taskName}”?',
|
'sessions.scheduledTasks.dialog.confirm.deleteTask': '删除计划任务“{taskName}”?',
|
||||||
|
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': '删除循环任务“{taskName}”及其 Markdown 文件?',
|
||||||
'sessions.scheduledTasks.dialog.schedule.daily': '每天 {time}',
|
'sessions.scheduledTasks.dialog.schedule.daily': '每天 {time}',
|
||||||
'sessions.scheduledTasks.dialog.schedule.dailyWithTimezone': '每天 {time}({timezone})',
|
'sessions.scheduledTasks.dialog.schedule.dailyWithTimezone': '每天 {time}({timezone})',
|
||||||
'sessions.scheduledTasks.dialog.schedule.weekly': '每周 {days} {time}',
|
'sessions.scheduledTasks.dialog.schedule.weekly': '每周 {days} {time}',
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'sessions.scheduledTasks.dialog.toast.started': '任務已啟動',
|
'sessions.scheduledTasks.dialog.toast.started': '任務已啟動',
|
||||||
'sessions.scheduledTasks.dialog.toast.runFailed': '執行任務失敗',
|
'sessions.scheduledTasks.dialog.toast.runFailed': '執行任務失敗',
|
||||||
'sessions.scheduledTasks.dialog.confirm.deleteTask': '刪除排程任務「{taskName}」?',
|
'sessions.scheduledTasks.dialog.confirm.deleteTask': '刪除排程任務「{taskName}」?',
|
||||||
|
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': '刪除迴圈任務「{taskName}」及其 Markdown 檔案?',
|
||||||
'sessions.scheduledTasks.dialog.schedule.daily': '每天 {time}',
|
'sessions.scheduledTasks.dialog.schedule.daily': '每天 {time}',
|
||||||
'sessions.scheduledTasks.dialog.schedule.dailyWithTimezone': '每天 {time}({timezone})',
|
'sessions.scheduledTasks.dialog.schedule.dailyWithTimezone': '每天 {time}({timezone})',
|
||||||
'sessions.scheduledTasks.dialog.schedule.weekly': '每週 {days} {time}',
|
'sessions.scheduledTasks.dialog.schedule.weekly': '每週 {days} {time}',
|
||||||
|
|||||||
@@ -112,6 +112,37 @@ export const deleteScheduledTask = async (projectID: string, taskID: string): Pr
|
|||||||
return parsed.tasks as ScheduledTask[];
|
return parsed.tasks as ScheduledTask[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getLoopFileEndpoint = (projectID: string, taskID: string): string => {
|
||||||
|
const safeProjectID = ensureProjectID(projectID);
|
||||||
|
const safeTaskID = ensureProjectID(taskID);
|
||||||
|
return `/api/projects/${encodeURIComponent(safeProjectID)}/scheduled-tasks/${encodeURIComponent(safeTaskID)}/loop-file`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setLoopScheduledTaskEnabled = async (projectID: string, taskID: string, enabled: boolean): Promise<void> => {
|
||||||
|
const response = await runtimeFetch(getLoopFileEndpoint(projectID, taskID), {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await parseErrorMessage(response, 'Failed to update loop task'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteScheduledTaskLoopFile = async (projectID: string, taskID: string): Promise<void> => {
|
||||||
|
const response = await runtimeFetch(getLoopFileEndpoint(projectID, taskID), {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await parseErrorMessage(response, 'Failed to delete loop file'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const syncScheduledTaskLoops = async (projectID: string): Promise<void> => {
|
||||||
|
await fetchScheduledTasks(projectID);
|
||||||
|
};
|
||||||
|
|
||||||
export const runScheduledTaskNow = async (projectID: string, taskID: string): Promise<{ sessionId?: string }> => {
|
export const runScheduledTaskNow = async (projectID: string, taskID: string): Promise<{ sessionId?: string }> => {
|
||||||
const safeProjectID = ensureProjectID(projectID);
|
const safeProjectID = ensureProjectID(projectID);
|
||||||
const safeTaskID = ensureProjectID(taskID);
|
const safeTaskID = ensureProjectID(taskID);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ Server-owned scheduled task runtime and routes for OpenChamber-only automation.
|
|||||||
- `packages/web/server/lib/scheduled-tasks/routes.js`
|
- `packages/web/server/lib/scheduled-tasks/routes.js`
|
||||||
- Scheduled task CRUD endpoints
|
- Scheduled task CRUD endpoints
|
||||||
- Listing tasks reconciles loop files first, so opening the Scheduled Tasks UI discovers file additions, edits, and removals without a server restart
|
- Listing tasks reconciles loop files first, so opening the Scheduled Tasks UI discovers file additions, edits, and removals without a server restart
|
||||||
|
- Loop-file endpoints toggle `enabled` in frontmatter or delete the authoritative markdown file, then reconcile the project
|
||||||
- Manual run endpoint
|
- Manual run endpoint
|
||||||
- OpenChamber events SSE stream endpoint
|
- OpenChamber events SSE stream endpoint
|
||||||
|
|
||||||
@@ -94,14 +95,12 @@ project write lock on every `syncProject` when the project path is known:
|
|||||||
- **Malformed files** (missing `name`/`schedule`/`model`/body, invalid cron,
|
- **Malformed files** (missing `name`/`schedule`/`model`/body, invalid cron,
|
||||||
unreadable) are reported to the scheduler as `definition: null` entries and
|
unreadable) are reported to the scheduler as `definition: null` entries and
|
||||||
warned about; they never block valid loops in the same or other scopes.
|
warned about; they never block valid loops in the same or other scopes.
|
||||||
- **UI edits** to a loop-sourced task are preserved in the config but the loop
|
- **Loop-file mutations.** The loop file remains authoritative. The scheduled-
|
||||||
file remains authoritative: the next reconciliation re-applies the file's
|
tasks UI opens it in the built-in file editor, updates its `enabled`
|
||||||
definition (including `enabled`). Use `enabled: false` in the file to
|
frontmatter through the loop-file endpoint, and deletes the file through the
|
||||||
disable. Deleting a loop-sourced task through the API is rejected with a 400
|
loop-file endpoint after confirmation. Each mutation reconciles the project.
|
||||||
while its loop file still exists on disk — the loop file is the removal
|
The general task deletion API still rejects loop-sourced tasks while their
|
||||||
surface; once the file is gone, deleting the orphan task is allowed. The
|
file exists; once the file is gone, deleting an orphan task is allowed.
|
||||||
scheduled-tasks UI marks loop tasks as file-managed and disables their
|
|
||||||
edit/enable/delete actions for the same reason; `run now` remains available.
|
|
||||||
|
|
||||||
## Public exports (runtime.js)
|
## Public exports (runtime.js)
|
||||||
|
|
||||||
@@ -120,6 +119,8 @@ project write lock on every `syncProject` when the project path is known:
|
|||||||
- `GET /api/projects/:projectId/scheduled-tasks`
|
- `GET /api/projects/:projectId/scheduled-tasks`
|
||||||
- `PUT /api/projects/:projectId/scheduled-tasks`
|
- `PUT /api/projects/:projectId/scheduled-tasks`
|
||||||
- `DELETE /api/projects/:projectId/scheduled-tasks/:taskId`
|
- `DELETE /api/projects/:projectId/scheduled-tasks/:taskId`
|
||||||
|
- `PATCH /api/projects/:projectId/scheduled-tasks/:taskId/loop-file`
|
||||||
|
- `DELETE /api/projects/:projectId/scheduled-tasks/:taskId/loop-file`
|
||||||
- `POST /api/projects/:projectId/scheduled-tasks/:taskId/run`
|
- `POST /api/projects/:projectId/scheduled-tasks/:taskId/run`
|
||||||
- `GET /api/openchamber/scheduled-tasks/status`
|
- `GET /api/openchamber/scheduled-tasks/status`
|
||||||
- `GET /api/openchamber/events`
|
- `GET /api/openchamber/events`
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { parseMdFile, getAncestors, findWorktreeRoot } from '../opencode/shared.js';
|
import { parseMdFile, writeMdFile, getAncestors, findWorktreeRoot } from '../opencode/shared.js';
|
||||||
import { MAX_TASK_NAME_LENGTH } from '../projects/project-config.js';
|
import { MAX_TASK_NAME_LENGTH } from '../projects/project-config.js';
|
||||||
|
|
||||||
const LOOP_DIR_NAME = 'loops';
|
const LOOP_DIR_NAME = 'loops';
|
||||||
@@ -140,6 +140,15 @@ export const parseLoopDefinition = (filePath) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const setLoopFileEnabled = (filePath, enabled) => {
|
||||||
|
if (!parseLoopDefinition(filePath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { frontmatter, body } = parseMdFile(filePath);
|
||||||
|
writeMdFile(filePath, { ...frontmatter, enabled: Boolean(enabled) }, body);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const walkLoopMdFiles = (rootDir) => {
|
const walkLoopMdFiles = (rootDir) => {
|
||||||
if (!rootDir || !fs.existsSync(rootDir)) {
|
if (!rootDir || !fs.existsSync(rootDir)) {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -81,6 +81,35 @@ export const registerScheduledTaskRoutes = (app, dependencies) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.patch('/api/projects/:projectId/scheduled-tasks/:taskId/loop-file', async (req, res) => {
|
||||||
|
const projectID = parseProjectID(req);
|
||||||
|
const taskID = parseTaskID(req);
|
||||||
|
if (!projectID) return res.status(400).json({ error: 'projectId is required' });
|
||||||
|
if (!taskID) return res.status(400).json({ error: 'taskId is required' });
|
||||||
|
try {
|
||||||
|
const task = await scheduledTaskService.setLoopEnabled(projectID, taskID, req.body?.enabled);
|
||||||
|
return res.json({ task });
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.statusCode) return res.status(error.statusCode).json({ error: error.message });
|
||||||
|
console.error('[ScheduledTasks] failed to update loop file:', error);
|
||||||
|
return res.status(500).json({ error: 'Failed to update loop file' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/projects/:projectId/scheduled-tasks/:taskId/loop-file', async (req, res) => {
|
||||||
|
const projectID = parseProjectID(req);
|
||||||
|
const taskID = parseTaskID(req);
|
||||||
|
if (!projectID) return res.status(400).json({ error: 'projectId is required' });
|
||||||
|
if (!taskID) return res.status(400).json({ error: 'taskId is required' });
|
||||||
|
try {
|
||||||
|
return res.json({ tasks: await scheduledTaskService.removeLoopFile(projectID, taskID) });
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.statusCode) return res.status(error.statusCode).json({ error: error.message });
|
||||||
|
console.error('[ScheduledTasks] failed to delete loop file:', error);
|
||||||
|
return res.status(500).json({ error: 'Failed to delete loop file' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/projects/:projectId/scheduled-tasks/:taskId/run', async (req, res) => {
|
app.post('/api/projects/:projectId/scheduled-tasks/:taskId/run', async (req, res) => {
|
||||||
const projectID = parseProjectID(req);
|
const projectID = parseProjectID(req);
|
||||||
const taskID = parseTaskID(req);
|
const taskID = parseTaskID(req);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { OpenChamberControlError } from '../openchamber-control/error.js';
|
import { OpenChamberControlError } from '../openchamber-control/error.js';
|
||||||
|
import { setLoopFileEnabled } from './loops.js';
|
||||||
|
|
||||||
const asNonEmptyString = (value) => {
|
const asNonEmptyString = (value) => {
|
||||||
if (typeof value !== 'string') return null;
|
if (typeof value !== 'string') return null;
|
||||||
@@ -53,6 +54,47 @@ export const createScheduledTaskService = (dependencies) => {
|
|||||||
return scheduledTasksRuntime.syncProject(projectID);
|
return scheduledTasksRuntime.syncProject(projectID);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const findLoopTask = async (projectID, taskID) => {
|
||||||
|
await findProjectByID(projectID);
|
||||||
|
const normalizedTaskID = asNonEmptyString(taskID);
|
||||||
|
if (!normalizedTaskID) throw new OpenChamberControlError('taskId is required', 400);
|
||||||
|
const tasks = await scheduledTasksRuntime.syncProject(projectID);
|
||||||
|
const task = tasks.find((entry) => entry?.id === normalizedTaskID) || null;
|
||||||
|
if (!task) throw new OpenChamberControlError('Task not found', 404);
|
||||||
|
if (!task.loopFile) throw new OpenChamberControlError('Task is not managed by a loop file', 400);
|
||||||
|
if (!fs.existsSync(task.loopFile)) throw new OpenChamberControlError('Loop file not found', 404);
|
||||||
|
return task;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setLoopEnabled = async (projectID, taskID, enabled) => {
|
||||||
|
if (typeof enabled !== 'boolean') {
|
||||||
|
throw new OpenChamberControlError('enabled must be a boolean', 400);
|
||||||
|
}
|
||||||
|
const task = await findLoopTask(projectID, taskID);
|
||||||
|
try {
|
||||||
|
if (!setLoopFileEnabled(task.loopFile, enabled)) {
|
||||||
|
throw new OpenChamberControlError('Loop file must be valid before changing its enabled state', 400);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof OpenChamberControlError) throw error;
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to update loop file';
|
||||||
|
throw new OpenChamberControlError(message, 500);
|
||||||
|
}
|
||||||
|
const tasks = await scheduledTasksRuntime.syncProject(projectID);
|
||||||
|
return tasks.find((entry) => entry.id === taskID) || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeLoopFile = async (projectID, taskID) => {
|
||||||
|
const task = await findLoopTask(projectID, taskID);
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(task.loopFile);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to delete loop file';
|
||||||
|
throw new OpenChamberControlError(message, 500);
|
||||||
|
}
|
||||||
|
return scheduledTasksRuntime.syncProject(projectID);
|
||||||
|
};
|
||||||
|
|
||||||
const upsert = async (projectID, taskInput) => {
|
const upsert = async (projectID, taskInput) => {
|
||||||
await findProjectByID(projectID);
|
await findProjectByID(projectID);
|
||||||
if (!taskInput || typeof taskInput !== 'object') {
|
if (!taskInput || typeof taskInput !== 'object') {
|
||||||
@@ -154,6 +196,8 @@ export const createScheduledTaskService = (dependencies) => {
|
|||||||
remove,
|
remove,
|
||||||
run,
|
run,
|
||||||
setEnabled,
|
setEnabled,
|
||||||
|
setLoopEnabled,
|
||||||
|
removeLoopFile,
|
||||||
status,
|
status,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { mkdtemp, rm, writeFile } from 'fs/promises';
|
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
|
||||||
import { createScheduledTaskService } from './service.js';
|
import { createScheduledTaskService } from './service.js';
|
||||||
|
import { registerScheduledTaskRoutes } from './routes.js';
|
||||||
|
|
||||||
const createService = (overrides = {}) => {
|
const createService = (overrides = {}) => {
|
||||||
const projectConfigRuntime = {
|
const projectConfigRuntime = {
|
||||||
@@ -64,6 +65,140 @@ describe('scheduled-task service list', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('scheduled-task loop-file mutations', () => {
|
||||||
|
it('updates only enabled in loop frontmatter and reconciles the task', async () => {
|
||||||
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-loop-toggle-'));
|
||||||
|
try {
|
||||||
|
const loopFilePath = path.join(tempRoot, 'daily.md');
|
||||||
|
await writeFile(loopFilePath, `---
|
||||||
|
name: daily-digest
|
||||||
|
schedule: "0 9 * * *"
|
||||||
|
enabled: true
|
||||||
|
model: openai/gpt-5
|
||||||
|
custom: keep-me
|
||||||
|
---
|
||||||
|
|
||||||
|
Run the digest.
|
||||||
|
`, 'utf8');
|
||||||
|
const currentTask = { ...loopTask, loopFile: loopFilePath };
|
||||||
|
const updatedTask = { ...currentTask, enabled: false };
|
||||||
|
const syncProject = vi.fn()
|
||||||
|
.mockResolvedValueOnce([currentTask])
|
||||||
|
.mockResolvedValueOnce([updatedTask]);
|
||||||
|
const { service } = createService({ scheduledTasksRuntime: { syncProject } });
|
||||||
|
|
||||||
|
await expect(service.setLoopEnabled('project-test', currentTask.id, false)).resolves.toEqual(updatedTask);
|
||||||
|
|
||||||
|
const content = await readFile(loopFilePath, 'utf8');
|
||||||
|
expect(content).toContain('enabled: false');
|
||||||
|
expect(content).toContain('custom: keep-me');
|
||||||
|
expect(content).toContain('Run the digest.');
|
||||||
|
expect(syncProject).toHaveBeenCalledTimes(2);
|
||||||
|
} finally {
|
||||||
|
await rm(tempRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes the authoritative loop file and reconciles the task away', async () => {
|
||||||
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-loop-remove-'));
|
||||||
|
try {
|
||||||
|
const loopFilePath = path.join(tempRoot, 'daily.md');
|
||||||
|
await writeFile(loopFilePath, 'loop', 'utf8');
|
||||||
|
const currentTask = { ...loopTask, loopFile: loopFilePath };
|
||||||
|
const syncProject = vi.fn()
|
||||||
|
.mockResolvedValueOnce([currentTask])
|
||||||
|
.mockResolvedValueOnce([]);
|
||||||
|
const { service } = createService({ scheduledTasksRuntime: { syncProject } });
|
||||||
|
|
||||||
|
await expect(service.removeLoopFile('project-test', currentTask.id)).resolves.toEqual([]);
|
||||||
|
await expect(readFile(loopFilePath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
||||||
|
expect(syncProject).toHaveBeenCalledTimes(2);
|
||||||
|
} finally {
|
||||||
|
await rm(tempRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not rewrite a malformed loop when toggling', async () => {
|
||||||
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-loop-invalid-'));
|
||||||
|
try {
|
||||||
|
const loopFilePath = path.join(tempRoot, 'daily.md');
|
||||||
|
const malformed = '---\nname: daily-digest\n---\nRun.\n';
|
||||||
|
await writeFile(loopFilePath, malformed, 'utf8');
|
||||||
|
const currentTask = { ...loopTask, loopFile: loopFilePath };
|
||||||
|
const syncProject = vi.fn(async () => [currentTask]);
|
||||||
|
const { service } = createService({ scheduledTasksRuntime: { syncProject } });
|
||||||
|
|
||||||
|
await expect(service.setLoopEnabled('project-test', currentTask.id, false)).rejects.toMatchObject({ statusCode: 400 });
|
||||||
|
await expect(readFile(loopFilePath, 'utf8')).resolves.toBe(malformed);
|
||||||
|
expect(syncProject).toHaveBeenCalledOnce();
|
||||||
|
} finally {
|
||||||
|
await rm(tempRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scheduled-task loop-file routes', () => {
|
||||||
|
const createResponse = () => ({
|
||||||
|
statusCode: 200,
|
||||||
|
payload: null,
|
||||||
|
status(code) {
|
||||||
|
this.statusCode = code;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
json(payload) {
|
||||||
|
this.payload = payload;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const captureHandlers = (scheduledTaskService) => {
|
||||||
|
const handlers = new Map();
|
||||||
|
const app = {
|
||||||
|
get: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
patch: vi.fn((route, handler) => handlers.set(`PATCH ${route}`, handler)),
|
||||||
|
delete: vi.fn((route, handler) => handlers.set(`DELETE ${route}`, handler)),
|
||||||
|
};
|
||||||
|
registerScheduledTaskRoutes(app, {
|
||||||
|
scheduledTaskService,
|
||||||
|
readSettingsFromDiskMigrated: vi.fn(),
|
||||||
|
sanitizeProjects: vi.fn(),
|
||||||
|
projectConfigRuntime: {},
|
||||||
|
scheduledTasksRuntime: {},
|
||||||
|
getOpenChamberEventClients: () => new Set(),
|
||||||
|
writeSseEvent: vi.fn(),
|
||||||
|
});
|
||||||
|
return handlers;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('routes loop enabled changes through the loop-file service', async () => {
|
||||||
|
const setLoopEnabled = vi.fn(async () => ({ ...loopTask, enabled: false }));
|
||||||
|
const handlers = captureHandlers({ setLoopEnabled });
|
||||||
|
const handler = handlers.get('PATCH /api/projects/:projectId/scheduled-tasks/:taskId/loop-file');
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await handler({ params: { projectId: 'project-test', taskId: loopTask.id }, body: { enabled: false } }, res);
|
||||||
|
|
||||||
|
expect(setLoopEnabled).toHaveBeenCalledWith('project-test', loopTask.id, false);
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.payload.task.enabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes loop deletion through the loop-file service', async () => {
|
||||||
|
const removeLoopFile = vi.fn(async () => []);
|
||||||
|
const handlers = captureHandlers({ removeLoopFile });
|
||||||
|
const handler = handlers.get('DELETE /api/projects/:projectId/scheduled-tasks/:taskId/loop-file');
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await handler({ params: { projectId: 'project-test', taskId: loopTask.id } }, res);
|
||||||
|
|
||||||
|
expect(removeLoopFile).toHaveBeenCalledWith('project-test', loopTask.id);
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.payload).toEqual({ tasks: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('scheduled-task service remove', () => {
|
describe('scheduled-task service remove', () => {
|
||||||
it('rejects deleting a loop-sourced task while its loop file still exists', async () => {
|
it('rejects deleting a loop-sourced task while its loop file still exists', async () => {
|
||||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-loop-delete-'));
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-loop-delete-'));
|
||||||
|
|||||||
Reference in New Issue
Block a user