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 57a22de2..0190f560 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 c77d6a07..5d0742a0 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 72985914..9d97a182 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 986a4cd4..5d40c830 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 0f4da4e7..352c6727 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 0f09fba2..5ce56da8 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 12f727b1..06c5aa7f 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 10dd7709..b33b7976 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 dd52bcb9..4b389c8d 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 35de2c9c..057e6637 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 6641606b..7c98d160 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 54166695..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; diff --git a/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md b/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md index 5bdba849..4de46ab5 100644 --- a/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md +++ b/packages/web/server/lib/scheduled-tasks/DOCUMENTATION.md @@ -48,7 +48,7 @@ Field mapping (model: `packages/ui/src/lib/scheduledTasksApi.ts`): | Frontmatter | Task field | |---|---| -| `name` | `name` (required) | +| `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) | @@ -97,7 +97,9 @@ project write lock on every `syncProject` when the project path is known: 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 — - the loop file is the removal surface. + the loop file is the removal surface. 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) diff --git a/packages/web/server/lib/scheduled-tasks/loops.js b/packages/web/server/lib/scheduled-tasks/loops.js index 1fd71be7..7d15e671 100644 --- a/packages/web/server/lib/scheduled-tasks/loops.js +++ b/packages/web/server/lib/scheduled-tasks/loops.js @@ -41,6 +41,7 @@ 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); @@ -94,6 +95,12 @@ export const parseLoopDefinition = (filePath) => { 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) { diff --git a/packages/web/server/lib/scheduled-tasks/loops.test.js b/packages/web/server/lib/scheduled-tasks/loops.test.js index 7281adda..e05ef980 100644 --- a/packages/web/server/lib/scheduled-tasks/loops.test.js +++ b/packages/web/server/lib/scheduled-tasks/loops.test.js @@ -181,6 +181,33 @@ model: openai/gpt-5 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', () => {