Files
openchamber/packages/web/server/lib/scheduled-tasks/loops.js
T
makeittech 59a6c1b70d 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).
2026-08-06 09:38:18 +03:00

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;
};