diff --git a/packages/docs/content/docs/scheduled-tasks.mdx b/packages/docs/content/docs/scheduled-tasks.mdx index e7753190..4f5a6554 100644 --- a/packages/docs/content/docs/scheduled-tasks.mdx +++ b/packages/docs/content/docs/scheduled-tasks.mdx @@ -15,6 +15,7 @@ A scheduled task runs a prompt for you on a schedule — for example, a daily "s - **daily** — at one or more times each day - **weekly** — on chosen weekdays and times - **once** — a single date and time + - **cron** — an arbitrary cron expression 4. Set what it does: the prompt to send, and the provider, model, and agent to use. The prompt can be a slash command, like `/review`. 5. Save, and make sure the task is enabled. @@ -22,6 +23,48 @@ You can run any task immediately with **run now** to check it does what you expe Check **Run as goal** to make the run pursue its prompt to completion instead of stopping after one reply — see [Session Goals](/session-goals/). +## Loops: scheduled tasks as markdown files + +A **loop** is a scheduled task defined as a portable markdown file you can commit to your repo. Drop a file into `.agents/loops/` and the task appears on the next sync — no dialog needed: + +```markdown +--- +name: daily-digest +schedule: "0 9 * * *" +enabled: true +model: anthropic/claude-sonnet-4-5 +agent: plan +timezone: Europe/Kyiv +--- +Summarize repository changes since yesterday and post the digest. +``` + +### Where files live + +- **Project scope** — `.agents/loops/*.md` in the project directory or any ancestor directory up to the git worktree root. +- **User scope** — `~/.agents/loops/*.md` applies to every project you open. + +If a project loop and a user loop share a name, the project loop wins. + +### Fields + +| Field | Meaning | +|---|---| +| `name` | Task name (required, max 80 characters). | +| `schedule` | Cron expression (required) — loop files are cron-only. | +| `enabled` | Set `true` to run. Loops are **off by default**, so committing a file never starts running a task on its own. | +| `model` | `provider/model` (required), e.g. `anthropic/claude-sonnet-4-5`. | +| `agent` | Agent to use (optional). | +| `timezone` | IANA timezone (optional, defaults to the server zone). | +| body | The execution prompt (required). Can be a slash command, like `/review src/`. | + +### 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`). +- 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. +- `daily`/`weekly`/`once` schedules and goal settings remain UI-only; loop files are always cron. + ## What success looks like After a run, the task shows when it last ran, whether it succeeded, and a link to the session it created. If a run fails, the error is shown there too. diff --git a/packages/ui/src/components/session/ScheduledTasksDialog.tsx b/packages/ui/src/components/session/ScheduledTasksDialog.tsx index c75e677e..6590e67b 100644 --- a/packages/ui/src/components/session/ScheduledTasksDialog.tsx +++ b/packages/ui/src/components/session/ScheduledTasksDialog.tsx @@ -463,6 +463,14 @@ export function ScheduledTasksDialog() {
{formatSchedule(task, t)}
+ {task.loopFile ? ( +
+ {t('sessions.scheduledTasks.dialog.loopFile.note', { file: task.loopFile })} +
+ ) : null}
@@ -525,8 +533,11 @@ export function ScheduledTasksDialog() { className={cn( 'inline-flex cursor-pointer items-center gap-2 typography-micro font-medium', task.enabled ? 'text-foreground' : 'text-muted-foreground', - isBusy && 'cursor-not-allowed opacity-50', + (isBusy || task.loopFile) && 'cursor-not-allowed opacity-50', )} + title={task.loopFile + ? t('sessions.scheduledTasks.dialog.loopFile.toggleDisabled') + : undefined} > {task.enabled ? t('sessions.scheduledTasks.dialog.taskToggle.enabled') : t('sessions.scheduledTasks.dialog.taskToggle.paused')} @@ -555,7 +566,10 @@ export function ScheduledTasksDialog() { setEditorTask(task); setEditorOpen(true); }} - disabled={isBusy} + 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 })} > {t('sessions.scheduledTasks.dialog.actions.edit')} @@ -564,7 +578,10 @@ export function ScheduledTasksDialog() { variant="destructive" size="sm" onClick={() => void handleDeleteTask(task)} - disabled={isBusy} + disabled={isBusy || Boolean(task.loopFile)} + title={task.loopFile + ? t('sessions.scheduledTasks.dialog.loopFile.actionsDisabled') + : undefined} aria-label={t('sessions.scheduledTasks.dialog.actions.deleteAria', { taskName: task.name })} > diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 0814ed97..4bda736a 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -248,6 +248,9 @@ export const dict = { 'sessions.scheduledTasks.dialog.taskToggle.pauseAria': '{taskName} pausieren', 'sessions.scheduledTasks.dialog.taskToggle.enabled': 'Aktiviert', 'sessions.scheduledTasks.dialog.taskToggle.paused': 'Pausiert', + 'sessions.scheduledTasks.dialog.loopFile.note': 'Von Loop-Datei verwaltet {file}', + 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': 'Aktiviert wird durch die Loop-Datei gesteuert; setze enabled im Markdown-Frontmatter', + 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Loop-Aufgaben werden in ihrer .agents/loops-Markdown-Datei konfiguriert', 'sessions.scheduledTasks.editor.title.edit': 'Geplante Aufgabe bearbeiten', 'sessions.scheduledTasks.editor.title.new': 'Neue geplante Aufgabe', 'sessions.scheduledTasks.editor.description': 'Konfigurieren Sie eine serverseitige Aufgabe, die eine neue Sitzung erstellt und eine Eingabeaufforderung sendet.', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 895faaae..2ba921aa 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -268,6 +268,9 @@ export const dict = { 'sessions.scheduledTasks.dialog.taskToggle.pauseAria': 'Pause {taskName}', 'sessions.scheduledTasks.dialog.taskToggle.enabled': 'Enabled', 'sessions.scheduledTasks.dialog.taskToggle.paused': 'Paused', + 'sessions.scheduledTasks.dialog.loopFile.note': 'Managed by loop file {file}', + 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': 'Enabled is controlled by the loop file; set enabled in the markdown frontmatter', + 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Loop tasks are configured in their .agents/loops markdown file', 'sessions.scheduledTasks.editor.title.edit': 'Edit scheduled task', 'sessions.scheduledTasks.editor.title.new': 'New scheduled task', 'sessions.scheduledTasks.editor.description': 'Configure a server-side task that creates a new session and sends a prompt.', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 21bcd484..8b2a1342 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -269,6 +269,9 @@ export const dict: Record = { "sessions.scheduledTasks.dialog.taskToggle.pauseAria": "Pausar {taskName}", "sessions.scheduledTasks.dialog.taskToggle.enabled": "Habilitado", "sessions.scheduledTasks.dialog.taskToggle.paused": "Pausado", + 'sessions.scheduledTasks.dialog.loopFile.note': 'Gestionada por el archivo de bucle {file}', + 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': 'La activación la controla el archivo de bucle; establece enabled en el frontmatter de Markdown', + 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Las tareas de bucle se configuran en su archivo Markdown .agents/loops', "sessions.scheduledTasks.editor.title.edit": "Editar tarea programada", "sessions.scheduledTasks.editor.title.new": "Nueva tarea programada", "sessions.scheduledTasks.editor.description": "Configura una tarea del lado del servidor que crea una nueva sesión y envía un prompt.", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index a94c8ff7..f9465c52 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -105,6 +105,9 @@ export const dict = { 'sessions.scheduledTasks.dialog.taskToggle.pauseAria': 'Pause {taskName}', 'sessions.scheduledTasks.dialog.taskToggle.enabled': 'Activé', 'sessions.scheduledTasks.dialog.taskToggle.paused': 'En pause', + 'sessions.scheduledTasks.dialog.loopFile.note': 'Gérée par le fichier de boucle {file}', + 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': "L'activation est contrôlée par le fichier de boucle ; définissez enabled dans le frontmatter Markdown", + 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Les tâches de boucle sont configurées dans leur fichier Markdown .agents/loops', 'sessions.scheduledTasks.editor.title.edit': 'Modifier une tâche planifiée', 'sessions.scheduledTasks.editor.title.new': 'Nouvelle tâche planifiée', 'sessions.scheduledTasks.editor.description': 'Configurez une tâche côté serveur qui crée une nouvelle session et envoie un prompt.', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index a135d9e5..d2c1fed8 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -269,6 +269,9 @@ export const dict: Record = { 'sessions.scheduledTasks.dialog.taskToggle.pauseAria': '{taskName}を一時停止', 'sessions.scheduledTasks.dialog.taskToggle.enabled': '有効', 'sessions.scheduledTasks.dialog.taskToggle.paused': '一時停止中', + 'sessions.scheduledTasks.dialog.loopFile.note': 'ループファイル {file} によって管理', + 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': '有効状態はループファイルが制御します。Markdown フロントマターで enabled を設定してください', + 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'ループタスクは .agents/loops の Markdown ファイルで設定します', 'sessions.scheduledTasks.editor.title.edit': 'スケジュールタスクを編集', 'sessions.scheduledTasks.editor.title.new': '新しいスケジュールタスク', 'sessions.scheduledTasks.editor.description': '新しいセッションを作成しプロンプトを送信するサーバーサイドタスクを設定します。', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 84c41bcc..302fc278 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -269,6 +269,9 @@ export const dict: Record = { 'sessions.scheduledTasks.dialog.taskToggle.pauseAria': '{taskName} 일시 중지', 'sessions.scheduledTasks.dialog.taskToggle.enabled': '활성화됨', 'sessions.scheduledTasks.dialog.taskToggle.paused': '일시 중지됨', + 'sessions.scheduledTasks.dialog.loopFile.note': '루프 파일에서 관리됨: {file}', + 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': '활성화 여부는 루프 파일이 제어합니다. Markdown frontmatter에서 enabled를 설정하세요', + 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': '루프 작업은 .agents/loops Markdown 파일에서 구성합니다', 'sessions.scheduledTasks.editor.title.edit': '예약 작업 편집', 'sessions.scheduledTasks.editor.title.new': '새 예약 작업', 'sessions.scheduledTasks.editor.description': '새 세션을 만들고 프롬프트를 보내는 서버 작업을 설정합니다.', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 3e1cb4e4..a8dacf18 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -396,6 +396,9 @@ export const dict: Record = { 'sessions.scheduledTasks.dialog.taskToggle.pauseAria': 'Wstrzymaj {taskName}', 'sessions.scheduledTasks.dialog.taskToggle.enabled': 'Włączone', 'sessions.scheduledTasks.dialog.taskToggle.paused': 'Wstrzymane', + 'sessions.scheduledTasks.dialog.loopFile.note': 'Zarządzane przez plik pętli {file}', + 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': 'Włączenie jest kontrolowane przez plik pętli; ustaw enabled w frontmatterze Markdown', + 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Zadania pętli są konfigurowane w pliku Markdown .agents/loops', 'sessions.scheduledTasks.editor.title.edit': 'Edytuj zaplanowane zadanie', 'sessions.scheduledTasks.editor.title.new': 'Nowe zaplanowane zadanie', 'sessions.scheduledTasks.editor.description': 'Skonfiguruj zadanie po stronie serwera, które tworzy nową sesję i wysyła prompt.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 333666ce..1387d286 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -269,6 +269,9 @@ export const dict: Record = { "sessions.scheduledTasks.dialog.taskToggle.pauseAria": "Pausar {taskName}", "sessions.scheduledTasks.dialog.taskToggle.enabled": "Ativado", "sessions.scheduledTasks.dialog.taskToggle.paused": "Pausado", + 'sessions.scheduledTasks.dialog.loopFile.note': 'Gerenciada pelo arquivo de loop {file}', + 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': 'A ativação é controlada pelo arquivo de loop; defina enabled no frontmatter Markdown', + 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Tarefas de loop são configuradas no arquivo Markdown .agents/loops', "sessions.scheduledTasks.editor.title.edit": "Editar tarefa agendada", "sessions.scheduledTasks.editor.title.new": "Nova tarefa agendada", "sessions.scheduledTasks.editor.description": "Configure uma tarefa do lado do servidor que cria uma nova sessão e envia um prompt.", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 2680f0c3..3856769b 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -269,6 +269,9 @@ export const dict: Record = { "sessions.scheduledTasks.dialog.taskToggle.pauseAria": "Призупинити {taskName}", "sessions.scheduledTasks.dialog.taskToggle.enabled": "Увімкнено", "sessions.scheduledTasks.dialog.taskToggle.paused": "Призупинено", + 'sessions.scheduledTasks.dialog.loopFile.note': 'Керується файлом циклу {file}', + 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': 'Активність контролюється файлом циклу; встановіть enabled у frontmatter Markdown', + 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Завдання циклів налаштовуються у файлі Markdown .agents/loops', "sessions.scheduledTasks.editor.title.edit": "Редагувати заплановане завдання", "sessions.scheduledTasks.editor.title.new": "Нове заплановане завдання", "sessions.scheduledTasks.editor.description": "Налаштувати завдання на стороні сервера, яке створює нову сесію і надсилає запит.", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 5a36af20..bb328a56 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -269,6 +269,9 @@ export const dict: Record = { 'sessions.scheduledTasks.dialog.taskToggle.pauseAria': '暂停 {taskName}', 'sessions.scheduledTasks.dialog.taskToggle.enabled': '已启用', 'sessions.scheduledTasks.dialog.taskToggle.paused': '已暂停', + 'sessions.scheduledTasks.dialog.loopFile.note': '由循环文件 {file} 管理', + 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': '启用状态由循环文件控制;请在 Markdown frontmatter 中设置 enabled', + 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': '循环任务在其 .agents/loops Markdown 文件中配置', 'sessions.scheduledTasks.editor.title.edit': '编辑计划任务', 'sessions.scheduledTasks.editor.title.new': '新建计划任务', 'sessions.scheduledTasks.editor.description': '配置一个服务端任务,用于创建新会话并发送提示词。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 846aa35c..584f0590 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -282,6 +282,9 @@ export const dict: Record = { 'sessions.scheduledTasks.dialog.taskToggle.pauseAria': '暫停 {taskName}', 'sessions.scheduledTasks.dialog.taskToggle.enabled': '已啟用', 'sessions.scheduledTasks.dialog.taskToggle.paused': '已暫停', + 'sessions.scheduledTasks.dialog.loopFile.note': '由迴圈檔案 {file} 管理', + 'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': '啟用狀態由迴圈檔案控制;請在 Markdown frontmatter 中設定 enabled', + 'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': '迴圈任務在其 .agents/loops Markdown 檔案中設定', 'sessions.scheduledTasks.editor.title.edit': '編輯排程任務', 'sessions.scheduledTasks.editor.title.new': '新增排程任務', 'sessions.scheduledTasks.editor.description': '設定一個伺服器端任務,用於建立新會話並傳送提示詞。', diff --git a/packages/ui/src/lib/scheduledTasksApi.ts b/packages/ui/src/lib/scheduledTasksApi.ts index 8b215d64..c7c4aeb1 100644 --- a/packages/ui/src/lib/scheduledTasksApi.ts +++ b/packages/ui/src/lib/scheduledTasksApi.ts @@ -6,6 +6,9 @@ export type ScheduledTask = { id: string; name: string; enabled: boolean; + /** Absolute path of the `.agents/loops/*.md` file driving this task, when + * any. Present only for loop-sourced tasks; unknown to older clients. */ + loopFile?: string; schedule: { kind: 'daily' | 'weekly' | 'once' | 'cron'; times?: string[]; diff --git a/packages/web/server/lib/projects/project-config.js b/packages/web/server/lib/projects/project-config.js index ed8d98b6..ab92383e 100644 --- a/packages/web/server/lib/projects/project-config.js +++ b/packages/web/server/lib/projects/project-config.js @@ -2,7 +2,7 @@ import { DateTime, IANAZone } from 'luxon'; import parser from 'cron-parser'; const PROJECT_CONFIG_VERSION = 1; -const MAX_TASK_NAME_LENGTH = 80; +export const MAX_TASK_NAME_LENGTH = 80; const MAX_TASK_PROMPT_LENGTH = 20_000; const MAX_CRON_LENGTH = 200; const MAX_LAST_ERROR_LENGTH = 2_000; @@ -313,6 +313,11 @@ const normalizeTaskForStorage = (value, options) => { const schedule = normalizeSchedule(value.schedule, existingTask?.schedule); const execution = normalizeExecution(value.execution); + // Loop provenance: absolute path of the `.agents/loops/*.md` file driving + // this task, when any. Preserved on every write so the scheduler can detect + // removed loop files across restarts. Unknown to the UI model. + const loopFile = asNonEmptyString(value.loopFile) ?? asNonEmptyString(existingTask?.loopFile); + const nowMs = Math.max(0, Math.round(now)); const baseState = normalizeState(value.state, existingTask?.state); const state = { @@ -328,6 +333,7 @@ const normalizeTaskForStorage = (value, options) => { schedule, execution, state, + ...(loopFile ? { loopFile } : {}), }; }; @@ -559,11 +565,141 @@ export const createProjectConfigRuntime = (deps) => { }); }; + /** + * Reconcile discovered `.agents/loops` definitions with the persisted JSON + * task list. + * + * Rules (documented in scheduled-tasks/DOCUMENTATION.md): + * - For loop-owned tasks (carrying the `loopFile` marker) identity is the + * LOOP FILE PATH: a loop takes its task over regardless of the task's + * current name, so renaming the loop (`name` field or a UI edit) renames + * the task in place instead of leaving a stale duplicate behind. + * - A loop whose name matches a JSON task (no `loopFile`) takes that task + * over: its schedule/execution/enabled are overwritten from the file while + * its id and runtime state are preserved (markdown wins on conflict). + * Execution fields the file format does not define (goalEnabled, + * goalTokenBudget, permissionAutoAccept, variant) are preserved. + * - A task whose loopFile no longer matches any discovered loop file is + * unscheduled (removed). JSON-configured tasks (no loopFile) are never + * removed. + * - A task whose loop file still exists but is currently unparseable is + * KEPT with its last good definition: only a genuinely removed file + * unschedules a task, so transiently malformed files (mid-edit, bad + * merge) never delete tasks or their runtime state. + * - Loops with no matching task are created under a deterministic + * `loop::` id, so runtime state survives restarts. + * - Malformed definitions are skipped with a warning and never block valid + * loops; the scheduler passes them as `definition: null` entries, and + * normalization failures here are isolated per loop. + */ + const reconcileLoopTasks = async (projectID, loops) => { + return withProjectWriteLock(projectID, async () => { + const now = Date.now(); + const current = await readProjectConfigFromDisk(projectID); + const tasks = current.scheduledTasks; + + const activeLoopFilePaths = new Set(); + const pendingLoops = new Map(); + const loopsByPath = new Map(); + for (const loop of loops) { + if (!loop || typeof loop.filePath !== 'string' || !loop.filePath) { + continue; + } + activeLoopFilePaths.add(loop.filePath); + if (loop.definition && typeof loop.definition === 'object') { + pendingLoops.set(loop.definition.name, loop); + loopsByPath.set(loop.filePath, loop); + } + } + + const consumedLoopPaths = new Set(); + const nextTasks = []; + for (const task of tasks) { + if (task.loopFile && !activeLoopFilePaths.has(task.loopFile)) { + // The driving loop file was removed (or renamed) — unschedule. + continue; + } + + // Loop-owned tasks adopt by file path (covers renames of the `name` + // field); JSON tasks adopt by name. + const loop = task.loopFile + ? loopsByPath.get(task.loopFile) || null + : pendingLoops.get(task.name) || null; + if (loop) { + try { + const adopted = normalizeTaskForStorage( + { + ...task, + ...loop.definition, + // File-defined execution fields win; UI-only fields the file + // format does not define are preserved from the task. + execution: { ...task.execution, ...loop.definition.execution }, + loopFile: loop.filePath, + }, + { + now, + createId: taskIDFactory, + existingTask: task, + allowCreate: false, + refreshUpdatedAt: false, + }, + ); + nextTasks.push(adopted); + pendingLoops.delete(loop.definition.name); + if (task.loopFile) { + consumedLoopPaths.add(task.loopFile); + loopsByPath.delete(task.loopFile); + } + } catch (error) { + console.warn(`[scheduled-tasks] skipped loop ${loop.filePath} for task "${task.name}":`, error?.message ?? error); + nextTasks.push(task); + } + continue; + } + + if (task.loopFile && consumedLoopPaths.has(task.loopFile)) { + // Orphan duplicate: another task already adopted this loop file + // (left over from a rename) — unschedule it. + continue; + } + + nextTasks.push(task); + } + + for (const loop of pendingLoops.values()) { + try { + const id = `loop:${loop.scope}:${loop.definition.name}`; + const created = normalizeTaskForStorage( + { id, ...loop.definition, loopFile: loop.filePath }, + { + now, + createId: taskIDFactory, + existingTask: null, + allowCreate: true, + refreshUpdatedAt: false, + }, + ); + nextTasks.push(created); + } catch (error) { + console.warn(`[scheduled-tasks] skipped loop ${loop.filePath}:`, error?.message ?? error); + } + } + + await writeProjectConfigToDisk(projectID, { + version: PROJECT_CONFIG_VERSION, + scheduledTasks: nextTasks, + }); + + return nextTasks; + }); + }; + return { listScheduledTasks, upsertScheduledTask, deleteScheduledTask, updateScheduledTaskState, + reconcileLoopTasks, resolveProjectConfigPath, }; }; diff --git a/packages/web/server/lib/projects/project-config.test.js b/packages/web/server/lib/projects/project-config.test.js index ef7b5eb4..408866c8 100644 --- a/packages/web/server/lib/projects/project-config.test.js +++ b/packages/web/server/lib/projects/project-config.test.js @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import os from 'os'; import path from 'path'; import { mkdtemp, rm, readFile, writeFile } from 'fs/promises'; @@ -173,3 +173,302 @@ describe('project-config runtime', () => { } }); }); + +describe('project-config loop reconciliation', () => { + const loop = (name, overrides = {}) => ({ + scope: 'project', + filePath: `/repo/.agents/loops/${name}.md`, + definition: { + name, + enabled: true, + schedule: { kind: 'cron', cron: '0 9 * * *', timezone: 'UTC' }, + execution: { + prompt: `Loop prompt for ${name}`, + providerID: 'openai', + modelID: 'gpt-4.1', + }, + ...overrides, + }, + }); + + it('creates tasks for discovered loops with deterministic ids', async () => { + const { runtime, cleanup } = await createRuntime(); + try { + const tasks = await runtime.reconcileLoopTasks('project-test', [ + loop('daily-digest'), + loop('weekly-report'), + ]); + + expect(tasks).toHaveLength(2); + const digest = tasks.find((task) => task.name === 'daily-digest'); + expect(digest.id).toBe('loop:project:daily-digest'); + expect(digest.schedule.cron).toBe('0 9 * * *'); + expect(digest.execution.providerID).toBe('openai'); + expect(digest.loopFile).toBe('/repo/.agents/loops/daily-digest.md'); + + const reloaded = await runtime.listScheduledTasks('project-test'); + expect(reloaded).toHaveLength(2); + expect(reloaded[0].state.createdAt).toBeGreaterThan(0); + } finally { + await cleanup(); + } + }); + + it('adopts an existing task by name, preserving id and state, and persists state across reconciles', async () => { + const { runtime, cleanup } = await createRuntime(); + try { + const created = await runtime.upsertScheduledTask('project-test', { + name: 'daily-digest', + enabled: true, + schedule: { kind: 'daily', time: '09:30', timezone: 'UTC' }, + execution: { prompt: 'JSON prompt', providerID: 'openai', modelID: 'gpt-4.1' }, + }); + + const first = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]); + const adopted = first.find((task) => task.id === created.task.id); + expect(adopted).toBeDefined(); + expect(adopted.id).toBe(created.task.id); + expect(adopted.name).toBe('daily-digest'); + expect(adopted.schedule.kind).toBe('cron'); + expect(adopted.schedule.cron).toBe('0 9 * * *'); + expect(adopted.execution.prompt).toBe('Loop prompt for daily-digest'); + expect(adopted.loopFile).toBe('/repo/.agents/loops/daily-digest.md'); + + const state = adopted.state; + await runtime.updateScheduledTaskState('project-test', adopted.id, { + nextRunAt: 123456, + lastRunAt: 111, + lastStatus: 'success', + }); + + const second = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]); + const again = second.find((task) => task.id === created.task.id); + expect(again.id).toBe(created.task.id); + expect(again.state.nextRunAt).toBe(123456); + expect(again.state.lastRunAt).toBe(111); + expect(again.state.lastStatus).toBe('success'); + expect(again.loopFile).toBe('/repo/.agents/loops/daily-digest.md'); + } finally { + await cleanup(); + } + }); + + it('unschedules a loop-sourced task when its file is removed', async () => { + const { runtime, cleanup } = await createRuntime(); + try { + await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]); + const tasks = await runtime.reconcileLoopTasks('project-test', []); + + expect(tasks).toHaveLength(0); + expect(await runtime.listScheduledTasks('project-test')).toHaveLength(0); + } finally { + await cleanup(); + } + }); + + it('leaves JSON-configured tasks untouched when no loop matches', async () => { + const { runtime, cleanup } = await createRuntime(); + try { + const created = await runtime.upsertScheduledTask('project-test', { + name: 'json-only', + enabled: true, + schedule: { kind: 'daily', time: '08:00', timezone: 'UTC' }, + execution: { prompt: 'JSON prompt', providerID: 'openai', modelID: 'gpt-4.1' }, + }); + + const tasks = await runtime.reconcileLoopTasks('project-test', [loop('loop-only')]); + + expect(tasks).toHaveLength(2); + expect(tasks.find((task) => task.id === created.task.id)).toBeDefined(); + expect(tasks.find((task) => task.name === 'loop-only')).toBeDefined(); + } finally { + await cleanup(); + } + }); + + it('does not remove a JSON task that merely shares a loop name after the loop is gone... keeps it when never adopted', async () => { + // A JSON task that was never driven by a loop file (no loopFile marker) + // must survive reconciles even when a loop with the same name existed + // only in a previous reconcile round — but once a loop adopted it, the + // file is authoritative and removing the file unschedules the task. + const { runtime, cleanup } = await createRuntime(); + try { + const created = await runtime.upsertScheduledTask('project-test', { + name: 'daily-digest', + enabled: true, + schedule: { kind: 'daily', time: '09:30', timezone: 'UTC' }, + execution: { prompt: 'JSON prompt', providerID: 'openai', modelID: 'gpt-4.1' }, + }); + + // First reconcile adopts the task (loopFile marker set). + await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]); + // Loop file removed -> task unscheduled. + const afterRemoval = await runtime.reconcileLoopTasks('project-test', []); + expect(afterRemoval.find((task) => task.id === created.task.id)).toBeUndefined(); + } finally { + await cleanup(); + } + }); + + it('skips invalid loop definitions without blocking valid ones', async () => { + const { runtime, cleanup } = await createRuntime(); + try { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + const tasks = await runtime.reconcileLoopTasks('project-test', [ + loop('bad-loop', { schedule: { kind: 'cron', cron: 'not a cron', timezone: 'UTC' } }), + loop('good-loop'), + ]); + + expect(tasks.map((task) => task.name)).toEqual(['good-loop']); + expect(warn).toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + } finally { + await cleanup(); + } + }); + + it('renames a loop-sourced task in place when the loop name changes but the file stays', async () => { + // Identity for loop-owned tasks is the loop file path: changing the `name` + // field (or renaming via the UI) must not leave a stale duplicate running. + const { runtime, cleanup } = await createRuntime(); + try { + const first = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]); + const original = first.find((task) => task.name === 'daily-digest'); + + const renamed = await runtime.reconcileLoopTasks('project-test', [{ + scope: 'project', + filePath: '/repo/.agents/loops/daily-digest.md', + definition: { + name: 'digest', + enabled: true, + schedule: { kind: 'cron', cron: '0 9 * * *', timezone: 'UTC' }, + execution: { prompt: 'Loop prompt for digest', providerID: 'openai', modelID: 'gpt-4.1' }, + }, + }]); + + expect(renamed).toHaveLength(1); + const adopted = renamed[0]; + expect(adopted.id).toBe(original.id); + expect(adopted.name).toBe('digest'); + expect(adopted.loopFile).toBe('/repo/.agents/loops/daily-digest.md'); + expect(adopted.execution.prompt).toBe('Loop prompt for digest'); + } finally { + await cleanup(); + } + }); + + it('reverts a UI rename of a loop task back to the loop name on reconcile', async () => { + const { runtime, cleanup } = await createRuntime(); + try { + await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]); + const created = (await runtime.listScheduledTasks('project-test'))[0]; + + // The UI editor renamed the task; loopFile survives the write. + await runtime.upsertScheduledTask('project-test', { + id: created.id, + name: 'renamed-by-ui', + enabled: true, + schedule: { kind: 'daily', time: '09:30', timezone: 'UTC' }, + execution: { prompt: 'UI prompt', providerID: 'openai', modelID: 'gpt-4.1' }, + }); + + const after = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]); + expect(after).toHaveLength(1); + expect(after[0].id).toBe(created.id); + expect(after[0].name).toBe('daily-digest'); + expect(after[0].execution.prompt).toBe('Loop prompt for daily-digest'); + } finally { + await cleanup(); + } + }); + + it('keeps a loop-sourced task while its file exists but is currently unparseable', async () => { + // A transiently malformed file (mid-edit, bad merge) must not delete the + // task or its runtime state — only a genuinely removed file unschedules. + const { runtime, cleanup } = await createRuntime(); + try { + const first = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]); + const original = first[0]; + await runtime.updateScheduledTaskState('project-test', original.id, { + nextRunAt: 123456, + lastRunAt: 111, + lastStatus: 'success', + }); + + const after = await runtime.reconcileLoopTasks('project-test', [{ + scope: 'project', + filePath: '/repo/.agents/loops/daily-digest.md', + definition: null, + }]); + + expect(after).toHaveLength(1); + expect(after[0].id).toBe(original.id); + expect(after[0].name).toBe('daily-digest'); + expect(after[0].loopFile).toBe('/repo/.agents/loops/daily-digest.md'); + expect(after[0].schedule.cron).toBe('0 9 * * *'); + expect(after[0].state.nextRunAt).toBe(123456); + expect(after[0].state.lastStatus).toBe('success'); + } finally { + await cleanup(); + } + }); + + it('unschedules orphan duplicates of the same loop file', async () => { + // Zombie cleanup: two tasks driving one file (e.g. left over from a + // rename under the old name-identity rules) — the later one is removed. + const { runtime, cleanup } = await createRuntime(); + try { + const first = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]); + const original = first[0]; + await runtime.upsertScheduledTask('project-test', { + id: 'zombie-copy', + name: 'daily-digest-copy', + enabled: true, + loopFile: '/repo/.agents/loops/daily-digest.md', + schedule: { kind: 'daily', time: '09:30', timezone: 'UTC' }, + execution: { prompt: 'Stale copy', providerID: 'openai', modelID: 'gpt-4.1' }, + }); + + const after = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]); + + expect(after).toHaveLength(1); + expect(after[0].id).toBe(original.id); + expect(after.find((task) => task.id === 'zombie-copy')).toBeUndefined(); + } finally { + await cleanup(); + } + }); + + it('preserves UI-only execution fields when adopting a JSON task', async () => { + const { runtime, cleanup } = await createRuntime(); + try { + const created = await runtime.upsertScheduledTask('project-test', { + name: 'daily-digest', + enabled: true, + schedule: { kind: 'daily', time: '09:30', timezone: 'UTC' }, + execution: { + prompt: 'JSON prompt', + providerID: 'openai', + modelID: 'gpt-4.1', + variant: 'fast', + goalEnabled: true, + goalTokenBudget: 20000, + permissionAutoAccept: true, + }, + }); + + const adopted = await runtime.reconcileLoopTasks('project-test', [loop('daily-digest')]); + const task = adopted.find((entry) => entry.id === created.task.id); + expect(task.execution.prompt).toBe('Loop prompt for daily-digest'); + expect(task.execution.variant).toBe('fast'); + expect(task.execution.goalEnabled).toBe(true); + expect(task.execution.goalTokenBudget).toBe(20000); + expect(task.execution.permissionAutoAccept).toBe(true); + } finally { + await cleanup(); + } + }); +}); diff --git a/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md b/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md index 2fc61770..4be03824 100644 --- a/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md +++ b/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md @@ -5,7 +5,8 @@ Server-owned scheduled task runtime and routes for OpenChamber-only automation. ## Scope - Per-project scheduled task persistence is owned by `packages/web/server/lib/projects/project-config.js`. -- Runtime orchestration and execution is owned by this module. +- Markdown loop discovery/parsing is owned by `packages/web/server/lib/scheduled-tasks/loops.js`. +- Runtime orchestration and execution is owned by `packages/web/server/lib/scheduled-tasks/runtime.js`. - This module is OpenChamber feature logic; it is intentionally separate from OpenCode proxy/runtime internals. ## Files @@ -17,11 +18,90 @@ Server-owned scheduled task runtime and routes for OpenChamber-only automation. - Session create + prompt_async execution - Emits OpenChamber task-run events +- `packages/web/server/lib/scheduled-tasks/loops.js` + - Discovery of `.agents/loops/*.md` (project scope, ancestors up to the worktree root) and `~/.agents/loops/*.md` (user scope) + - Frontmatter parsing into scheduled-task definitions + - `syncProject` reconciles discovered loops with the persisted task list on every project sync (startup, task save/delete) + - `packages/web/server/lib/scheduled-tasks/routes.js` - Scheduled task CRUD endpoints - Manual run endpoint - OpenChamber events SSE stream endpoint +## Loop file format + +Portable, git-commit-able scheduled-task definitions: + +```markdown +--- +name: daily-digest +schedule: "0 9 * * *" +enabled: true +model: anthropic/claude-sonnet-4-5 +agent: plan +timezone: Europe/Kyiv +--- +Summarize repository changes since yesterday. +``` + +Field mapping (model: `packages/ui/src/lib/scheduledTasksApi.ts`): + +| Frontmatter | Task field | +|---|---| +| `name` | `name` (required, max 80 characters — longer names are rejected as malformed) | +| `schedule` | `schedule.kind: "cron"` + `schedule.cron` (required, cron-only in the portable format) | +| `enabled` | `enabled` (default `false` — a loop only runs when the file explicitly enables it; add `enabled: true` to activate) | +| `model` | split on the first `/` into `execution.providerID` / `execution.modelID` (required) | +| `agent` | `execution.agent` (optional) | +| `timezone` | `schedule.timezone` (optional, IANA; defaults to the server zone) | +| body | `execution.prompt` (required) | + +`thinking_level` and `goalEnabled`/`goalTokenBudget` are not part of the portable +format (UI/JSON-only today); `daily`/`weekly`/`once` schedules remain UI/JSON-only. +Runtime state (`lastRunAt`, `nextRunAt`, `lastStatus`, `lastError`, `lastSessionId`, +`lastDurationMs`) is never written to the markdown file — it continues to live in +the project config state store. + +## Loop reconciliation rules + +`projectConfigRuntime.reconcileLoopTasks(projectID, loops)` runs inside the +project write lock on every `syncProject` when the project path is known: + +- **Identity.** For loop-owned tasks (carrying the `loopFile` marker) identity + is the loop file path: a loop takes its task over regardless of the task's + current name, so renaming the loop (the `name` field, or a UI rename) renames + the task in place instead of leaving a stale duplicate behind. A loop whose + name matches a JSON task (no `loopFile`) takes that task over instead: its + schedule/execution/enabled are overwritten from the file while the task's + `id` and runtime `state` are preserved (markdown wins on conflict). +- **UI-only fields survive adoption.** Execution fields the file format does + not define (`goalEnabled`, `goalTokenBudget`, `permissionAutoAccept`, + `variant`) are preserved from the task when a loop adopts it; only fields the + file defines are re-applied. +- **Deletion.** A task carrying the `loopFile` marker whose loop file is no + longer discovered (removed or renamed) is unscheduled (removed from the + config). The marker is persisted in the config file, so removal is detected + across restarts. JSON-configured tasks without the marker are never removed. + A task whose loop file still exists but is currently unparseable is KEPT with + its last good definition — a transiently malformed file (mid-edit, bad merge) + never deletes a task or its runtime state. +- **Creation.** Loops without a matching task are created under a deterministic + `loop::` id so runtime state survives restarts. At most one task + is driven per loop file; orphan duplicates of the same file are unscheduled. +- **Scope precedence.** Project-scope loops shadow user-scope loops with the + same name; among project files the nearest ancestor wins. +- **Malformed files** (missing `name`/`schedule`/`model`/body, invalid cron, + unreadable) are reported to the scheduler as `definition: null` entries and + 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 + file remains authoritative: the next reconciliation re-applies the file's + definition (including `enabled`). Use `enabled: false` in the file to + disable. Deleting a loop-sourced task through the API is rejected with a 400 + while its loop file still exists on disk — the loop file is the removal + surface; once the file is gone, deleting the orphan task is allowed. The + 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) - `createScheduledTasksRuntime(dependencies)` diff --git a/packages/web/server/lib/scheduled-tasks/loops.js b/packages/web/server/lib/scheduled-tasks/loops.js new file mode 100644 index 00000000..7d15e671 --- /dev/null +++ b/packages/web/server/lib/scheduled-tasks/loops.js @@ -0,0 +1,209 @@ +/** + * Markdown loops — portable scheduled-task definitions. + * + * Loops are git-commit-able markdown files with YAML frontmatter, discovered + * from `.agents/loops/*.md` (project scope, including ancestor directories up + * to the worktree root) and `~/.agents/loops/*.md` (user scope), mirroring the + * skills discovery pattern (`packages/web/server/lib/opencode/skills.js`). + * + * File format: + * + * --- + * name: daily-digest + * schedule: "0 9 * * *" + * enabled: true + * model: anthropic/claude-sonnet-4-5 + * agent: plan + * timezone: Europe/Kyiv + * --- + * Summarize repository changes since yesterday and post the digest. + * + * Field mapping (see packages/ui/src/lib/scheduledTasksApi.ts): + * name -> task.name + * schedule -> task.schedule.kind "cron" + task.schedule.cron + * enabled -> task.enabled (default false — loops only run when the file + * explicitly enables them, so discovery never auto-executes + * repository content) + * model -> split into task.execution.providerID / task.execution.modelID + * agent -> task.execution.agent (optional) + * timezone -> task.schedule.timezone (optional, defaults to the server zone) + * body -> task.execution.prompt + * + * `thinking_level` and `goalEnabled`/`goalTokenBudget` are not part of the + * portable format (they are UI-only today); editing them in the file has no + * effect and they remain JSON/UI-only. + * + * Runtime state (lastRunAt, nextRunAt, lastStatus, ...) is never written to + * the markdown file; it continues to live in the project config/state store. + */ + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { parseMdFile, getAncestors, findWorktreeRoot } from '../opencode/shared.js'; +import { MAX_TASK_NAME_LENGTH } from '../projects/project-config.js'; + +const LOOP_DIR_NAME = 'loops'; +const USER_LOOP_ROOT = () => path.join(os.homedir(), '.agents', LOOP_DIR_NAME); + +const asNonEmptyString = (value) => { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +}; + +/** + * Split a `provider/model` string into its two parts. Splits on the first `/` + * so model ids containing a slash (e.g. `openai/gpt-5`) still resolve. + */ +const splitProviderModel = (value) => { + const raw = asNonEmptyString(value); + if (!raw) { + return null; + } + const separator = raw.indexOf('/'); + if (separator <= 0 || separator === raw.length - 1) { + return null; + } + return { + providerId: raw.slice(0, separator).trim(), + modelId: raw.slice(separator + 1).trim(), + }; +}; + +/** + * Parse one loop markdown file into a scheduled-task definition, or return + * null when the file is malformed. Malformed files are skipped with a warning + * and never prevent valid files from loading. + */ +export const parseLoopDefinition = (filePath) => { + let parsed; + try { + parsed = parseMdFile(filePath); + } catch (error) { + console.warn(`[loops] skipped malformed loop file ${filePath}:`, error?.message ?? error); + return null; + } + + const frontmatter = parsed.frontmatter && typeof parsed.frontmatter === 'object' + ? parsed.frontmatter + : {}; + const name = asNonEmptyString(frontmatter.name); + if (!name) { + console.warn(`[loops] skipped ${filePath}: frontmatter "name" is required`); + return null; + } + if (name.length > MAX_TASK_NAME_LENGTH) { + // Reject instead of clamping: task names are clamped to this length at + // storage time, so identity keys must match the stored value exactly. + console.warn(`[loops] skipped ${filePath}: frontmatter "name" exceeds ${MAX_TASK_NAME_LENGTH} characters`); + return null; + } + + const cron = asNonEmptyString(frontmatter.schedule); + if (!cron) { + console.warn(`[loops] skipped ${filePath}: frontmatter "schedule" (cron expression) is required`); + return null; + } + + const prompt = asNonEmptyString(parsed.body); + if (!prompt) { + console.warn(`[loops] skipped ${filePath}: markdown body (the execution prompt) is required`); + return null; + } + + const providerModel = splitProviderModel(frontmatter.model); + if (!providerModel) { + console.warn(`[loops] skipped ${filePath}: frontmatter "model" must be "provider/model"`); + return null; + } + + const timezone = asNonEmptyString(frontmatter.timezone); + const agent = asNonEmptyString(frontmatter.agent); + + return { + name, + enabled: typeof frontmatter.enabled === 'boolean' ? frontmatter.enabled : false, + schedule: { + kind: 'cron', + cron, + ...(timezone ? { timezone } : {}), + }, + execution: { + prompt, + providerID: providerModel.providerId, + modelID: providerModel.modelId, + ...(agent ? { agent } : {}), + }, + }; +}; + +const walkLoopMdFiles = (rootDir) => { + if (!rootDir || !fs.existsSync(rootDir)) { + return []; + } + try { + return fs.readdirSync(rootDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.md')) + .map((entry) => path.join(rootDir, entry.name)) + .sort(); + } catch { + return []; + } +}; + +/** + * Discover loop files for a project: `~/.agents/loops/*.md` (user scope) plus + * `.agents/loops/*.md` in every ancestor of the project path up to the + * worktree root (project scope). + */ +export const discoverLoopFiles = (projectPath) => { + const files = []; + for (const filePath of walkLoopMdFiles(USER_LOOP_ROOT())) { + files.push({ filePath, scope: 'user' }); + } + if (projectPath) { + const worktreeRoot = findWorktreeRoot(projectPath) || path.resolve(projectPath); + for (const ancestor of getAncestors(projectPath, worktreeRoot)) { + const root = path.join(ancestor, '.agents', LOOP_DIR_NAME); + for (const filePath of walkLoopMdFiles(root)) { + files.push({ filePath, scope: 'project' }); + } + } + } + return files; +}; + +/** + * Discover and parse all loops for a project. Project-scope loops shadow + * user-scope loops with the same name; among project files the nearest + * ancestor wins. + * + * Unparseable files are reported as `{ scope, filePath, definition: null }` + * entries instead of being dropped: the scheduler must distinguish "file is + * gone" (unschedule its task) from "file exists but is currently malformed" + * (keep its task with the last good definition until the file is fixed). + * Malformed files never block valid ones in the same or other scopes. + */ +export const discoverLoops = (projectPath) => { + const byName = new Map(); + const loops = []; + for (const { filePath, scope } of discoverLoopFiles(projectPath)) { + const definition = parseLoopDefinition(filePath); + if (!definition) { + loops.push({ scope, filePath, definition: null }); + continue; + } + const existing = byName.get(definition.name); + if (existing && (existing.scope === 'project' || scope === 'user')) { + continue; + } + byName.set(definition.name, { scope, filePath, definition }); + } + for (const entry of byName.values()) { + loops.push(entry); + } + return loops; +}; diff --git a/packages/web/server/lib/scheduled-tasks/loops.test.js b/packages/web/server/lib/scheduled-tasks/loops.test.js new file mode 100644 index 00000000..e05ef980 --- /dev/null +++ b/packages/web/server/lib/scheduled-tasks/loops.test.js @@ -0,0 +1,389 @@ +import { describe, expect, it, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { mkdtemp, rm, writeFile, mkdir } from 'fs/promises'; +import { parseLoopDefinition, discoverLoops, discoverLoopFiles } from './loops.js'; + +const createProject = async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-loops-')); + const projectPath = path.join(tempRoot, 'repo'); + await mkdir(projectPath, { recursive: true }); + await mkdir(path.join(projectPath, '.git'), { recursive: true }); + return { + projectPath, + cleanup: async () => { + await rm(tempRoot, { recursive: true, force: true }); + }, + }; +}; + +const writeLoop = async (projectPath, fileName, content) => { + const dir = path.join(projectPath, '.agents', 'loops'); + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, fileName), content, 'utf8'); +}; + +describe('parseLoopDefinition', () => { + it('maps frontmatter and body to the scheduled-task definition shape', async () => { + const { projectPath, cleanup } = await createProject(); + try { + await writeLoop(projectPath, 'digest.md', `--- +name: daily-digest +schedule: "0 9 * * *" +enabled: true +model: anthropic/claude-sonnet-4-5 +agent: plan +timezone: Europe/Kyiv +--- +Summarize repository changes since yesterday. +`); + + const definition = parseLoopDefinition(path.join(projectPath, '.agents', 'loops', 'digest.md')); + + expect(definition).toEqual({ + name: 'daily-digest', + enabled: true, + schedule: { kind: 'cron', cron: '0 9 * * *', timezone: 'Europe/Kyiv' }, + execution: { + prompt: 'Summarize repository changes since yesterday.', + providerID: 'anthropic', + modelID: 'claude-sonnet-4-5', + agent: 'plan', + }, + }); + } finally { + await cleanup(); + } + }); + + it('splits model ids containing a slash on the first separator', async () => { + const { projectPath, cleanup } = await createProject(); + try { + const filePath = path.join(projectPath, 'loop.md'); + await writeFile(filePath, `--- +name: nested-model +schedule: "0 8 * * 1" +model: openai/gpt-5 +--- +Run weekly checks. +`, 'utf8'); + + const definition = parseLoopDefinition(filePath); + + expect(definition.execution.providerID).toBe('openai'); + expect(definition.execution.modelID).toBe('gpt-5'); + expect(definition.enabled).toBe(false); + } finally { + await cleanup(); + } + }); + + it('defaults enabled to false and omits optional fields', async () => { + const { projectPath, cleanup } = await createProject(); + try { + const filePath = path.join(projectPath, 'loop.md'); + await writeFile(filePath, `--- +name: minimal +schedule: "*/30 * * * *" +model: openai/gpt-5 +--- +Run every half hour. +`, 'utf8'); + + const definition = parseLoopDefinition(filePath); + + // Loops only run when the file explicitly enables them: discovery of + // repository content must never auto-execute scheduled sessions. + expect(definition.enabled).toBe(false); + expect(definition.schedule).toEqual({ kind: 'cron', cron: '*/30 * * * *' }); + expect(definition.execution.agent).toBeUndefined(); + } finally { + await cleanup(); + } + }); + + it('honors an explicit enabled: true in the frontmatter', async () => { + const { projectPath, cleanup } = await createProject(); + try { + const filePath = path.join(projectPath, 'loop.md'); + await writeFile(filePath, `--- +name: explicit-enabled +schedule: "*/30 * * * *" +model: openai/gpt-5 +enabled: true +--- +Run every half hour. +`, 'utf8'); + + expect(parseLoopDefinition(filePath).enabled).toBe(true); + } finally { + await cleanup(); + } + }); + + it('returns null for files missing required frontmatter fields', async () => { + const { projectPath, cleanup } = await createProject(); + try { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + const noName = path.join(projectPath, 'noname.md'); + await writeFile(noName, `--- +schedule: "0 9 * * *" +model: openai/gpt-5 +--- +Prompt only. +`, 'utf8'); + expect(parseLoopDefinition(noName)).toBeNull(); + + const noSchedule = path.join(projectPath, 'noschedule.md'); + await writeFile(noSchedule, `--- +name: no-schedule +model: openai/gpt-5 +--- +Prompt only. +`, 'utf8'); + expect(parseLoopDefinition(noSchedule)).toBeNull(); + + const noModel = path.join(projectPath, 'nomodel.md'); + await writeFile(noModel, `--- +name: no-model +schedule: "0 9 * * *" +--- +Prompt only. +`, 'utf8'); + expect(parseLoopDefinition(noModel)).toBeNull(); + + const malformed = path.join(projectPath, 'malformed.md'); + await writeFile(malformed, 'not a markdown frontmatter file at all', 'utf8'); + expect(parseLoopDefinition(malformed)).toBeNull(); + } finally { + warn.mockRestore(); + } + } finally { + await cleanup(); + } + }); + + it('treats a missing body as an invalid loop', async () => { + const { projectPath, cleanup } = await createProject(); + try { + const filePath = path.join(projectPath, 'empty-body.md'); + await writeFile(filePath, `--- +name: empty-body +schedule: "0 9 * * *" +model: openai/gpt-5 +--- +`, 'utf8'); + + expect(parseLoopDefinition(filePath)).toBeNull(); + } finally { + await cleanup(); + } + }); + + it('rejects names longer than the storage limit', async () => { + const { projectPath, cleanup } = await createProject(); + try { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + const filePath = path.join(projectPath, 'long-name.md'); + await writeFile(filePath, `--- +name: ${'x'.repeat(81)} +schedule: "0 9 * * *" +model: openai/gpt-5 +--- +Run. +`, 'utf8'); + + // Task names are clamped to 80 chars at storage time; a raw name that + // exceeds it could never match the stored task, so the file is treated + // as malformed rather than creating an unreachable definition. + expect(parseLoopDefinition(filePath)).toBeNull(); + expect(warn).toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + } finally { + await cleanup(); + } + }); +}); + +describe('discoverLoops', () => { + it('discovers project loops and parses them', async () => { + const { projectPath, cleanup } = await createProject(); + try { + await writeLoop(projectPath, 'digest.md', `--- +name: daily-digest +schedule: "0 9 * * *" +model: openai/gpt-5 +--- +Summarize. +`); + + const loops = discoverLoops(projectPath); + + expect(loops).toHaveLength(1); + expect(loops[0].scope).toBe('project'); + expect(loops[0].definition.name).toBe('daily-digest'); + expect(loops[0].filePath.endsWith(path.join('.agents', 'loops', 'digest.md'))).toBe(true); + } finally { + await cleanup(); + } + }); + + it('scans ancestor directories up to the worktree root', async () => { + const { projectPath, cleanup } = await createProject(); + try { + // Worktree root contains the loop; the project directory is nested. + const nested = path.join(projectPath, 'src', 'nested'); + await mkdir(nested, { recursive: true }); + await writeLoop(projectPath, 'root-loop.md', `--- +name: root-loop +schedule: "0 9 * * *" +model: openai/gpt-5 +--- +From the root. +`); + + const loops = discoverLoops(nested); + + expect(loops.map((loop) => loop.definition.name)).toEqual(['root-loop']); + expect(loops[0].scope).toBe('project'); + } finally { + await cleanup(); + } + }); + + it('discovers user-scope loops from ~/.agents/loops', async () => { + const { projectPath, cleanup } = await createProject(); + const home = await mkdtemp(path.join(os.tmpdir(), 'oc-loops-home-')); + const userDir = path.join(home, '.agents', 'loops'); + await mkdir(userDir, { recursive: true }); + await writeFile(path.join(userDir, 'user-loop.md'), `--- +name: user-loop +schedule: "0 7 * * *" +model: openai/gpt-5 +--- +User scope. +`, 'utf8'); + const originalHome = os.homedir; + vi.spyOn(os, 'homedir').mockReturnValue(home); + + try { + const loops = discoverLoops(projectPath); + + expect(loops.map((loop) => loop.definition.name)).toEqual(['user-loop']); + expect(loops[0].scope).toBe('user'); + } finally { + os.homedir = originalHome; + await rm(home, { recursive: true, force: true }); + await cleanup(); + } + }); + + it('lets project scope shadow user scope on name collision', async () => { + const { projectPath, cleanup } = await createProject(); + const home = await mkdtemp(path.join(os.tmpdir(), 'oc-loops-home-')); + const userDir = path.join(home, '.agents', 'loops'); + await mkdir(userDir, { recursive: true }); + await writeFile(path.join(userDir, 'same-name.md'), `--- +name: shared +schedule: "0 7 * * *" +model: openai/gpt-5 +--- +User version. +`, 'utf8'); + await writeLoop(projectPath, 'same-name.md', `--- +name: shared +schedule: "0 8 * * *" +model: anthropic/claude-sonnet-4-5 +--- +Project version. +`); + const originalHome = os.homedir; + vi.spyOn(os, 'homedir').mockReturnValue(home); + + try { + const loops = discoverLoops(projectPath); + + expect(loops).toHaveLength(1); + expect(loops[0].scope).toBe('project'); + expect(loops[0].definition.execution.providerID).toBe('anthropic'); + expect(loops[0].definition.schedule.cron).toBe('0 8 * * *'); + } finally { + os.homedir = originalHome; + await rm(home, { recursive: true, force: true }); + await cleanup(); + } + }); + + it('reports malformed files as unparsed entries without blocking valid ones', async () => { + const { projectPath, cleanup } = await createProject(); + try { + await writeLoop(projectPath, 'bad.md', `--- +name: bad +schedule: "0 9 * * *" +--- +No model. +`); + await writeLoop(projectPath, 'good.md', `--- +name: good +schedule: "0 9 * * *" +model: openai/gpt-5 +--- +Valid. +`); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + const loops = discoverLoops(projectPath); + + // The malformed file stays visible as a `definition: null` entry so + // the scheduler can keep its task alive while the file is fixed. + const bad = loops.find((loop) => loop.filePath.endsWith(path.join('.agents', 'loops', 'bad.md'))); + expect(bad.definition).toBeNull(); + expect(bad.scope).toBe('project'); + + const good = loops.find((loop) => loop.filePath.endsWith(path.join('.agents', 'loops', 'good.md'))); + expect(good.definition.name).toBe('good'); + expect(warn).toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + } finally { + await cleanup(); + } + }); + + it('returns an empty list when nothing exists', async () => { + const { projectPath, cleanup } = await createProject(); + try { + expect(discoverLoops(projectPath)).toEqual([]); + } finally { + await cleanup(); + } + }); + + it('lists raw loop files per scope without parsing', async () => { + const { projectPath, cleanup } = await createProject(); + try { + await writeLoop(projectPath, 'one.md', `--- +name: one +schedule: "0 9 * * *" +model: openai/gpt-5 +--- +One. +`); + await writeFile(path.join(projectPath, 'not-a-loop.txt'), 'ignore me', 'utf8'); + + const files = discoverLoopFiles(projectPath); + + expect(files).toHaveLength(1); + expect(files[0].scope).toBe('project'); + expect(files[0].filePath.endsWith(path.join('.agents', 'loops', 'one.md'))).toBe(true); + } finally { + await cleanup(); + } + }); +}); diff --git a/packages/web/server/lib/scheduled-tasks/runtime.js b/packages/web/server/lib/scheduled-tasks/runtime.js index 5b1cffc7..62015b95 100644 --- a/packages/web/server/lib/scheduled-tasks/runtime.js +++ b/packages/web/server/lib/scheduled-tasks/runtime.js @@ -3,6 +3,7 @@ import { DateTime } from 'luxon'; import parser from 'cron-parser'; import { expandSnippets } from '../opencode/snippets.js'; import { buildGoalIntroText, createSessionGoal } from '../session-goal/create.js'; +import { discoverLoops } from './loops.js'; const DEFAULT_GLOBAL_CONCURRENCY = 4; const DEFAULT_PROJECT_CONCURRENCY = 2; @@ -382,8 +383,19 @@ export const createScheduledTasksRuntime = (deps) => { const syncProject = async (projectID) => { await ensureProjectPath(projectID); + const projectPath = projectPathByID.get(projectID) || null; + + let tasks; + if (projectPath) { + // Reconcile `.agents/loops` definitions with the persisted task list: + // loop files are authoritative while present, removed files unschedule + // their task, and runtime state is preserved (see loops.js). + const loops = await discoverLoops(projectPath); + tasks = await projectConfigRuntime.reconcileLoopTasks(projectID, loops); + } else { + tasks = await projectConfigRuntime.listScheduledTasks(projectID); + } - const tasks = await projectConfigRuntime.listScheduledTasks(projectID); setProjectTasks(projectID, tasks); for (const task of tasks) { diff --git a/packages/web/server/lib/scheduled-tasks/runtime.test.js b/packages/web/server/lib/scheduled-tasks/runtime.test.js index 7dafaca9..3a59b19f 100644 --- a/packages/web/server/lib/scheduled-tasks/runtime.test.js +++ b/packages/web/server/lib/scheduled-tasks/runtime.test.js @@ -1,5 +1,15 @@ -import { describe, expect, it } from 'vitest'; -import { computeNextRunAt, expandCommandGoalObjective, formatScheduledSessionTitle, parseScheduledCommandPrompt } from './runtime.js'; +import { describe, expect, it, vi } from 'vitest'; +import os from 'os'; +import path from 'path'; +import { mkdtemp, rm, mkdir, writeFile } from 'fs/promises'; +import { + computeNextRunAt, + expandCommandGoalObjective, + formatScheduledSessionTitle, + parseScheduledCommandPrompt, + createScheduledTasksRuntime, +} from './runtime.js'; +import { createProjectConfigRuntime } from '../projects/project-config.js'; describe('scheduled-tasks runtime helpers', () => { it('computes next daily run in timezone', () => { @@ -109,3 +119,90 @@ describe('scheduled-tasks runtime helpers', () => { .toBe('Review the requested scope.\n\nauth module'); }); }); + +describe('scheduled-tasks runtime syncProject wiring', () => { + const createTempProject = async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-runtime-loop-')); + const repoPath = path.join(tempRoot, 'repo'); + await mkdir(path.join(repoPath, '.agents', 'loops'), { recursive: true }); + return { + tempRoot, + repoPath, + cleanup: async () => { + await rm(tempRoot, { recursive: true, force: true }); + }, + }; + }; + + const createProjectConfig = async (tempRoot) => createProjectConfigRuntime({ + fsPromises: await import('fs/promises'), + path, + projectsDirPath: path.join(tempRoot, 'config'), + createTaskID: () => 'task-fixed-id', + }); + + const createRuntimeDeps = (overrides = {}) => ({ + buildOpenCodeUrl: () => 'http://localhost', + getOpenCodeAuthHeaders: () => ({}), + waitForOpenCodeReady: async () => {}, + ...overrides, + }); + + it('reconciles discovered loops when the project path is known', async () => { + const { tempRoot, repoPath, cleanup } = await createTempProject(); + try { + await writeFile(path.join(repoPath, '.agents', 'loops', 'daily.md'), `--- +name: daily +schedule: "0 9 * * *" +enabled: true +model: openai/gpt-5 +--- +Run daily. +`, 'utf8'); + + const projectConfigRuntime = await createProjectConfig(tempRoot); + const runtime = createScheduledTasksRuntime({ + ...createRuntimeDeps(), + projectConfigRuntime, + listProjects: async () => [{ id: 'proj', path: repoPath }], + }); + + await runtime.syncProject('proj'); + + const tasks = await projectConfigRuntime.listScheduledTasks('proj'); + expect(tasks).toHaveLength(1); + expect(tasks[0].id).toBe('loop:project:daily'); + expect(tasks[0].loopFile).toBe(path.join(repoPath, '.agents', 'loops', 'daily.md')); + // syncTaskSchedule computed and persisted the next run for the enabled task. + expect(tasks[0].state.nextRunAt).toBeGreaterThan(0); + } finally { + await cleanup(); + } + }); + + it('falls back to plain listing when the project path cannot be resolved', async () => { + const { tempRoot, cleanup } = await createTempProject(); + try { + const projectConfigRuntime = await createProjectConfig(tempRoot); + const reconcileSpy = vi.spyOn(projectConfigRuntime, 'reconcileLoopTasks'); + const listSpy = vi.spyOn(projectConfigRuntime, 'listScheduledTasks'); + + const runtime = createScheduledTasksRuntime({ + ...createRuntimeDeps(), + projectConfigRuntime, + // Project not registered -> ensureProjectPath cannot resolve a path. + listProjects: async () => [], + }); + + await runtime.syncProject('proj'); + + expect(reconcileSpy).not.toHaveBeenCalled(); + expect(listSpy).toHaveBeenCalledWith('proj'); + expect(await projectConfigRuntime.listScheduledTasks('proj')).toEqual([]); + reconcileSpy.mockRestore(); + listSpy.mockRestore(); + } finally { + await cleanup(); + } + }); +}); diff --git a/packages/web/server/lib/scheduled-tasks/service.js b/packages/web/server/lib/scheduled-tasks/service.js index 7d44f4e8..4d94251a 100644 --- a/packages/web/server/lib/scheduled-tasks/service.js +++ b/packages/web/server/lib/scheduled-tasks/service.js @@ -1,3 +1,4 @@ +import fs from 'node:fs'; import path from 'node:path'; import { OpenChamberControlError } from '../openchamber-control/error.js'; @@ -78,6 +79,19 @@ export const createScheduledTaskService = (dependencies) => { await findProjectByID(projectID); const normalizedTaskID = asNonEmptyString(taskID); if (!normalizedTaskID) throw new OpenChamberControlError('taskId is required', 400); + const current = await projectConfigRuntime.listScheduledTasks(projectID); + const existing = current.find((task) => task.id === normalizedTaskID) || null; + if (existing?.loopFile && fs.existsSync(existing.loopFile)) { + // Loop tasks are owned by their `.agents/loops` markdown file: deleting + // the JSON row would be silently undone by the next reconcile while the + // file exists. The file itself is the removal surface. Once the file is + // gone (the task is an orphan that the next sync would remove anyway), + // deleting the row is safe and allowed. + throw new OpenChamberControlError( + 'Loop task is managed by its .agents/loops markdown file; delete the file to remove the task', + 400, + ); + } const result = await projectConfigRuntime.deleteScheduledTask(projectID, normalizedTaskID); if (!result.deleted) throw new OpenChamberControlError('Task not found', 404); await scheduledTasksRuntime.syncProject(projectID); diff --git a/packages/web/server/lib/scheduled-tasks/service.test.js b/packages/web/server/lib/scheduled-tasks/service.test.js new file mode 100644 index 00000000..a90c1b64 --- /dev/null +++ b/packages/web/server/lib/scheduled-tasks/service.test.js @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from 'vitest'; +import os from 'os'; +import path from 'path'; +import { mkdtemp, rm, writeFile } from 'fs/promises'; +import { createScheduledTaskService } from './service.js'; + +const createService = (overrides = {}) => { + const projectConfigRuntime = { + listScheduledTasks: vi.fn(async () => []), + deleteScheduledTask: vi.fn(async () => ({ deleted: true, tasks: [] })), + ...(overrides.projectConfigRuntime || {}), + }; + const scheduledTasksRuntime = { + syncProject: vi.fn(async () => []), + ...(overrides.scheduledTasksRuntime || {}), + }; + const service = createScheduledTaskService({ + readSettingsFromDiskMigrated: async () => ({ + projects: [{ id: 'project-test', path: '/repo' }], + }), + sanitizeProjects: (projects) => projects, + projectConfigRuntime, + scheduledTasksRuntime, + }); + return { service, projectConfigRuntime, scheduledTasksRuntime }; +}; + +const loopTask = { + id: 'loop:project:daily-digest', + name: 'daily-digest', + enabled: true, + loopFile: '/repo/.agents/loops/daily-digest.md', + schedule: { kind: 'cron', cron: '0 9 * * *', timezone: 'UTC' }, + execution: { prompt: 'digest', providerID: 'openai', modelID: 'gpt-4.1' }, +}; + +describe('scheduled-task service remove', () => { + 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-')); + try { + const loopFilePath = path.join(tempRoot, 'daily.md'); + await writeFile(loopFilePath, '---\nname: daily-digest\n---\nRun.\n', 'utf8'); + + const { service, projectConfigRuntime, scheduledTasksRuntime } = createService({ + projectConfigRuntime: { + listScheduledTasks: vi.fn(async () => [{ ...loopTask, loopFile: loopFilePath }]), + }, + }); + + await expect(service.remove('project-test', loopTask.id)).rejects.toMatchObject({ + statusCode: 400, + message: expect.stringContaining('delete the file to remove the task'), + }); + expect(projectConfigRuntime.deleteScheduledTask).not.toHaveBeenCalled(); + expect(scheduledTasksRuntime.syncProject).not.toHaveBeenCalled(); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } + }); + + it('allows deleting a loop-sourced task once its loop file is gone', async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-loop-delete-')); + try { + // The loop file was removed from disk; the orphan task is allowed to be + // deleted directly instead of waiting for the next reconcile. + const loopFilePath = path.join(tempRoot, 'gone.md'); + + const { service, projectConfigRuntime, scheduledTasksRuntime } = createService({ + projectConfigRuntime: { + listScheduledTasks: vi.fn(async () => [{ ...loopTask, loopFile: loopFilePath }]), + }, + }); + + const tasks = await service.remove('project-test', loopTask.id); + + expect(projectConfigRuntime.deleteScheduledTask).toHaveBeenCalledWith('project-test', loopTask.id); + expect(scheduledTasksRuntime.syncProject).toHaveBeenCalled(); + expect(Array.isArray(tasks)).toBe(true); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } + }); + + it('deletes JSON-configured tasks normally', async () => { + const jsonTask = { ...loopTask, id: 'json-task', loopFile: undefined }; + const { service, projectConfigRuntime, scheduledTasksRuntime } = createService({ + projectConfigRuntime: { + listScheduledTasks: vi.fn(async () => [jsonTask]), + deleteScheduledTask: vi.fn(async () => ({ deleted: true, tasks: [] })), + }, + }); + + const tasks = await service.remove('project-test', jsonTask.id); + + expect(projectConfigRuntime.deleteScheduledTask).toHaveBeenCalledWith('project-test', jsonTask.id); + expect(scheduledTasksRuntime.syncProject).toHaveBeenCalled(); + expect(Array.isArray(tasks)).toBe(true); + }); +});