fix(tasks): guard loop name length and surface loop ownership in the UI
Review follow-up: - Reject loop files whose frontmatter name exceeds MAX_TASK_NAME_LENGTH (80): task names are clamped at storage time, so a raw name longer than the limit could never match the stored task identity. The file is treated as malformed (definition: null) instead of creating an unreachable definition; MAX_TASK_NAME_LENGTH is now exported from project-config.js and shared with loops.js. - Surface loop-sourced tasks in the scheduled-tasks dialog: tasks carrying loopFile show a 'Managed by loop file <path>' note, and the enable toggle / edit / delete actions are disabled with an explanatory tooltip, since the file remains authoritative and would revert any such change. run-now stays available. New locale keys added to all 11 message files (i18n parity test enforces exact key sets). - ScheduledTask type gains an optional loopFile field (additive, unknown to older clients).
This commit is contained in:
@@ -463,6 +463,14 @@ export function ScheduledTasksDialog() {
|
||||
<div className="typography-micro truncate text-muted-foreground">
|
||||
{formatSchedule(task, t)}
|
||||
</div>
|
||||
{task.loopFile ? (
|
||||
<div
|
||||
className="typography-micro truncate text-muted-foreground/70"
|
||||
title={task.loopFile}
|
||||
>
|
||||
{t('sessions.scheduledTasks.dialog.loopFile.note', { file: task.loopFile })}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-5 gap-y-1 typography-micro text-muted-foreground">
|
||||
@@ -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}
|
||||
>
|
||||
<Checkbox
|
||||
checked={task.enabled}
|
||||
@@ -534,7 +545,7 @@ export function ScheduledTasksDialog() {
|
||||
ariaLabel={task.enabled
|
||||
? t('sessions.scheduledTasks.dialog.taskToggle.pauseAria', { taskName: task.name })
|
||||
: t('sessions.scheduledTasks.dialog.taskToggle.enableAria', { taskName: task.name })}
|
||||
disabled={isBusy}
|
||||
disabled={isBusy || Boolean(task.loopFile)}
|
||||
/>
|
||||
{task.enabled ? t('sessions.scheduledTasks.dialog.taskToggle.enabled') : t('sessions.scheduledTasks.dialog.taskToggle.paused')}
|
||||
</label>
|
||||
@@ -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 })}
|
||||
>
|
||||
<Icon name="edit-2" className="h-4 w-4" /> {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 })}
|
||||
>
|
||||
<Icon name="delete-bin" className="h-4 w-4" />
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -269,6 +269,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
"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.",
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -269,6 +269,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'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': '新しいセッションを作成しプロンプトを送信するサーバーサイドタスクを設定します。',
|
||||
|
||||
@@ -269,6 +269,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'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': '새 세션을 만들고 프롬프트를 보내는 서버 작업을 설정합니다.',
|
||||
|
||||
@@ -396,6 +396,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'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.',
|
||||
|
||||
@@ -269,6 +269,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
"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.",
|
||||
|
||||
@@ -269,6 +269,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
"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": "Налаштувати завдання на стороні сервера, яке створює нову сесію і надсилає запит.",
|
||||
|
||||
@@ -269,6 +269,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'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': '配置一个服务端任务,用于创建新会话并发送提示词。',
|
||||
|
||||
@@ -282,6 +282,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'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': '設定一個伺服器端任務,用於建立新會話並傳送提示詞。',
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user