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).
210 lines
6.9 KiB
JavaScript
210 lines
6.9 KiB
JavaScript
/**
|
|
* 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;
|
|
};
|